fix(terminal): polish pass — node-pty diagnostics, Ctrl+W guards, black flash, split grouping - #5170
fix(terminal): polish pass — node-pty diagnostics, Ctrl+W guards, black flash, split grouping#5170StiensWout wants to merge 23 commits into
Conversation
A broken node-pty install used to take the whole server down as a bare defect that headless startup logging could swallow, so npx t3 exited cleanly with no output. Write a diagnosis straight to stderr (toolchain hints, reinstall steps) and force a failing exit code before dying. When spawn-helper ships without its exec bit, every shell candidate fails with posix_spawnp and the error read like no shell existed on the machine. The chmod repair no longer disables itself after one failed attempt, failures are logged with the manual remedy, and spawn errors name the non-executable helper directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ault The window-level handler owns terminal.close but bails without preventDefault when the palette is open or no thread is active, letting mod+W fall through to the tab/window close default while the terminal is focused. Prevent the default at the xterm key handler like the sibling shortcuts already do. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closing the last terminal disposes the xterm mount, and while focus is in flight the held key's auto-repeats resolve no command, leak past the renderer guards, and hit the windowMenu close accelerator - one hold could take out the window (and on Windows/Linux the whole app). Drop auto-repeated CmdOrCtrl+W presses in before-input-event; deliberate single presses still work everywhere. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Reviewed the Effect service changes in apps/server/src/terminal/NodePtyAdapter.ts (plus its test) against the service conventions. Two findings, both in the new spawn-failure diagnostics path.
Posted via Macroscope — Effect Service Conventions
Review follow-up: the not-executable spawn-helper wrapper still carried posix_spawnp in its cause chain, so the manager retried every shell candidate (re-attempting chmod and logging a warning per candidate) before surfacing the actionable error. Treat that diagnosis as non-retryable. Also stop latching the ensure flag when the helper path resolves to null, since resolution failures are transient and folded into null. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
One new finding in this revision. The previously raised points about the manufactured plain Error and the process-exit side effects in the adapter still stand and are not re-posted here.
Posted via Macroscope — Effect Service Conventions
The ghostty surface appends an opaque canvas to the drawer before awaiting webfonts and the WASM core, and an alpha:false backing store is pure black until the first paint. Prefill the canvas with the theme background as soon as the context exists so a brand-new terminal opens straight into its themed background. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The drawer reconciles its terminal list against server session metadata, but its lag guard compared raw server ids while the store filters suppressed (closed) ids. One stale server session was enough to defeat the guard on every local change: a fresh split's id is not server-known yet, so reconcile dropped it and the next metadata update re-added it as its own group. The right panel has no such reconcile pass, which is why only the bottom bar misbehaved. The guard decision now lives in the store as a pure helper that filters suppressed ids before comparing, with regression tests for the stale- session split scenario. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Reviewed the terminal-polish changes for Effect service conventions. One leftover from the ChatView.tsx helper extraction; the earlier findings on NodePtyAdapter.ts/Manager.ts still stand and are not re-posted here.
Posted via Macroscope — Effect Service Conventions
…y to the CLI Review follow-ups from Macroscope: - model the non-executable spawn-helper failure as SpawnHelperNotExecutableError with a helperPath attribute, and key the terminal manager's retry decision off the tag instead of substring-matching a message - skip the chmod when the helper is already executable, so a read-only package store no longer logs the same warning on every spawn - render the node-pty load diagnostic and set the exit code in the CLI entrypoint rather than inside the adapter constructor - drop a docblock left behind in ChatView by the helper extraction Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
be1a799 to
45bc0dc
Compare
There was a problem hiding this comment.
One convention finding on the new SpawnHelperNotExecutableError declaration. The earlier feedback on error modeling and process-exit policy has been addressed.
Posted via Macroscope — Effect Service Conventions
Review follow-ups from Macroscope: a bare `mode & 0o111` calls an owner-only helper (0o100) executable for every other user, which skips the chmod repair and suppresses the actionable diagnosis for exactly the users hitting the failure. Effect's FileSystem.access cannot test X_OK, so derive executability from the mode against the process uid/gids. Also make the SpawnHelperNotExecutableError cause required, since every construction wraps a real spawn failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ApprovabilityVerdict: Needs human review This PR introduces significant new terminal state management (pending/suppressed id tracking, async resize acknowledgment, new error handling paths) spanning server and client. While each fix addresses real issues, the aggregate introduces new coordination patterns and state concepts that warrant human review. You can customize Macroscope's approvability policy. Learn more. |
…longer lose state Four review findings: - swallow auto-repeats of the close chord at the window handler, so a hold that unmounts the last terminal cannot fall through to the browser tab-close default once the shortcut stops resolving - roll back the optimistic close suppression when both the close request and the fallback exit write fail, instead of hiding a still-running terminal for the rest of the session; the right-panel close had no failure handling at all - merge pending local ids during reconcile rather than adopting the server list wholesale, so an update that carries an unknown server id no longer drops a split opened moments earlier - publish the diagnosed spawn failure to the client, so the actionable chmod +x remedy is no longer hidden behind the generic PtySpawnError wrapper Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review follow-up: the previous merge kept every client id, so a terminal that ended server-side was never removed once any new id arrived in the same update, leaving a dead tab on screen for good. A client-only id is ambiguous — either a local open the server has not registered yet, or a session that ended remotely — and id sets alone cannot tell them apart. Track ids introduced by split/new/ensure in a pending set, clear an id once the server reports it, and let reconcile keep only pending or server-visible ids. An empty server list is still treated as unloaded metadata so a reconnect cannot clear the drawer. The merge decision moves into the store, where the pending set lives. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review follow-up: pending ids are immune to reconcile, and every caller fired openTerminal without handling rejection, so a failed open left a terminal tab in the drawer that nothing could ever clean up. Add abandonPendingTerminal and call it from every path that marks an id pending: both split directions, new terminal, the drawer toggle, and the project-script runner. It clears the id without suppressing it, so a session that did get created after all can still be adopted. Also rolls back the optimistic close suppression in the third close path, matching the drawer and right-panel handlers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two review findings on the close-rollback path: - a failed right-panel close re-surfaced the live terminal in the bottom drawer, because the rollback only cleared drawer-store suppression while the panel surface stayed removed; restore the panel surface as well - the drawer reconcile effect only watched the server session list, so a suppression rollback did not resurface the terminal until unrelated metadata changed; subscribe to the suppression and pending sets that feed the store's reconcile decision Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ight Review follow-up: closing an optimistic tab while its open request is pending freed the id for the next terminal, so the first request's failure handler could tear down the second terminal's tab. Suppressed and pending ids now stay reserved during allocation in both the drawer and the chat-level handlers, so a stale failure rollback can only ever touch an id that no longer exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tcomes Four review findings on the rollback machinery: - a failed panel close now restores the terminal into its original surface at its original pane position and split direction via a pre-close snapshot and rightPanelStore.restoreTerminal, instead of a standalone surface that stranded split members - id reservation now tracks only opens actually in flight (a small per-thread registry held for the request window), instead of reserving every suppressed id, which would have skipped every terminal ever closed in the session and grown without bound - an interrupted open now abandons the optimistic id like a failed one: interruption is not confirmation, and if the session was created after all, reconcile re-adopts it from server metadata - an interrupted close now rolls back the optimistic suppression in all drawer, chat-level, and panel close paths, converging on server truth instead of treating interruption as success Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review re-check follow-ups plus a reported reflow artifact: - panel add/split now allocate against the in-flight reservation registry and open through the shared helper, so closing a panel terminal mid-open can no longer free its id for the next panel open while the request is unresolved - an interrupted panel close now parks its surface snapshot instead of guessing: authoritative session metadata restores the terminal into its original surface if the session survived, or drops the snapshot if it did not - the PTY now hears the leading edge of a resize burst as well as the settled size. Trailing-only notification left the shell on its old width for the whole drag while the grid rewrapped already-printed output, stranding fragments of a wrapped prompt above the live one on narrow panes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The previous leading-edge notify only helped the first step of a drag: the timer swallowed every intermediate step, so the shell's redraw sequences were still computed for one width and interpreted at another, and each mismatched redraw orphaned the previous prompt as a stranded fragment. Notify on every grid change instead, like a native terminal's SIGWINCH — fit() already coalesces to one call per crossed cell boundary, so a drag cannot flood the PTY. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Lockstep notification was not enough: the shell's redraw bytes are generated against the width the server pty had at generation time, and during a drag the client grid had already moved on, so each mismatched redraw still orphaned the previous prompt as a stranded fragment. Sequence the resize into the output stream instead. fit() requests the resize and keeps the old grid; the acknowledgement arrives on the same socket after all old-width output, so core.resize lands exactly where the server pty flipped and every byte is interpreted at the width it was generated for. Superseded acknowledgements are ignored via a sequence counter, and a failed request still applies locally so the grid cannot get stuck. Verified in a live paired session: clear + six wrap-crossing viewport steps leave a single correctly wrapped prompt with no stale fragments, where the debounced code stacked one fragment per redraw. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Codex-assisted diagnosis of the remaining resize artifact: the resize RPC layer coalesces with mode 'latest', and applyGridSize discarded acknowledgements superseded by a newer measurement. When a drag produced several measurements while one request was in flight, the server PTY visited widths the client grid skipped — the shell redrew for a width the emulator never occupied, which is exactly the stranded-fragment mechanism. This is why coarse jumps still failed while slow fine steps were clean. Resizes are now single-flight: one request at a time, its exact size always applied on settle, then the newest measurement is requested if it moved on. The emulator steps through every width the PTY entered, in order. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…esizes Two review findings: - recording a pending panel close now notifies subscribers, and the settling effect subscribes via useSyncExternalStore. Without this, a close recorded after its settling metadata already arrived — or one whose session survived, so the list never changes — left the terminal suppressed and paneless forever - a denied PTY resize no longer moves the grid: the drawer resolves the acknowledgement with the request outcome, and the surface keeps the grid at the PTY's size and re-aligns the desired size so the same measurement retries instead of being skipped. The pre-grant exception keeps mount bookkeeping aligned with the constructed core Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ndling - pending panel closes settle only after a grace window, so a close whose metadata has not propagated yet cannot be restored from a stale cached session list and pin a dead pane; entries stay pending until post-grace metadata decides, with tests for the stale-cache window - the first-settle bookkeeping exception applies exactly once: repeated failed resizes before any grant no longer keep moving the grid while the PTY stays put - a resize while the terminal process is not running now records the session dimensions, so restart (which falls back to session.cols/rows) spawns at the size the client was acknowledged for instead of the stale one Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…llback gaps Review follow-ups, the first being the root ordering gap: - the server resize reply now orders itself behind every output byte queued before the resize, via a flush sentinel drained through the same per-session event queue; queue wipes release sentinels so awaiters cannot hang. The client can therefore apply its grid on the reply knowing no old-width bytes follow, with a Manager ordering test - a failed or interrupted resize now schedules a retry of the measured size until the PTY grants it, instead of leaving the emulator on a size the server never confirmed - a definite open failure now also rolls back the optimistic right-panel surface or split pane, not just the drawer-store pending id - an authoritative empty session list settles pending panel closes (the closed terminal may have been the last session), distinguished from unloaded metadata via the query's loaded state - surviving snapshots restore in reverse close order so nested split closes reinsert at their original pane positions Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 8026b6a. Configure here.
Reopening a terminal replays its history, and stored capability queries made the terminal answer again — the shell then echoed the replies as junk at the prompt (the 2026;2$y…1$r0m / 1;2c / 11;rgb:… gibberish from the field reports). The history sanitizer already dropped DSR, CPR, DA, and OSC colour traffic; it now also strips DECRQM queries and DECRPM replies ($-intermediate p/y), XTVERSION and kitty-keyboard queries, and DECRQSS/XTGETTCAP DCS queries and replies. Setters sharing final bytes — DECSTR, DECSCL, DECSCUSR, restore-cursor — survive, covered by a regression test. Prevents new junk at the source; residue already flattened into stored scrollback as plain text is out of scope here (PR pingdotgg#3508 tackles that broader stripping). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review follow-up: dequeue compacts the pending queue before the dequeued chunk is published, so a resize landing in that window saw an empty queue, skipped the flush sentinel, and acked ahead of the old-width bytes. A running drain now also gates the flush — the sentinel is picked up when the drain loop re-enters, behind the in-flight publication. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Terminal polish pass
Five terminal fixes that share one theme: the terminal backend fails loudly instead of silently, and the terminal UI stops eating destructive or surprising interactions.
Fixes #4924
Fixes #2787
Addresses #5129
Server: node-pty load failures are loud and actionable
A broken node-pty install (missing toolchain, wrong Node ABI) died as a bare startup defect that headless logging could swallow —
npx t3exited cleanly with no output. The failure stays typed in the adapter, and the CLI entrypoint now renders its diagnostic to stderr (what node-pty is, the two usual causes, the reinstall steps) and forces a non-zero exit code.This makes the failure diagnosable; it does not solve the underlying missing-toolchain/prebuild problem in #5129, and it overlaps with #5132 — happy to rebase on whichever lands first.
Server: spawn-helper exec-bit repair and diagnosis
When npm extracts node-pty without the exec bit on
spawn-helper, every shell candidate fails withposix_spawnp failedand the final error read like no working shell existed on the machine. Now:FileSystem.accesscannot testX_OK;SpawnHelperNotExecutableErrorcarryinghelperPath, and the shell-candidate fallback short-circuits on its tag rather than on message text.Web + desktop: held Ctrl+W cannot close the window
The terminal close shortcut never called
preventDefaultin the terminal key handler, and after the last terminal closes, held-key auto-repeats resolve no command and leak to Electron's windowMenu close accelerator — one hold could close the window (and on Windows/Linux, quit the app). The web handler now prevents the default like its sibling shortcuts, and the desktop window drops auto-repeatedCmdOrCtrl+Winbefore-input-event. Deliberate single presses still close terminals and windows as before.Web: brand-new terminals no longer flash black
The ghostty surface appends an opaque (
alpha: false) canvas to the drawer before awaiting webfonts and the WASM core, and an opaque backing store is pure black until the first paint. The canvas is now prefilled with the theme background as soon as the 2D context exists.Web: bottom-bar splits stay in their group
The drawer's reconcile-against-server-metadata pass compared raw server session ids while the store filters suppressed (closed) ids. One stale server session defeated the "server list is lagging" guard on every local change: a fresh split's id is not server-known yet, so reconcile dropped it and the next metadata update re-added it as its own group. The guard decision is now a pure store helper that filters suppressed ids before comparing, with regression tests. The right panel never ran this reconcile pass, which is why splitting only misbehaved in the bottom bar.
Web: PTY and grid resize in lockstep, sequenced through the stream
Resizing a narrow pane left stranded fragments of the wrapped prompt above the live one. The PTY resize was trailing-debounced while the local grid reflowed immediately, so the shell's redraw sequences were computed for one width and interpreted at another; each mismatched redraw orphaned the previous prompt. The grid now requests the resize and applies it only when the PTY acknowledges — the acknowledgement arrives on the same socket after all old-width output, so every byte is interpreted at the width it was generated for. Verified in a live paired session: wrap-crossing resize sequences that previously stacked a fragment per redraw now leave a single correctly wrapped prompt.
Web: panel opens reserved, interrupted panel closes settled by metadata
Follow-ups from the re-check: the right-panel add/split paths now allocate against the in-flight reservation registry and open through the shared rollback helper, and an interrupted panel close parks its surface snapshot until authoritative session metadata either restores the terminal into its original surface (session survived) or drops it (session closed), instead of guessing.
Server: replayable query traffic stripped from terminal history
Reopening a terminal replays its history, and stored capability queries made the terminal answer again — the shell echoed the replies as visible junk at the prompt (the
2026;2$y…1$r0m/1;2c/11;rgb:…gibberish from #1238). The history sanitizer now also strips DECRQM/DECRPM ($p/$y), XTVERSION and kitty-keyboard queries, and DECRQSS/XTGETTCAP DCS traffic, while setters sharing final bytes (DECSTR, DECSCL, DECSCUSR, restore-cursor) survive. This prevents new junk at the source; residue already flattened into stored scrollback as plain text is deliberately left to #3508, which tackles that broader live-stream stripping.Work done by Claude Fable 5 via Claude Code.
🤖 Generated with Claude Code
Note
Medium Risk
Changes span PTY spawn/exec-bit logic, terminal event ordering, and optimistic UI rollback across drawer and right panel; regressions could affect terminal open/close, resize timing, or split grouping, though coverage is strong in tests.
Overview
This PR tightens terminal backend failures and web/desktop terminal UX so broken installs and optimistic UI do not strand users.
Server: The CLI now writes typed startup defects (e.g. node-pty load) to stderr and sets a non-zero exit. node-pty spawn-helper repair retries chmod, judges exec against the caller’s uid/gids, and surfaces
SpawnHelperNotExecutableErrorwith achmodfix instead of retrying every shell. Resize queues a flush sentinel so resize replies publish after old-width output. History sanitization strips more replayable CSI/DCS query traffic (DECRQM, XTVERSION, kitty keyboard, DECRQSS/XTGETTCAP) while keeping setters like DECSCUSR.Web: The ghostty canvas is prefilled with the theme background and grid resize waits on PTY ack (single-flight with retry). Terminal UI state adds pending vs suppressed ids,
reconcilableServerTerminalIds, and rollback viaunsuppressTerminal/abandonPendingTerminal. Open reservations avoid id collisions; failed/interrupted closes restore drawer tabs or park panel snapshots until metadata settles (terminalPendingPanelCloses). Ctrl+W auto-repeat is swallowed at window and chat level; terminal close usespreventDefaultin the surface.Desktop: Electron blocks auto-repeated Cmd/Ctrl+W so held close shortcuts do not close the window after the last terminal unmounts.
Reviewed by Cursor Bugbot for commit 013841c. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Fix terminal black flash, Ctrl+W auto-repeat, split grouping, and node-pty spawn diagnostics
CmdOrCtrl+Wauto-repeat events in both the Electronbefore-input-eventhandler and the web keydown handler, preventing accidental window/tab closure when holding the close shortcut as a terminal unmountsrestoreTerminalaction in the right-panel storeuseTerminalUiStateStoreso unconfirmed locally opened terminals stay visible across reconcile cycles; addsunsuppressTerminalandabandonPendingTerminalactionsSpawnHelperNotExecutableErrorwith achmodremedy when node-pty spawn fails due to a non-executable spawn-helper, skipping shell retries and reporting diagnostics to stderr on startupMacroscope summarized 013841c.