Stop stale and phantom tab state after a command finishes - #210
Merged
Conversation
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.
Contributor
Window-state benchmark
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 |
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.
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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."--executeinvocation as an idle shell. ghostty's command-wrapper launches every nushell pane asnu --execute 'use ghostty *'.shellInvocationRunsCommanddidn't know--executetakes a value, so it readuse ghostty *as a command word —foregroundProcessIsShellreturned false for every nushell pane, permanently.starship, and the paused poll then froze that wrong name in place.Why
A hook-heavy shell forks a real external process on every prompt —
starship 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 (samplingtcgetpgrp+ICANONevery 20ms, exactly whatProcessInspectorreads) caughtstarshipholding the foreground for ~64ms immediately after Return. Andlsin 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:
activityQuietPollWorkprecisely so an occluded pane can still settle; foreground-owned runs rely entirely on the poll. ButPollCadence.modereturns.paused— no timer at all — when no window is visible and the app is inactive, andisAnyPaneBusyonly 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 (
didBecomeActive→pollNow). Fix 1 independently mattered because without it the tracker never seesnewKey == niland 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--executeruns 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-filewere 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
shellIsAtPrompttoTerminalExecutionTracker: armed by OSC 133;D inmarkCommandFinished(unconditionally, ahead of the.runningguard — 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 bymarkProgressStarted. 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, andmise run testall passEnd-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 plainautoload/; andnu -cdoes not loadconfig.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 flipsfalse → 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/shellsand a literalnuargv wouldn't parse as a shell on a runner without nushell.TerminalExecutionTrackerTests.promptHookAfterCompletion_cannotStartForegroundRun—starship,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 stillrunningat 2s and clears at 3s; classified correctly it'sdoneon 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
runninguntil the user returns (then resolves todonecorrectly). That's the same.pausedcadence behavior, but for a genuine command the eventualdoneis 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:
onCommandFinishedonly 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).nilalways 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.programTitleexpiry 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 — publishesstarshipwherenuis 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:
PollCadenceis 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.