Skip to content

Stop stale and phantom tab state after a command finishes - #210

Merged
thdxg merged 3 commits into
mainfrom
claude/prompt-hook-shell-classification
Aug 3, 2026
Merged

Stop stale and phantom tab state after a command finishes#210
thdxg merged 3 commits into
mainfrom
claude/prompt-hook-shell-classification

Conversation

@thdxg

@thdxg thdxg commented Aug 3, 2026

Copy link
Copy Markdown
Owner

What

Two fixes for one reported bug: "run a simple ls, the spinner appears for a while — but it refreshes state when I click on the app."

  1. Recognize a shell-integration --execute invocation as an idle shell. ghostty's command-wrapper launches every nushell pane as nu --execute 'use ghostty *'. shellInvocationRunsCommand didn't know --execute takes a value, so it read use ghostty * as a command word — foregroundProcessIsShell returned false for every nushell pane, permanently.
  2. Don't let a prompt hook start a run the poll can never end. Gate foreground-owned starts on whether the shell is sitting at a prompt, which OSC 133;D already tells us.
  3. Refresh a pane's identity when its command ends. Nothing re-read the process table on OSC 133;D, so a finished agent's name and icon lingered until the next poll tick.
  4. Keep prompt hooks out of the pane's name. A poll landing inside a hook renamed the tab to starship, and the paused poll then froze that wrong name in place.

Why

A hook-heavy shell forks a real external process on every promptstarship prompt, mise hook-env, zoxide — and the foreground poll cannot distinguish one from a user command: same canonical tty, same non-shell foreground. A pty probe of a real nushell (sampling tcgetpgrp + ICANON every 20ms, exactly what ProcessInspector reads) caught starship holding the foreground for ~64ms immediately after Return. And ls in nushell is a builtin — it never appears as a process at all, so the hook is the only thing the poll can see.

Starting a run from that evidence is worse than a brief wrong glyph, for two compounding reasons:

  • The hook is already dead when the poll spots it, so no transition remains to observe.
  • A foreground-owned run has no self-settling wake. Activity-owned runs got activityQuietPollWork precisely so an occluded pane can still settle; foreground-owned runs rely entirely on the poll. But PollCadence.mode returns .pausedno timer at all — when no window is visible and the app is inactive, and isAnyPaneBusy only holds fast cadence while the app is frontmost.

So the phantom sits there until something wakes the poll — which is exactly why clicking the app appeared to fix it (didBecomeActivepollNow). Fix 1 independently mattered because without it the tracker never sees newKey == nil and so loses its authoritative return-to-prompt completion edge, leaving the 3s quiet-settle as the only exit; that's the 3-second variant of the same symptom.

General rule this earns: never start a foreground-owned run from evidence you cannot later observe ending.

How

Fix 1 is one entry in shellOptionConsumesNextArgument. Unlike -c, nushell's --execute runs its code and then enters the REPL, so the invocation genuinely is an idle shell. It completes the flag set rather than special-casing nu: --rcfile/--init-file were already listed for bash, and --execute (shell_integration.zig:810) is the only other flag ghostty injects. Blast radius is one consumer — foregroundProcessIsShell, feeding only the status indicator; tab naming, OSC-title provenance and layout capture use name-only checks.

Fix 2 adds shellIsAtPrompt to TerminalExecutionTracker: armed by OSC 133;D in markCommandFinished (unconditionally, ahead of the .running guard — a fast command's D lands while the pane still reads idle, and the hooks that follow are exactly the ones to suppress), cleared by a nonempty submission and by markProgressStarted. A blank Return deliberately does not clear it. Only foreground starts are gated; output and progress evidence are untouched.

The gate is armed only by OSC 133;D, never by the poll's own return-to-shell observation — arming it from the poll would break integration-less shells entirely, since their next real command would be suppressed as prompt noise.

Verified

  • mise run format, mise run lint, and mise run test all pass
  • Built and ran the change in the app and confirmed the behavior
  • Added or updated tests for new model / persistence / palette / hotkey logic

End-to-end A/B — measured as a RATE, and one correction. An earlier version of this description claimed the harness seeded a deliberately slow prompt hook to make the race deterministic. That hook silently never loaded (nushell auto-sources vendor/autoload, not a plain autoload/; and nu -c does not load config.nu, which invalidated my check of it). The observed phantoms therefore came from the real starship/mise/zoxide hooks, so the numbers below are reported as a catch rate over many trials rather than as a deterministic result.

Unit tests pin each layer, since no single test spans both:

  • ProcessInspectorTests.shellIntegrationExecuteInvocation_isIdleShell — the real argv flips false → true. Fails without fix 1. Derives the shell from the host's login shell rather than hardcoding a nushell path, since shell names come from /etc/shells and a literal nu argv wouldn't parse as a shell on a runner without nushell.
  • TerminalExecutionTrackerTests.promptHookAfterCompletion_cannotStartForegroundRunstarship, mise, and a blank Return all fail to start a run; the next real submission still does. Fails without fix 2 (3 assertions).
  • TerminalExecutionTrackerTests.withoutShellIntegration_foregroundDetectionIsUnchanged — a shell that never emits D never arms the gate, so CI's bash 3.2 keeps today's behavior.
  • TerminalExecutionTrackerTests.promptHookRun_endsAtOncePerShellClassification_insteadOfQuietSettling — replays the observed sequence under both classifications: misclassified it's still running at 2s and clears at 3s; classified correctly it's done on the next poll.

Notes for reviewers

Reattached zmx sessions never fire D before their first completion, so a session that comes back with a command already running still registers — the gate can only be armed by an observed completion.

Known remaining gap, deliberately not addressed here: if a real command finishes while the window is occluded and the app is inactive, the poll is paused, so the tab keeps showing running until the user returns (then resolves to done correctly). That's the same .paused cadence behavior, but for a genuine command the eventual done is the intended signal, so it needs a design decision rather than a patch.

Naming (commits 3–4)

Reported separately by the same user, same root cause, different code path: "the tab name becomes stale sometimes — it refreshes when I click the app", and "when I quit claude code, the agent icon only disappears when I make an interaction."

The pane's name and agent icon were refreshed only by the adaptive poll, which slows to 2s when the app is inactive and stops outright once no window is visible. Two consequences:

  • Nothing refreshed on command end. A command boundary is exactly when a pane's identity changes, and OSC 133;D already reports it — but onCommandFinished only touched run state. So quitting claude left its name and logo up until some unrelated event re-sampled. Now that closure refreshes the foreground, unconditionally (names and icons are shown whether or not the status indicator is on).
  • A hook could become the name. While the shell is at a prompt nothing the user launched is running, so a non-shell foreground there can only be a prompt hook — ignored for naming. Shell names and nil always pass, so returning to the prompt has no lag, and a submitted command is named on the first poll because submitting clears the prompt state. programTitle expiry stays keyed on the pid so a title is never misattributed, and a shell without OSC 133 never arms the gate.

Tests: promptHookNeverBecomesTheTabName (fails without the gate — publishes starship where nu is required), submittedCommandIsNamedImmediately (no added latency for real commands), withoutShellIntegration_namingIsUnchanged.

Design note for review. This PR makes two state refreshes event-driven that previously depended on the poll, but the underlying asymmetry remains: PollCadence is the only thing keeping names fresh, and it deliberately pauses to save idle CPU (#110). Anything derived from the process table can therefore still go stale while the app is inactive. The durable direction is to drive identity from terminal events (command start/end, output) and treat the poll as a backstop — worth a follow-up rather than widening this change.

ghostty's command-wrapper launches every nushell pane as
`nu --execute 'use ghostty *'` to load shell integration. The argv scan
in shellInvocationRunsCommand did not know --execute takes a value, so it
read `use ghostty *` as a command word and reported the pane's idle
prompt as foreground work — foregroundProcessIsShell returned false for
every nushell pane, permanently.

That costs TerminalExecutionTracker its authoritative completion edge:
it never sees the shell (newKey == nil), so a return to the prompt looks
like a non-shell program switching to raw mode, which demotes the run to
activity ownership. The only remaining exit is the 3-second quiet-settle,
so any run started by a prompt hook (starship, mise, zoxide all fork a
real process per prompt) left the tab spinner up for a full 3 seconds
after a command that finished in milliseconds.

Unlike -c, nushell's --execute runs its code and then enters the
interactive REPL, so the invocation genuinely is an idle shell. This
completes the value-taking flag set: --rcfile/--init-file were already
listed for bash, and --execute is the only other flag ghostty injects.
@github-actions github-actions Bot added the area:tests Test changes label Aug 3, 2026
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Window-state benchmark

State Metric main@794f1e1c8 this branch Δ
focused CPU % 1.00 1.00 +0%
Memory (RSS MB) 105.8 107.7 +2%
CPU ms/s (powermetrics) 9.4 9.7 +3%
Wakeups/s (powermetrics) 182.4 173.1 -5%
workload-focused CPU % 2.80 2.60 -7%
Memory (RSS MB) 159.1 166.4 +5%
CPU ms/s (powermetrics) 27.3 25.1 -8%
Wakeups/s (powermetrics) 272.6 258.9 -5%
workload-unfocused CPU % 2.50 2.60 +4%
Memory (RSS MB) 162.2 169.4 +4%
CPU ms/s (powermetrics) 23.1 25.6 +10%
Wakeups/s (powermetrics) 270.0 268.8 -0%

Reported value is the median of 3×10s windows per state (splitting the window and taking the median keeps one co-scheduled spike from skewing a state); CPU % is the process CPU-time delta over a window. Runs land on different shared runners, so treat small deltas as noise — 🔺/🔻 marks changes ≥25% that also clear the metric's absolute noise floor (CPU % ≥0.5, Memory (RSS MB) ≥25, CPU ms/s ≥5, Wakeups/s ≥50); CPU deltas off a noise-dominated baseline aren't flagged (CPU % baseline ≥1.5, CPU ms/s baseline ≥15). The benchmark:regression / benchmark:improvement label needs corroboration — ≥2 flagged metrics in the same direction, at least one under workload — so a lone noisy cell shows its arrow here without tagging the PR.

A hook-heavy shell forks a REAL external process on every prompt —
starship prompt, mise hook-env, zoxide — and the foreground poll cannot
tell one from a user command: same canonical tty, same non-shell
foreground. A pty probe of a real nushell shows starship holding the
foreground for ~64ms right after Return, which is squarely inside the
250ms burst cadence.

Starting a run from that evidence is worse than a brief wrong glyph,
because the hook is typically already dead when the poll spots it, so
no transition remains to observe — and unlike an activity-owned run, a
foreground-owned one has no self-settling wake. PollCadence returns
.paused (no timer at all) for an occluded, inactive app, so the phantom
spinner stays up until something wakes the poll. That is what made it
look like the state 'refreshes when I click on the app'.

Gate foreground STARTS on whether the shell is sitting at a prompt,
which OSC 133 already tells us: D arms it (unconditionally — a fast
command's D lands while the pane still reads idle, and those following
hooks are exactly the ones to suppress), a nonempty submission clears
it, a blank Return does not. Output and progress evidence are
untouched, and a shell with no integration never arms the gate, so
bash 3.2 keeps today's foreground behavior exactly.
@github-actions github-actions Bot added the area:state AppState, models, persistence label Aug 3, 2026
@thdxg thdxg changed the title Recognize a shell-integration --execute invocation as an idle shell Stop prompt hooks from stranding the tab spinner after a fast command Aug 3, 2026
…s out of it

Two naming defects with the same root: the pane's name and agent icon
were only ever refreshed by the adaptive poll, and that poll slows to 2s
when the app is inactive and stops entirely once no window is visible.

First, nothing re-read the process table when a command ENDED. Quitting
claude left its name and agent logo on the tab until the next poll tick,
which — with the window occluded — meant until the user clicked or typed.
A command boundary is precisely when a pane's identity changes, and OSC
133;D already tells us about it, so refresh there instead of waiting.
This is unconditional: names and agent icons show regardless of the
status-indicator pref.

Second, a prompt hook could BECOME the name. starship/mise/zoxide each
fork a real process per prompt, so a poll landing in one renamed the tab
to 'starship' — and with the poll then paused, that wrong name stuck.
While the shell sits at a prompt nothing the user launched is running, so
a non-shell foreground there can only be a hook: ignore it for naming.
Shell names and nil always pass, so returning to the prompt has no lag,
and a submitted command names immediately because submitting clears the
prompt state. Title expiry stays keyed on the pid so a title can never be
misattributed, and a shell without OSC 133 never arms the gate at all.
@github-actions github-actions Bot added the area:ui Views, Settings UI label Aug 3, 2026
@thdxg thdxg changed the title Stop prompt hooks from stranding the tab spinner after a fast command Stop stale and phantom tab state after a command finishes Aug 3, 2026
@thdxg
thdxg merged commit 30fef69 into main Aug 3, 2026
10 checks passed
@thdxg
thdxg deleted the claude/prompt-hook-shell-classification branch August 3, 2026 07:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:state AppState, models, persistence area:tests Test changes area:ui Views, Settings UI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant