diff --git a/DESIGN.md b/DESIGN.md index c8fbcbb2..6ae89974 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -207,6 +207,12 @@ The icon-and-tooltip button used inside pane headers (kill, alert toggle, todo, - **Hover:** `hover:bg-current/10` — a 10%-opacity wash of the current text color. Theme-agnostic, works light or dark. - **Tooltip:** rendered through a portal as a `PopupButtonRow` 8px below the button, with `text-sm` primary line and an optional muted detail line. Keybindings inside the tooltip auto-render as `[bracketed]` shortcuts. +#### Popup Button (`popupButton`) +The flat segments inside a `PopupButtonRow` — the row owns the border, background, shadow, and `text-sm`, so a segment contributes only padding and tone. +- **Tones:** `foreground` (default), `muted` (`text-muted`, hover to full foreground), and `primary` — `bg-header-active-bg` + `text-header-active-fg`, for the one emphasized action in rows that ask rather than offer (the restored pane's resume offer). +- **Hover:** the default wash is `hover:bg-foreground/10`. A filled tone holds its fill instead — the wash sets *background*, so on `primary` it would replace the accent rather than sit over it. This matches the modal primary button, which likewise has no hover treatment. +- **Flash:** `flashed` swaps in `animate-copy-flash` with `bg-header-active-bg/25` for copy-confirm moments. + #### Chrome Button (window controls) The Windows/Linux native-style window control row in the standalone app bar. - **Variants:** `icon` (h-5 min-w-5, hover bg-current/10), `labeled` (h-5 min-w-5 px-1.5 text-xs), `window` (w-11, hover bg-current/10), `windowClose` (w-11, hover bg `#b92a1b` text-white). diff --git a/docs/specs/layout.md b/docs/specs/layout.md index bbf07e14..6dce6cde 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -335,6 +335,7 @@ For a terminal Surface the pane ID is its session ID. `TerminalPane` calls `getO - **Create**: `getOrCreateTerminal` spawns xterm.js + UnicodeGraphemesAddon + FitAddon + PTY, returns existing if already created. The xterm instance sets `allowProposedApi: true` because UnicodeGraphemesAddon activates through xterm's proposed Unicode API. The WebGL addon is *not* loaded at create — it is claimed lazily on the session's first mount (see "Renderer" below). - **Resume**: `resumeTerminal` creates xterm entry and writes replay data without spawning a new PTY. Used when the webview is recreated while the host retains Live PTYs (Link: Severed → Resuming → Live). - **Restore**: `restoreTerminal` creates xterm entry and spawns a new PTY with saved cwd and scrollback. Used on cold start from a saved Snapshot (Link: Cold → Live). +- **Resume offer**: a restored pane whose snapshot carried a `resumeCommand` (`docs/specs/transport.md`) offers to run it — the replayed scrollback ends in an agent's resume hint, but the process that wrote it is gone and the pane now holds a fresh shell. See "Resume offer" below. - **Untouched**: new `getOrCreateTerminal` sessions start untouched. `isUntouched(id)` exposes the flag, and user-originated PTY input clears it via the registry input paths. Resume/restore seed the persisted flag; missing legacy snapshot data defaults to touched (`false`) so close confirmation remains conservative. - **Shell selection replacement**: the standalone shell dropdown and VS Code shell picker send `dormouse:new-terminal` with `replaceUntouched` when the selected shell type changes. `Wall` always creates a new session id and a fresh `surface:N` ref for that request. If the currently selected pane or door is untouched, the new terminal takes over the same leaf via a Lath `replace` op (an atomic identity swap; doors first reattach through the normal restore path), the old untouched session is disposed, and the replaced Surface's ref is retired. If the selected terminal is touched or no terminal is selected, the request spawns a new pane beside the selected one. Announced shell-selection spawns show a transient pane-anchored notice such as `Switched to zsh` or `Opened bash`. - During resume/restore replay, xterm.js may emit terminal-generated replies for OSC/CSI/DCS queries that were embedded in saved output. The registry drops those replay-time replies before they reach the new shell. This filter is limited to query/focus reports, and must not swallow user keyboard escape sequences such as arrows, function keys, or bracketed paste. @@ -342,6 +343,20 @@ For a terminal Surface the pane ID is its session ID. `TerminalPane` calls `getO - **Dispose**: `disposeSession` kills the PTY, disposes xterm, removes the registry entry. Only called on explicit kill (`x`). - **Swap**: the Cmd/Ctrl+Arrow swap trades two leaf identities via a Lath `swap` op — per-leaf metadata and registry entries are keyed by id, so they follow the swap with no DOM reattach or title swap (see "Cmd/Ctrl+Arrow swap" above). +### Resume offer + +A cold **restore** replays a Session's saved scrollback into a *fresh* shell. When that scrollback ended in an agent's resume hint, the snapshot carries the command as `PersistedPane.resumeCommand` (`docs/specs/transport.md`), and the restored pane offers to run it: two buttons at the pane's bottom-right, `Run ` and `Dismiss`. + +- **Seeded by restore only.** `restoreSession` seeds the offer per terminal pane; **resume** never does, because there the process is still Live and has nothing to resume. Browser surfaces are skipped with the rest of the terminal restore path. +- **Retired** by taking it, dismissing it, the user's first input into the pane, the pane's process exiting, or session dispose. Input counts from every path that reaches the PTY: keyboard input through xterm, direct clipboard paste, file-drop path insertion, a Pocket client's remote keystrokes (`remote-api.ts` → `#write`), and `dor send`. Only the xterm path runs through `onData`; each direct platform write retires the offer itself. Replay-shaped terminal reports do not count. It does not survive into the next save — the offer lives only in the runtime store, and the next restore re-seeds from the snapshot. +- **Untouched is not the gate.** A Session that ran an agent is touched by definition, so `isUntouched` would suppress the offer in exactly the case it exists for. Retirement keys off *post-restore* input instead. +- **Hidden, not retired, while a command is running** (`activity.kind === 'running'`): the offer types into the shell, and a shell with a foreground process is not listening. Shells without OSC integration report `unknown` and keep the offer. +- **Taking it** selects the pane, enters passthrough, and uses the normal deferred pane-click focus transition. It revalidates the command, drops out if the Session has no registry entry or its process already exited (a dead shell can run nothing, and the seed below would be a command start nothing ever finishes — the pane would count as running forever in `countRunningSessions`), marks the Session touched, seeds `commandLine` + `commandStart(user_input)` so semantic state is already `running`, then writes `\r` straight to the PTY. The explicit seed is required because the platform write bypasses xterm's keystroke fallback; an OSC-integrated shell may replace it with authoritative boundaries afterward. Not a bracketed paste — bracketing exists to stop an embedded newline from executing, which is the opposite of the intent. A click landing before the fresh shell has drawn its first prompt can still be swallowed by shell startup, the same hazard `typeCommandWhenPromptReady` guards for launched commands; the offer accepts it rather than delaying the button, since restore-then-click is far slower than spawn-then-type. +- **The button carries the invocation, not the command.** `Run claude --resume`, never `Run claude --resume ` — the session id is already on screen in the replayed scrollback directly above. The full command is the button's tooltip. +- **Chrome is the pane-overlay vocabulary**, not the modal one: a `PopupButtonRow` owning the border/background/shadow, with `popupButton` segments inside — the same recipe as `MouseOverrideBanner` in the opposite corner. Run takes the row's `primary` tone (`DESIGN.md` → Popup Button); Dismiss is `muted`. + +Source of truth: `lib/src/lib/resume-offers.ts` (store), `runResumeCommand` in `lib/src/lib/terminal-lifecycle.ts`, `lib/src/components/wall/ResumeBanner.tsx` (the pane-mounted offer + its presentational `ResumeBannerView`), seeded in `lib/src/lib/session-restore.ts`. + ### Renderer Every terminal renders through stock `@xterm/addon-webgl`, claimed lazily by @@ -505,7 +520,9 @@ The refill adopts the replacement (`selectPane`) only when the current selection | `lib/src/lib/session-save.ts` | Serialization: collects layout, scrollback, cwd, alert state for persistence | | `lib/src/lib/session-restore.ts` | Deserialization: loads saved session, calls `restoreTerminal()` for each pane | | `lib/src/lib/reconnect.ts` | Priority-based recovery: live PTYs first, then saved session, then empty | -| `lib/src/lib/resume-patterns.ts` | Detects resumable commands (`claude --resume`, etc.) in scrollback | +| `lib/src/lib/resume-patterns.ts` | Detects resumable commands (`claude --resume`, etc.) in scrollback, newest line first, and labels them with the invocation alone | +| `lib/src/lib/resume-offers.ts` | Pending resume offers per Session — seeded by cold restore, retired on take/dismiss/input/dispose | +| `lib/src/components/wall/ResumeBanner.tsx` | The restored pane's `Run ` / `Dismiss` offer | | `lib/src/index.css` | Lath host styling — `.lath-host` / `.lath-leaf` / `.lath-sash` / drop-preview layout and background flattening | | `lib/src/theme.css` | Two-layer VSCode theme token system (`@theme --color-*` → `--vscode-*`) and Tailwind v4 `@theme` integration | diff --git a/docs/specs/mouse-and-clipboard.md b/docs/specs/mouse-and-clipboard.md index a68dea7f..720a6b5a 100644 --- a/docs/specs/mouse-and-clipboard.md +++ b/docs/specs/mouse-and-clipboard.md @@ -220,7 +220,7 @@ Only the single extension step described above is offered. There is no multi-lev ### 8.1 Overview -Paste reads the system clipboard and writes the content to the PTY. Paste keystrokes are **intercepted by the terminal**, not forwarded to the inside program. The inside program only receives the pasted bytes (optionally wrapped in bracketed-paste markers; see §8.5). +Paste reads the system clipboard and writes the content to the PTY. Paste keystrokes are **intercepted by the terminal**, not forwarded to the inside program. The inside program only receives the pasted bytes (optionally wrapped in bracketed-paste markers; see §8.5). A non-empty clipboard or file-path paste counts as user input: before the direct PTY write it marks the Session touched and retires any cold-restore resume offer (`docs/specs/layout.md`). Paste behavior differs by platform to match each OS's native convention. diff --git a/docs/specs/terminal-state.md b/docs/specs/terminal-state.md index c0733946..f90174db 100644 --- a/docs/specs/terminal-state.md +++ b/docs/specs/terminal-state.md @@ -169,6 +169,8 @@ Non-OSC title source: The `user_input` command fallback is best effort and renderer-only: it is sufficient for headers and grouping, but the `AlertManager` never sees it. Command-exit alerting and command-keyed WATCHING both need real shell integration, and `docs/specs/alert.md` records that as a deliberate limitation rather than plumbing the heuristic into a second command-tracking path. +Programmatic interactive launches that write directly to the platform PTY bypass xterm's keystroke fallback. They therefore emit `commandLine` + `commandStart(source: "user_input")` synchronously before the write: both `dor split/ensure -- ` and a cold-restore resume offer use `seedLaunchedCommand`. This keeps headers, grouping, and `countRunningSessions` correct on shells without OSC integration; an integrated shell's later boundaries remain authoritative. Source of truth: `seedLaunchedCommand` and its callers in `lib/src/lib/terminal-state-store.ts` and `lib/src/lib/terminal-lifecycle.ts`. + The parser accepts both BEL and ST terminators and handles split chunks. Supported-but-malformed semantic OSCs are consumed without changing state. Unsupported OSC pass-through vs. consume/ignore behavior is defined centrally in `docs/specs/terminal-escapes.md`. ## Reducer @@ -202,6 +204,7 @@ first authentic OSC boundary ──▶ pane promoted to OSC-driven; fallback ret - **Shape survival and reconnect seeding.** The prompt shape survives across commands (it does not reset on `promptStart`/`promptEnd`/`commandStart`) and is pre-seeded from restored scrollback on session restore / VS Code panel reopen, so the first command after a reconnect — when the live shell will not re-emit its prompt — is still titled. Seeding is learn-only and fires no prompt transition. - **Swap safety.** The fallback resolves the current Session id from the PTY id before recording submit input or prompt-looking output, so drag-to-swap moves the fallback state — including the learned prompt shape — with the visible pane. - **Synthesized idle transitions.** Visible output that looks like a returned shell prompt always refreshes the learned prompt shape, but only synthesizes the idle prompt transition when `currentCommand.source === "user_input"`. This keeps shape learning available for all shells while scoping the finish/start synthesis to shells that do not emit command finish/start OSCs (OSC-tracked shells drive their own boundaries). +- **Control stripping.** Prompt-looking output is judged over the last 1024 chars of a pane's output, with alt-screen spans dropped and terminal presentation controls removed by the shared `stripTerminalControls` (`lib/src/lib/terminal-controls.ts`, also used by resume-hint detection — `docs/specs/transport.md`). That tail cuts mid-sequence routinely, so an *unterminated* string control swallows the rest of the buffer instead of surrendering its payload: a half-arrived title OSC carrying the prompt string would otherwise land as the last visible line and flip a running command back to idle. - **Per-pane retirement.** The keystroke fallback and real OSC 633/133 integration are mutually exclusive per pane. The first authentic OSC boundary a pane emits (`promptStart`/`promptEnd`/`commandFinish` always, or a `commandStart` whose source is an OSC boundary — not `user_input`) promotes the pane to **OSC-driven**, after which the keystroke path stops recording: `recordTerminalUserInput` early-returns and no further `user_input` `commandStart`/`commandLine` is synthesized, so injected shells never double-count. The synthesized prompt markers the fallback itself emits are passed with a `keystrokeHeuristic` flag so they do **not** trigger promotion — otherwise the fallback would retire the very path that emits them. The flag is per-pane runtime state, seeded fresh and cleared on pane reset/removal; it is not persisted. ### CWD precedence @@ -265,4 +268,5 @@ Windows UNC display labels keep `\\server\share\` as the path root and do not re |------|------| | `lib/src/lib/terminal-state.ts` | Pure semantic model: types, reducer, CWD precedence, header derivation, grouping | | `lib/src/lib/terminal-state-store.ts` | React-facing store; PTY-id → pane-id resolution; keystroke fallback recording (`recordTerminalUserInput`) | +| `lib/src/lib/terminal-controls.ts` | `stripTerminalControls` — shared presentation-control stripping for consumers that read raw output as content | | `lib/src/lib/terminal-protocol.ts` | Semantic OSC parsing that emits `TerminalSemanticEvent` (parsing location rules in `docs/specs/terminal-escapes.md`) | diff --git a/docs/specs/transport.md b/docs/specs/transport.md index 542ca5d6..e14278f9 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -139,7 +139,9 @@ The wrapping lives at the **standalone adapter boundary**, not in the shared sav Versioning: the standalone top-level snapshot is a `PersistedWindow` (its own `version: 1`) wrapping v3 sessions. `readPersistedWindow` drops Workspaces whose inner session is unreadable and repairs a dangling `activeWorkspaceId` to the first Workspace; an unreadable or corrupt blob is logged and discarded so startup continues fresh rather than blocking on a bad save. VS Code hands back a bare `PersistedSession` — its single Workspace. -**Persisted scrollback cap.** Each terminal pane's persisted scrollback is capped at 100,000 chars, keeping the tail cut at a line boundary (with the trailing `\n` preserved, per "Scrollback trailing newline" below) so N busy panes can't rewrite N MB on every save. The trim happens where scrollback is resolved for persistence — the frontend save path (`session-save.ts`) and the VS Code host-side refresh (`vscode-ext/src/session-state.ts`) both apply it — before resume-command detection, which is unaffected because resume patterns live at the tail. The sidecar's larger in-memory live-buffer cap (`standalone/sidecar/pty-core.js`) is unchanged. Source of truth: `lib/src/lib/scrollback-trim.ts`. +**The resume command.** Each terminal `PersistedPane` records `resumeCommand`: the agent resume invocation detected at the tail of the scrollback being saved (`claude --resume `, `claude --continue`, `codex resume `), or `null`. Detection strips terminal presentation controls and accepts only the agents' opaque ASCII id grammar (alphanumeric, hyphen, underscore); shell punctuation is never captured into executable state. Text *around* the invocation is not part of that judgement — a hint printed mid-sentence (`` Resume with `claude --resume `. ``) is read, because the persisted command is rebuilt as invocation + captured id and anything trailing the id is dropped rather than carried. The one thing that must follow the invocation is a word break, so `claude --continuex` is not an offer to continue. The scan window is stripped as a whole *before* it is split into segments, so a string control whose payload spans an LF is removed as a unit, and an *unterminated* string control (OSC, DCS, SOS, PM, APC) swallows the rest of the window rather than surrendering its payload — a window title cut mid-sequence does not read as terminal output. "Terminated" tracks what the renderer honours rather than ECMA-48 alone: ST in both forms (`\x1b\\`, `\x9c`), BEL for OSC, and — because xterm's parser aborts a string control on CAN/SUB and ends one on a bare ESC — those three as well, so a prompt printed behind an aborted sequence stays visible instead of being swallowed. A payload whose *introducer* fell off the front of the window (the scrollback trim, or the sidecar's chunk-evicting buffer, can strand one) is not recoverable at this layer, since nothing left in view marks it as payload; it grants no more than ordinary output already does, which is a source of offers by design. Stripping is the shared `stripTerminalControls` in `lib/src/lib/terminal-controls.ts` — one implementation, so a hardening step cannot reach detection while missing the prompt detector that reads the same kind of tail slice (`docs/specs/terminal-state.md`). Restore seeding and Run revalidate the canonical command as defense against snapshots written by an older detector. Detection scans the last 50 LF-delimited raw segments **newest first** and chooses the rightmost match within a segment, so carriage-return-only redraws still select the newest visible hint and pattern order never outranks recency. A pane that resumed more than once therefore persists the command for its *current* session rather than a stale id. The field is derived with the trim, never carried forward from the previous save — a pane that resumed again has a newer hint at the tail, and a stale id would make restore offer the wrong session. Both writers go through one helper for that reason: the frontend save path (`session-save.ts`) and the VS Code host-side PTY refresh at deactivate (`vscode-ext/src/session-state.ts`), which rewrites `scrollback` from the live PTYs. Cold restore turns this field into the pane's resume offer (`docs/specs/layout.md` → "Resume offer"); nothing reads it on resume. Source of truth: `detectResumeCommand` / `normalizeResumeCommand` in `lib/src/lib/resume-patterns.ts`, paired with the trim by `terminalPersistedContent` in `lib/src/lib/session-types.ts`. + +**Persisted scrollback cap.** Each terminal pane's persisted scrollback is capped at 100,000 chars, keeping the tail cut at a line boundary (with the trailing `\n` preserved, per "Scrollback trailing newline" below) so N busy panes can't rewrite N MB on every save. The trim happens where scrollback is resolved for persistence — both save paths apply it through `terminalPersistedContent` (`session-types.ts`), which also derives the resume command from the trimmed text; detection is unaffected by the cut because resume patterns live at the tail. The sidecar's larger in-memory live-buffer cap (`standalone/sidecar/pty-core.js`) is unchanged. Source of truth: `lib/src/lib/scrollback-trim.ts`. Every saved-session entry point must pass through `readPersistedSession()`. That reader accepts both the canonical parsed object and a JSON-stringified session blob before validating it (covering host state APIs that may hand back the inner serialized JSON string); a present-but-unreadable blob is logged and discarded so the caller starts fresh — a corrupt save can never block startup. diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 9eb753f7..3850e5cb 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -4,7 +4,7 @@ ## What's built -Dormouse has two hosting modes: a `WebviewView` in the bottom panel (alongside Terminal, Problems, Output) and `WebviewPanel` editor tabs (via `dormouse.open`, supports multiple instances). Both restore across "Developer: Reload Window". PTY lifecycle is fully decoupled from the webview — PTYs live in the extension host via `pty-manager.ts`, survive panel visibility toggling, and replay buffered output on **resume**. Session persistence works across cold **restore**: pane layout, CWD, scrollback, alert state (enabled/disabled + todo), and resume commands are saved and restored on cold start. The view uses `workspaceState` for persistence; editor panels use VS Code's per-panel `vscode.setState()` so multiple panels don't clobber each other. Alert state is merged into every periodic save (not just deactivate) so it survives even if VS Code kills the extension host before deactivate completes. A `WebviewPanelSerializer` handles editor tab restoration; `onWebviewPanel:dormouse` activation event ensures the extension activates early enough. Theme integration uses VSCode `--vscode-*` tokens plus Dormouse semantic `--color-*` tokens, with a small resolver that materializes missing consumed VSCode colors from registry defaults. CSP is strict with nonce-gated scripts. +Dormouse has two hosting modes: a `WebviewView` in the bottom panel (alongside Terminal, Problems, Output) and `WebviewPanel` editor tabs (via `dormouse.open`, supports multiple instances). Both restore across "Developer: Reload Window". PTY lifecycle is fully decoupled from the webview — PTYs live in the extension host via `pty-manager.ts`, survive panel visibility toggling, and replay buffered output on **resume**. Session persistence works across cold **restore**: pane layout, CWD, scrollback, and alert state (enabled/disabled + todo) are saved and restored on cold start, and each pane's detected resume command comes back as its resume offer (`docs/specs/layout.md` → "Resume offer"). The deactivate-time PTY refresh re-derives that command from the scrollback it writes, so it never lags the buffer it came from (`docs/specs/transport.md` → "The resume command"). The view uses `workspaceState` for persistence; editor panels use VS Code's per-panel `vscode.setState()` so multiple panels don't clobber each other. Alert state is merged into every periodic save (not just deactivate) so it survives even if VS Code kills the extension host before deactivate completes. A `WebviewPanelSerializer` handles editor tab restoration; `onWebviewPanel:dormouse` activation event ensures the extension activates early enough. Theme integration uses VSCode `--vscode-*` tokens plus Dormouse semantic `--color-*` tokens, with a small resolver that materializes missing consumed VSCode colors from registry defaults. CSP is strict with nonce-gated scripts. **Architecture:** @@ -56,6 +56,7 @@ Frontend Library (lib/src/) ├── session-restore.ts — cold-start pane restoration ├── session-types.ts — PersistedSession/PersistedPane/PersistedAlertState types ├── resume-patterns.ts — detect resumable commands from scrollback + ├── resume-offers.ts — pending resume offers, seeded by cold restore ├── resolve-pane-element.ts — resolve a pane element to its Lath leaf (overlay measurement) └── platform/ ├── types.ts — PlatformAdapter interface diff --git a/lib/src/components/TerminalPane.tsx b/lib/src/components/TerminalPane.tsx index 3bf14448..5f679713 100644 --- a/lib/src/components/TerminalPane.tsx +++ b/lib/src/components/TerminalPane.tsx @@ -10,6 +10,7 @@ import { import { SelectionOverlay } from './SelectionOverlay'; import { SelectionPopup } from './SelectionPopup'; import { MouseOverrideBanner } from './wall/MouseOverrideBanner'; +import { ResumeBanner } from './wall/ResumeBanner'; import { TERMINAL_BOTTOM_RADIUS_CLASS } from './design'; import { throttleTrailing } from '../lib/throttle'; @@ -70,6 +71,7 @@ export function TerminalPane({ id, isFocused = true }: TerminalPaneProps) { + ); } diff --git a/lib/src/components/design.tsx b/lib/src/components/design.tsx index 4ef9eed3..8672f954 100644 --- a/lib/src/components/design.tsx +++ b/lib/src/components/design.tsx @@ -94,12 +94,22 @@ export const popupButton = tv({ tone: { foreground: '', muted: 'text-muted hover:text-foreground', + // The row's one emphasized action, for popups that ask rather than offer + // (the restored pane's resume offer). Same accent pair as a modal's + // primary button — the Active-Header-Doubles-As-Accent rule (DESIGN.md). + primary: 'bg-header-active-bg text-header-active-fg', }, flashed: { true: 'animate-copy-flash bg-header-active-bg/25 text-header-active-bg', false: 'hover:bg-foreground/10', }, }, + compoundVariants: [ + // The unflashed hover is a background wash, which would REPLACE a filled + // tone's background rather than sit over it. Hold the fill instead; a filled + // button already reads as the action without a hover cue. + { tone: 'primary', flashed: false, class: 'hover:bg-header-active-bg' }, + ], defaultVariants: { tone: 'foreground', flashed: false }, }); diff --git a/lib/src/components/wall/ResumeBanner.test.tsx b/lib/src/components/wall/ResumeBanner.test.tsx new file mode 100644 index 00000000..b363c46c --- /dev/null +++ b/lib/src/components/wall/ResumeBanner.test.tsx @@ -0,0 +1,138 @@ +/** + * @vitest-environment jsdom + */ +import { act, StrictMode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ResumeBanner } from './ResumeBanner'; +import { + __resetResumeOffersForTests, + getResumeOffer, + offerResumeCommand, +} from '../../lib/resume-offers'; +import { WallActionsContext } from './wall-context'; +import { stubWallActions } from './wall-test-utils'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const registry = vi.hoisted(() => ({ + paneStates: new Map(), + getTerminalPaneState: vi.fn(), + runResumeCommand: vi.fn(), + subscribeToTerminalPaneState: vi.fn(() => () => {}), +})); + +vi.mock('../../lib/terminal-registry', () => ({ + getTerminalPaneState: registry.getTerminalPaneState, + runResumeCommand: registry.runResumeCommand, + subscribeToTerminalPaneState: registry.subscribeToTerminalPaneState, +})); + +let container: HTMLDivElement; +let root: Root; +let wallActions: ReturnType; + +beforeEach(() => { + __resetResumeOffersForTests(); + registry.paneStates.clear(); + // Mirrors the real accessor: a pane with no state reads as `unknown`. + registry.getTerminalPaneState.mockImplementation( + (id: string) => registry.paneStates.get(id) ?? { activity: { kind: 'unknown' } }, + ); + wallActions = stubWallActions(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); +}); + +function render(terminalId: string) { + act(() => { + root.render( + + + + + , + ); + }); +} + +function buttonLabelled(text: string): HTMLButtonElement | undefined { + return [...container.querySelectorAll('button')].find((b) => b.textContent === text); +} + +describe('ResumeBanner', () => { + it('renders nothing without a pending offer', () => { + render('pane-a'); + expect(container.querySelectorAll('button')).toHaveLength(0); + }); + + it('offers the invocation, not the full command, once restored', () => { + offerResumeCommand('pane-a', 'claude --resume 4f2c9b1e-6a03'); + render('pane-a'); + + const run = buttonLabelled('Run claude --resume'); + expect(run).toBeTruthy(); + // The session id stays available without occupying the button. + expect(run?.title).toBe('claude --resume 4f2c9b1e-6a03'); + expect(buttonLabelled('Dismiss')).toBeTruthy(); + }); + + it('stays out of the way while a command is running', () => { + offerResumeCommand('pane-a', 'claude --resume abc'); + registry.paneStates.set('pane-a', { activity: { kind: 'running' } }); + render('pane-a'); + + expect(container.querySelectorAll('button')).toHaveLength(0); + // Hidden, not retired: the offer survives for when the command finishes. + expect(getResumeOffer('pane-a')).toBe('claude --resume abc'); + }); + + it('shows for a shell with no OSC integration (unknown activity)', () => { + offerResumeCommand('pane-a', 'codex resume xyz'); + registry.paneStates.set('pane-a', { activity: { kind: 'unknown' } }); + render('pane-a'); + + expect(buttonLabelled('Run codex resume')).toBeTruthy(); + }); + + it('runs the full command, id included, on Run', () => { + offerResumeCommand('pane-a', 'claude --resume 4f2c9b1e-6a03'); + render('pane-a'); + + act(() => { + buttonLabelled('Run claude --resume')?.click(); + }); + + expect(wallActions.onClickPanel).toHaveBeenCalledWith('pane-a'); + expect(registry.runResumeCommand).toHaveBeenCalledWith('pane-a', 'claude --resume 4f2c9b1e-6a03'); + expect(vi.mocked(wallActions.onClickPanel).mock.invocationCallOrder[0]) + .toBeLessThan(registry.runResumeCommand.mock.invocationCallOrder[0]); + }); + + it('retires the offer on Dismiss', () => { + offerResumeCommand('pane-a', 'claude --resume abc'); + render('pane-a'); + + act(() => { + buttonLabelled('Dismiss')?.click(); + }); + + expect(getResumeOffer('pane-a')).toBeNull(); + expect(container.querySelectorAll('button')).toHaveLength(0); + expect(registry.runResumeCommand).not.toHaveBeenCalled(); + }); + + it('shows only in the pane the offer belongs to', () => { + offerResumeCommand('pane-a', 'claude --resume abc'); + render('pane-b'); + + expect(container.querySelectorAll('button')).toHaveLength(0); + }); +}); diff --git a/lib/src/components/wall/ResumeBanner.tsx b/lib/src/components/wall/ResumeBanner.tsx new file mode 100644 index 00000000..1fb34654 --- /dev/null +++ b/lib/src/components/wall/ResumeBanner.tsx @@ -0,0 +1,104 @@ +import { useCallback, useContext, useSyncExternalStore } from 'react'; +import { PopupButtonRow, popupButton } from '../design'; +import { resumeCommandLabel } from '../../lib/resume-patterns'; +import { + clearResumeOffer, + getResumeOfferSnapshot, + subscribeToResumeOffers, +} from '../../lib/resume-offers'; +import { + getTerminalPaneState, + runResumeCommand, + subscribeToTerminalPaneState, +} from '../../lib/terminal-registry'; +import { WallActionsContext } from './wall-context'; + +export interface ResumeBannerViewProps { + /** The resume command detected in the restored scrollback + * (`lib/src/lib/resume-patterns.ts`). */ + command: string; + /** Run the command in this pane's fresh shell. */ + onResume: () => void; + /** Drop the offer for this pane. */ + onDismiss: () => void; +} + +/** + * The offer a cold-restored terminal pane makes: its saved scrollback ended in + * an agent's resume hint, but the process behind it is gone and the pane now + * holds a fresh shell. One click puts the agent session back. + * + * Two buttons, no prose. The run button is labelled with the invocation only + * (`Run claude --resume`) — the session id it will pass sits in the replayed + * scrollback directly above, so restating it would just crowd the button. + * + * Sits bottom-right — the opposite corner from `MouseOverrideBanner`, so the two + * never collide, and clear of the left-aligned prompt it is offering to type into. + * Same `PopupButtonRow` vocabulary as that sibling: one raised surface owning the + * border, background, and shadow, with the run action carrying the accent tone. + */ +export function ResumeBannerView({ command, onResume, onDismiss }: ResumeBannerViewProps) { + return ( + e.stopPropagation()} + role="status" + > + + + + ); +} + +/** + * The pane-mounted offer. Renders nothing unless this Session was cold-restored + * with a resume command still pending (`lib/src/lib/resume-offers.ts`). + * + * Hidden while a command is running: the offer types into the shell, and a shell + * with a foreground process is not listening. Sessions whose shell has no OSC + * integration report `unknown` activity and keep the offer — the pane still + * retires it on the user's first keystroke. + */ +export function ResumeBanner({ terminalId }: { terminalId: string }) { + const actions = useContext(WallActionsContext); + const offers = useSyncExternalStore(subscribeToResumeOffers, getResumeOfferSnapshot); + // Selects the one enum this gate needs rather than the whole pane-state map: + // that store hands out a new snapshot identity on every semantic event, and + // this component is mounted in every pane, so subscribing to the map would + // re-render all of them on every title/prompt/cwd event to render nothing. + const activityKind = useSyncExternalStore( + subscribeToTerminalPaneState, + useCallback(() => getTerminalPaneState(terminalId).activity.kind, [terminalId]), + ); + const command = offers.get(terminalId); + if (!command) return null; + if (activityKind === 'running') return null; + + return ( + { + // The banner stops its mousedown from reaching TerminalPanel so the + // terminal cannot begin a selection underneath it. Reproduce the pane + // click transition explicitly before the button unmounts: this selects + // the pane, enters passthrough, and defers xterm focus until after click. + actions.onClickPanel(terminalId); + runResumeCommand(terminalId, command); + }} + onDismiss={() => clearResumeOffer(terminalId)} + /> + ); +} diff --git a/lib/src/components/wall/use-dor-control.ts b/lib/src/components/wall/use-dor-control.ts index 5cd80364..be52a1f9 100644 --- a/lib/src/components/wall/use-dor-control.ts +++ b/lib/src/components/wall/use-dor-control.ts @@ -12,6 +12,7 @@ import type { import type { OpenPort } from '../../lib/platform/types'; import { buildShellCommandForKind, shellCommandKind } from 'dor/commands/shell-quote'; import { + clearResumeOffer, getDefaultShellOpts, getTerminalInstance, getTerminalPaneState, @@ -795,6 +796,9 @@ export function useDorControl({ } const target = requireTerminalSurface(params.surface, detail); if (!target) return; + // Direct PTY write, so xterm's onData never sees it — retire the + // cold-restore resume offer here (docs/specs/layout.md -> Resume offer). + clearResumeOffer(target.id); getPlatform().writePty(target.id, input); detail.respond({ ok: true, diff --git a/lib/src/lib/clipboard.test.ts b/lib/src/lib/clipboard.test.ts index 35b4fb2d..f3c54aee 100644 --- a/lib/src/lib/clipboard.test.ts +++ b/lib/src/lib/clipboard.test.ts @@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => ({ readClipboardImageAsFilePath: vi.fn<() => Promise>(), writePty: vi.fn<(id: string, data: string) => void>(), readText: vi.fn<() => Promise>(), + clearResumeOffer: vi.fn<(id: string) => void>(), })); vi.mock('./platform', () => ({ @@ -26,6 +27,10 @@ vi.mock('./terminal-registry', () => ({ markSessionTouched: vi.fn(), })); +vi.mock('./resume-offers', () => ({ + clearResumeOffer: mocks.clearResumeOffer, +})); + import { doPaste } from './clipboard'; describe('doPaste three-tier fallthrough', () => { @@ -47,6 +52,7 @@ describe('doPaste three-tier fallthrough', () => { expect(mocks.readClipboardImageAsFilePath).not.toHaveBeenCalled(); expect(mocks.writePty).toHaveBeenCalledTimes(1); expect(mocks.writePty).toHaveBeenCalledWith('t1', '/tmp/a.png /tmp/b\\ file.png '); + expect(mocks.clearResumeOffer).toHaveBeenCalledWith('t1'); }); it('falls through to text when no file refs', async () => { @@ -58,6 +64,7 @@ describe('doPaste three-tier fallthrough', () => { expect(mocks.readClipboardImageAsFilePath).not.toHaveBeenCalled(); expect(mocks.writePty).toHaveBeenCalledWith('t1', 'hello world'); + expect(mocks.clearResumeOffer).toHaveBeenCalledWith('t1'); }); it('falls through to image when no files and no text', async () => { @@ -68,6 +75,7 @@ describe('doPaste three-tier fallthrough', () => { await doPaste('t1'); expect(mocks.writePty).toHaveBeenCalledWith('t1', '/tmp/img.png '); + expect(mocks.clearResumeOffer).toHaveBeenCalledWith('t1'); }); it('is a no-op when all tiers come back empty', async () => { @@ -78,6 +86,7 @@ describe('doPaste three-tier fallthrough', () => { await doPaste('t1'); expect(mocks.writePty).not.toHaveBeenCalled(); + expect(mocks.clearResumeOffer).not.toHaveBeenCalled(); }); it('swallows file-ref adapter errors and falls through to text', async () => { diff --git a/lib/src/lib/clipboard.ts b/lib/src/lib/clipboard.ts index a92751df..7191781c 100644 --- a/lib/src/lib/clipboard.ts +++ b/lib/src/lib/clipboard.ts @@ -4,6 +4,7 @@ import { extractSelectionText } from './selection-text'; import { getPlatform } from './platform'; import { shellEscapePath } from './shell-escape'; import { getTerminalInstance, markSessionTouched } from './terminal-registry'; +import { clearResumeOffer } from './resume-offers'; async function writeText(text: string): Promise { if (!text) return; @@ -48,6 +49,9 @@ function writePasteToPty(terminalId: string, text: string): void { const bracketed = getMouseSelectionState(terminalId).bracketedPaste; const payload = bracketed ? `\x1b[200~${text}\x1b[201~` : text; markSessionTouched(terminalId); + // Paste and file-drop input bypass xterm's onData handler, so retire the + // restored-pane offer here before writing directly to the platform PTY. + clearResumeOffer(terminalId); getPlatform().writePty(terminalId, payload); } diff --git a/lib/src/lib/resume-offers.test.ts b/lib/src/lib/resume-offers.test.ts new file mode 100644 index 00000000..05d9ccfe --- /dev/null +++ b/lib/src/lib/resume-offers.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + __resetResumeOffersForTests, + clearResumeOffer, + getResumeOffer, + getResumeOfferSnapshot, + offerResumeCommand, + subscribeToResumeOffers, +} from './resume-offers'; + +describe('resume-offers', () => { + beforeEach(() => { + __resetResumeOffersForTests(); + }); + + it('holds an offer per session', () => { + offerResumeCommand('pane-a', 'claude --resume abc'); + offerResumeCommand('pane-b', 'codex resume xyz'); + expect(getResumeOffer('pane-a')).toBe('claude --resume abc'); + expect(getResumeOffer('pane-b')).toBe('codex resume xyz'); + expect(getResumeOffer('pane-c')).toBeNull(); + }); + + it('treats a null command as no offer', () => { + offerResumeCommand('pane-a', null); + expect(getResumeOffer('pane-a')).toBeNull(); + expect(getResumeOfferSnapshot().size).toBe(0); + }); + + it('rejects a non-canonical persisted command', () => { + offerResumeCommand('pane-a', 'claude --resume $(touch${IFS}/tmp/pwn)'); + expect(getResumeOffer('pane-a')).toBeNull(); + }); + + it('clears an existing offer when re-seeded with null', () => { + offerResumeCommand('pane-a', 'claude --resume abc'); + offerResumeCommand('pane-a', null); + expect(getResumeOffer('pane-a')).toBeNull(); + }); + + it('notifies subscribers on seed and clear', () => { + const listener = vi.fn(); + const unsubscribe = subscribeToResumeOffers(listener); + + offerResumeCommand('pane-a', 'claude --resume abc'); + expect(listener).toHaveBeenCalledTimes(1); + + clearResumeOffer('pane-a'); + expect(listener).toHaveBeenCalledTimes(2); + + unsubscribe(); + offerResumeCommand('pane-a', 'claude --resume abc'); + expect(listener).toHaveBeenCalledTimes(2); + }); + + it('does not notify when nothing changes', () => { + offerResumeCommand('pane-a', 'claude --resume abc'); + const listener = vi.fn(); + subscribeToResumeOffers(listener); + + offerResumeCommand('pane-a', 'claude --resume abc'); + clearResumeOffer('pane-unknown'); + expect(listener).not.toHaveBeenCalled(); + }); + + it('hands out a fresh snapshot identity only after a change', () => { + const first = getResumeOfferSnapshot(); + expect(getResumeOfferSnapshot()).toBe(first); + + offerResumeCommand('pane-a', 'claude --resume abc'); + const second = getResumeOfferSnapshot(); + expect(second).not.toBe(first); + expect(second.get('pane-a')).toBe('claude --resume abc'); + }); +}); diff --git a/lib/src/lib/resume-offers.ts b/lib/src/lib/resume-offers.ts new file mode 100644 index 00000000..71c69569 --- /dev/null +++ b/lib/src/lib/resume-offers.ts @@ -0,0 +1,69 @@ +/** + * Pending resume offers, keyed by Session id. + * + * A cold **restore** replays a Session's saved scrollback into a *fresh* shell — + * the agent process that wrote that scrollback is gone. When the snapshot also + * carried a `resumeCommand` (`resume-patterns.ts`), the restored pane offers to + * run it. An offer is short-lived by design: it is seeded only by restore, and + * the first thing the user does with the pane retires it. + * + * Never seeded on **resume** (`docs/specs/glossary.md`): there the process is + * still Live, so there is nothing to resume. + * + * Exposes a `useSyncExternalStore`-compatible subscription API. Pure state, no + * DOM or platform dependencies — safe to unit-test. + */ + +import { normalizeResumeCommand } from './resume-patterns'; + +const offers = new Map(); +const listeners = new Set<() => void>(); +let cachedSnapshot: ReadonlyMap | null = null; + +function notify(): void { + cachedSnapshot = null; + listeners.forEach((l) => l()); +} + +export function subscribeToResumeOffers(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function getResumeOfferSnapshot(): ReadonlyMap { + if (cachedSnapshot) return cachedSnapshot; + cachedSnapshot = new Map(offers); + return cachedSnapshot; +} + +export function getResumeOffer(id: string): string | null { + return offers.get(id) ?? null; +} + +/** Seed the offer a restored pane makes. An empty command clears instead, so a + * snapshot with no detected resume command can be passed through unguarded. */ +export function offerResumeCommand(id: string, command: string | null): void { + const normalized = command ? normalizeResumeCommand(command) : null; + if (!normalized) { + clearResumeOffer(id); + return; + } + if (offers.get(id) === normalized) return; + offers.set(id, normalized); + notify(); +} + +/** Retire the offer — taken, dismissed, superseded by user input, or disposed. */ +export function clearResumeOffer(id: string): void { + if (!offers.delete(id)) return; + notify(); +} + +/** Test reset. */ +export function __resetResumeOffersForTests(): void { + if (offers.size === 0) return; + offers.clear(); + notify(); +} diff --git a/lib/src/lib/resume-patterns.test.ts b/lib/src/lib/resume-patterns.test.ts index e2a08610..cd46375a 100644 --- a/lib/src/lib/resume-patterns.test.ts +++ b/lib/src/lib/resume-patterns.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { detectResumeCommand } from './resume-patterns'; +import { detectResumeCommand, resumeCommandLabel } from './resume-patterns'; describe('detectResumeCommand', () => { it('detects codex resume command', () => { @@ -17,6 +17,57 @@ describe('detectResumeCommand', () => { expect(detectResumeCommand(scrollback)).toBe('claude --continue'); }); + it('strips terminal styling from a captured id', () => { + const scrollback = 'claude --resume 4f2c9b1e-6a03-4d5e\x1b[0m\n'; + expect(detectResumeCommand(scrollback)).toBe('claude --resume 4f2c9b1e-6a03-4d5e'); + }); + + it('never promotes an unterminated string control payload to visible text', () => { + // A chunk or trim cut mid-OSC would otherwise leave the window title behind + // as text, and a title is not something the pane can resume. + expect(detectResumeCommand('\x1b]0;claude --resume evil\nprompt$ ')).toBeNull(); + }); + + it('does not surrender a string-control payload that spans a newline', () => { + // The scan window is stripped as a whole before it is split, so an OSC + // whose payload carries an LF is removed as a unit. Stripping each raw + // segment on its own handed the second half back as visible text. + expect(detectResumeCommand('\x1b]0;title\nclaude --resume evil\x07\nuser$ ')).toBeNull(); + expect(detectResumeCommand('\x1bPtmux;a\nclaude --resume evil\x1b\\\nuser$ ')).toBeNull(); + }); + + it('reads a hint that follows a terminated title on the same line', () => { + // The unterminated-swallow rule must not eat text after a control that did + // close: this is the ordinary case of a shell repainting its title. + expect(detectResumeCommand('\x1b]0;~/proj\x07claude --resume abc123\n$ ')).toBe( + 'claude --resume abc123', + ); + }); + + it('reads a hint wrapped in prose punctuation', () => { + // Rendering a command inside backticks, quotes or parens is how agents + // normally print one mid-sentence; requiring whitespace after the id lost + // every such hint. + expect(detectResumeCommand('Resume with `claude --resume abc123`.\n')).toBe( + 'claude --resume abc123', + ); + expect(detectResumeCommand("run 'codex resume 01JCX' now\n")).toBe('codex resume 01JCX'); + expect(detectResumeCommand('(claude --continue)\n')).toBe('claude --continue'); + }); + + it('never captures shell syntax, only the invocation in front of it', () => { + // The command is rebuilt as label + captured id, so what trails the id is + // dropped rather than persisted — but an id that is *made of* shell syntax + // never matches in the first place. + expect(detectResumeCommand('claude --resume $(touch${IFS}/tmp/pwn)\n')).toBeNull(); + expect(detectResumeCommand('codex resume safe; touch /tmp/pwn\n')).toBe('codex resume safe'); + }); + + it('does not match an invocation that is the prefix of a longer word', () => { + expect(detectResumeCommand('claude --continuex\n')).toBeNull(); + expect(detectResumeCommand('claude --continue-session\n')).toBeNull(); + }); + it('returns null when no pattern matches', () => { const scrollback = 'regular output\n$ ls\nfile1 file2\n$ '; expect(detectResumeCommand(scrollback)).toBeNull(); @@ -37,4 +88,40 @@ describe('detectResumeCommand', () => { const scrollback = filler + '\ncodex resume recent456\n'; expect(detectResumeCommand(scrollback)).toBe('codex resume recent456'); }); + + it('returns the most recent match when the same command repeats', () => { + const scrollback = + 'codex resume old123\nmore output\ncodex resume new789\n$ '; + expect(detectResumeCommand(scrollback)).toBe('codex resume new789'); + }); + + it('prefers the most recent command across pattern types', () => { + const scrollback = 'codex resume abc\nlater\nclaude --resume xyz\n$ '; + expect(detectResumeCommand(scrollback)).toBe('claude --resume xyz'); + }); + + it('prefers the rightmost pattern after a carriage-return redraw', () => { + const scrollback = 'codex resume old123\rclaude --resume new789'; + expect(detectResumeCommand(scrollback)).toBe('claude --resume new789'); + }); + + it('prefers the rightmost repeated pattern in one raw segment', () => { + const scrollback = 'codex resume old123\rcodex resume new789'; + expect(detectResumeCommand(scrollback)).toBe('codex resume new789'); + }); +}); + +describe('resumeCommandLabel', () => { + it('drops the session argument', () => { + expect(resumeCommandLabel('claude --resume 4f2c9b1e-6a03-4d5e')).toBe('claude --resume'); + expect(resumeCommandLabel('codex resume 01JCX8ZK5Q7M3N')).toBe('codex resume'); + }); + + it('keeps an argument-free command whole', () => { + expect(resumeCommandLabel('claude --continue')).toBe('claude --continue'); + }); + + it('falls back to the command itself when no pattern claims it', () => { + expect(resumeCommandLabel('nvim -S Session.vim')).toBe('nvim -S Session.vim'); + }); }); diff --git a/lib/src/lib/resume-patterns.ts b/lib/src/lib/resume-patterns.ts index 3deddc10..26f7b7e2 100644 --- a/lib/src/lib/resume-patterns.ts +++ b/lib/src/lib/resume-patterns.ts @@ -1,37 +1,129 @@ +import { stripTerminalControls } from './terminal-controls'; + interface ResumePattern { - name: string; + /** The invocation without its volatile session argument. What the UI offers to + * run: the session id is already on screen in the scrollback above the offer, + * so repeating it in chrome buys nothing and costs the button its shape. The + * detected command is this label plus the captured argument, so the button can + * never name something different from what it runs. */ + label: string; + /** Capture group 1 is the session argument, when the invocation takes one. + * Global (scanning wants every match in a line); every use must therefore + * reset `lastIndex` or go through `matchAll`, which does. */ regex: RegExp; - extract: (match: RegExpMatchArray) => string; } +// Claude and Codex currently emit opaque ASCII identifiers (UUID/ULID-shaped). +// Keep this deliberately narrower than a shell word: the captured value is +// later executed, so punctuation with shell meaning must never enter it. +const RESUME_ID = String.raw`[A-Za-z0-9][A-Za-z0-9_-]*`; + +/** The invocation must not be the prefix of a longer word — `claude --continuex` + * is not an offer to continue. Nothing stronger belongs here: agents render a + * hint inside prose punctuation as often as bare (`Resume with + * \`claude --resume \`.`), and requiring whitespace or end-of-line after it + * silently dropped every one of those. Safety comes from RESUME_ID plus the + * rebuild below, not from what follows the match; and because RESUME_ID is + * greedy this lookahead can never truncate an id, only reject a longer word. */ +const ENDS_INVOCATION = String.raw`(?![A-Za-z0-9_-])`; + const BUILTIN_PATTERNS: ResumePattern[] = [ { - name: 'codex', - regex: /codex resume (\S+)/, - extract: (m) => `codex resume ${m[1]}`, + label: 'codex resume', + regex: new RegExp(String.raw`\bcodex resume (${RESUME_ID})${ENDS_INVOCATION}`, 'g'), }, { - name: 'claude', - regex: /claude --resume (\S+)/, - extract: (m) => `claude --resume ${m[1]}`, + label: 'claude --resume', + regex: new RegExp(String.raw`\bclaude --resume (${RESUME_ID})${ENDS_INVOCATION}`, 'g'), }, { - name: 'claude-continue', - regex: /claude --continue/, - extract: () => 'claude --continue', + label: 'claude --continue', + regex: new RegExp(String.raw`\bclaude --continue${ENDS_INVOCATION}`, 'g'), }, ]; +/** How far back a resume hint is still considered current. */ +const SCAN_LINES = 50; + +/** Rightmost resume command in already-stripped text (`matchAll` leaves the + * shared patterns' `lastIndex` untouched — it scans against a clone). */ +function resumeCommandInVisible(visible: string): string | null { + let latest: { index: number; command: string } | null = null; + for (const { label, regex } of BUILTIN_PATTERNS) { + for (const match of visible.matchAll(regex)) { + const index = match.index; + if (latest && latest.index > index) continue; + latest = { + index, + command: match[1] ? `${label} ${match[1]}` : label, + }; + } + } + return latest?.command ?? null; +} + /** - * Scan the last 50 lines of scrollback for known resume commands. - * Returns the full resume command string, or null if none found. + * Return the canonical executable form of a resume command, or null when the + * value contains anything beyond one of the known invocations and its expected + * identifier grammar. Used again at restore/run boundaries because persisted + * snapshots may have been written by an older detector. + */ +export function normalizeResumeCommand(command: string): string | null { + const visible = stripTerminalControls(command).trim(); + const detected = resumeCommandInVisible(visible); + return detected === visible ? detected : null; +} + +/** + * Scan the last 50 lines of scrollback for known resume commands, newest line + * first. Returns the full resume command string for the most recent match, or + * null if none found. Within one LF-delimited raw output segment, the rightmost + * match wins (PTY redraws may use CR without LF). Recency matters: a pane that + * resumed more than once prints a fresh resume hint each time, and only the + * latest one resumes the *current* session — scanning oldest-first or preferring + * pattern order would persist a stale session id. + * + * Walks the tail rather than splitting the whole buffer: this runs per pane on + * every save, over scrollback capped at 100k chars (`scrollback-trim.ts`), and + * all but the last 50 lines would be allocated only to be discarded. + * + * The window is stripped once, *before* it is split into segments, so a string + * control whose payload contains an LF is removed as a unit — stripping each + * raw segment independently would hand the second half of an OSC title back as + * visible text. For the same reason an unterminated control swallows the rest + * of the window rather than the rest of its segment: with no terminator in + * view, everything after the introducer is payload as far as this can tell, and + * failing toward "no offer" is the safe direction. A payload whose introducer + * fell off the front of the window (a trim or a chunk eviction can strand one) + * is not recoverable here — nothing marks it as payload — but it grants no more + * than ordinary output does, which is already a source of offers. */ export function detectResumeCommand(scrollback: string): string | null { - const lines = scrollback.split('\n').slice(-50); - const text = lines.join('\n'); - for (const pattern of BUILTIN_PATTERNS) { - const match = text.match(pattern.regex); - if (match) return pattern.extract(match); + let cursor = scrollback.length; + let windowStart = 0; + for (let scanned = 0; scanned < SCAN_LINES && cursor > 0; scanned++) { + windowStart = scrollback.lastIndexOf('\n', cursor - 1) + 1; + cursor = windowStart - 1; + } + const segments = stripTerminalControls(scrollback.slice(windowStart)).split('\n'); + for (let i = segments.length - 1; i >= 0; i--) { + const found = resumeCommandInVisible(segments[i]); + if (found) return found; } return null; } + +/** + * The label for a detected resume command — its invocation with the session + * argument dropped (`claude --resume ` → `claude --resume`). Falls back to + * the command itself for a string no pattern claims. + */ +export function resumeCommandLabel(command: string): string { + for (const { label } of BUILTIN_PATTERNS) { + // A canonical command is exactly `label` or `label ` (normalizeResumeCommand), + // so a prefix test is exact here — and it can't be tripped by a shared + // pattern's `lastIndex` the way `regex.test` would be. + if (command === label || command.startsWith(`${label} `)) return label; + } + return command; +} diff --git a/lib/src/lib/session-restore.test.ts b/lib/src/lib/session-restore.test.ts index d5b7c1fc..eaf16df7 100644 --- a/lib/src/lib/session-restore.test.ts +++ b/lib/src/lib/session-restore.test.ts @@ -15,6 +15,7 @@ vi.mock('./terminal-registry', () => ({ })); import { restoreSession } from './session-restore'; +import { __resetResumeOffersForTests, getResumeOffer } from './resume-offers'; function createPlatform(savedState: PersistedSession | null): PlatformAdapter { return { @@ -61,6 +62,37 @@ function createPlatform(savedState: PersistedSession | null): PlatformAdapter { describe('restoreSession', () => { beforeEach(() => { vi.clearAllMocks(); + __resetResumeOffersForTests(); + }); + + it('offers the saved resume command to each restored terminal', () => { + const saved: PersistedSession = { + version: 3, + panes: [ + { id: 'pane-a', title: 'A', cwd: null, scrollback: 'claude --resume abc\n', resumeCommand: 'claude --resume abc', untouched: false }, + { id: 'pane-b', title: 'B', cwd: null, scrollback: 'no agent here\n', resumeCommand: null, untouched: false }, + ], + }; + + restoreSession(createPlatform(saved)); + + expect(getResumeOffer('pane-a')).toBe('claude --resume abc'); + expect(getResumeOffer('pane-b')).toBeNull(); + }); + + it('makes no resume offer for a browser surface', () => { + const saved: PersistedSession = { + version: 3, + panes: [ + // A browser pane's terminal-only fields are always blank, but the offer + // must be keyed off surface kind rather than trusting that. + { id: 'pane-web', title: 'localhost', cwd: null, scrollback: null, resumeCommand: 'claude --resume abc', untouched: false, surfaceType: 'browser' }, + ], + }; + + restoreSession(createPlatform(saved)); + + expect(getResumeOffer('pane-web')).toBeNull(); }); it('spawns restored terminals with the configured default shell', () => { diff --git a/lib/src/lib/session-restore.ts b/lib/src/lib/session-restore.ts index bdc5fcac..ae3756da 100644 --- a/lib/src/lib/session-restore.ts +++ b/lib/src/lib/session-restore.ts @@ -1,6 +1,7 @@ import { type LathPersistedLayout, isLathPersistedLayout } from './lath/persistence'; import type { PlatformAdapter } from './platform/types'; import { carrySurfaceRefs, readPersistedSession, type PersistedDoor, type PersistedSession, type PersistedSurfaceRefs } from './session-types'; +import { offerResumeCommand } from './resume-offers'; import { getDefaultShellOpts, restoreBrowserSurfaceTodo, restoreTerminal } from './terminal-registry'; export interface RestoredSession { @@ -44,6 +45,12 @@ export function restoreSession(platform: PlatformAdapter): RestoredSession | nul args: shellOpts?.args, untouched: pane.untouched, }); + // The replayed scrollback ends in an agent's resume hint, but the process + // that wrote it is gone and `restoreTerminal` just spawned a fresh shell. + // Offer the reconnect (docs/specs/layout.md -> Resume offer). Restore only: + // the live-resume path in reconnect.ts never reaches here, because there the + // process is still Live and has nothing to resume. + offerResumeCommand(pane.id, pane.resumeCommand); } return { diff --git a/lib/src/lib/session-save.ts b/lib/src/lib/session-save.ts index 221b8b06..a43843ce 100644 --- a/lib/src/lib/session-save.ts +++ b/lib/src/lib/session-save.ts @@ -1,7 +1,5 @@ import type { PlatformAdapter } from './platform/types'; -import { browserPersistedPane, readPersistedSession, type PersistedDoor, type PersistedPane, type PersistedSession, type PersistedSurfaceRefs, type PersistedSurfaceType } from './session-types'; -import { detectResumeCommand } from './resume-patterns'; -import { trimPersistedScrollback } from './scrollback-trim'; +import { browserPersistedPane, readPersistedSession, terminalPersistedContent, type PersistedDoor, type PersistedPane, type PersistedSession, type PersistedSurfaceRefs, type PersistedSurfaceType } from './session-types'; import { getActivity, getLivePersistedAlertState, getTerminalPaneState, isUntouched, resolveTerminalSessionId } from './terminal-registry'; import { UNNAMED_PANEL_TITLE } from './terminal-state'; @@ -56,15 +54,11 @@ export async function saveSession( platform.getScrollback(sessionId), platform.getCwd(sessionId), ]); - // Resume-command patterns live at the tail, so trimming before detection - // is safe. - const trimmedScrollback = trimPersistedScrollback(scrollback ?? previousPane?.scrollback ?? null); return { id: pane.id, title: pane.title, cwd: cwd ?? previousPane?.cwd ?? null, - scrollback: trimmedScrollback, - resumeCommand: trimmedScrollback ? detectResumeCommand(trimmedScrollback) : null, + ...terminalPersistedContent(scrollback ?? previousPane?.scrollback ?? null), untouched: isUntouched(pane.id), alert: liveAlert ?? previousPane?.alert ?? null, }; diff --git a/lib/src/lib/session-types.ts b/lib/src/lib/session-types.ts index 7f2383ec..d1feaae0 100644 --- a/lib/src/lib/session-types.ts +++ b/lib/src/lib/session-types.ts @@ -1,5 +1,7 @@ import type { SessionStatus } from './activity-monitor'; import { ACTIVITY_NOTIFICATION_SOURCES, type ActivityNotification, type TodoState } from './alert-manager'; +import { detectResumeCommand } from './resume-patterns'; +import { trimPersistedScrollback } from './scrollback-trim'; /** * Only the TODO reminder and its notification detail survive a restart. @@ -32,6 +34,25 @@ export interface PersistedPane { surfaceType?: PersistedSurfaceType; } +/** + * Trim a terminal Session's scrollback for persistence and derive the resume + * command from *that* trimmed text. The two must move together: the resume + * command has to describe the scrollback actually being written, or a pane + * persists a stale hint and restore offers the wrong session. Single source of + * truth shared by the renderer save path (`session-save.ts`) and the VS Code + * host refresh (`vscode-ext/session-state.ts`); detection is unaffected by the + * trim because resume patterns live at the tail. + */ +export function terminalPersistedContent( + scrollback: string | null, +): Pick { + const trimmed = trimPersistedScrollback(scrollback); + return { + scrollback: trimmed, + resumeCommand: trimmed ? detectResumeCommand(trimmed) : null, + }; +} + /** * Build the persisted record for a browser surface. Browser panes have no PTY, * so the terminal-only fields (cwd/scrollback/resumeCommand/untouched) are always diff --git a/lib/src/lib/terminal-controls.test.ts b/lib/src/lib/terminal-controls.test.ts new file mode 100644 index 00000000..c1e8611f --- /dev/null +++ b/lib/src/lib/terminal-controls.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { stripTerminalControls } from './terminal-controls'; + +describe('stripTerminalControls', () => { + it('removes terminated string controls with their payload', () => { + expect(stripTerminalControls('\x1b]0;window title\x07user$ ')).toBe('user$ '); + expect(stripTerminalControls('\x1bP+q544e\x1b\\user$ ')).toBe('user$ '); + // APC/PM/SOS are ST-terminated too — kitty's graphics protocol is an APC, + // so a pane running an image-capable tool emits one routinely. Without + // these the ESC catch-all would strip the introducer and promote the + // payload to text both detectors read. + expect(stripTerminalControls('\x1b_Gf=100;claude --resume evil\x1b\\user$ ')).toBe('user$ '); + expect(stripTerminalControls('\x1b^private;claude --resume evil\x1b\\user$ ')).toBe('user$ '); + expect(stripTerminalControls('\x1bXstring;claude --resume evil\x1b\\user$ ')).toBe('user$ '); + // ST's 8-bit form terminates just as well; without it the swallow below + // would eat the prompt trailing a title OSC. + expect(stripTerminalControls('prev line\n\x1b]0;t\x9cuser@host repo % ')).toBe( + 'prev line\nuser@host repo % ', + ); + // xterm aborts a string control on CAN/SUB and ends it on a bare ESC, so + // the text behind one is rendered output — the swallow below must not eat + // the prompt trailing any of the three. + expect(stripTerminalControls('prev line\n\x1b]0;t\x18user@host repo % ')).toBe( + 'prev line\nuser@host repo % ', + ); + expect(stripTerminalControls('prev line\n\x1b]0;t\x1auser@host repo % ')).toBe( + 'prev line\nuser@host repo % ', + ); + expect(stripTerminalControls('prev line\n\x1b]0;t\x1b[0muser@host repo % ')).toBe( + 'prev line\nuser@host repo % ', + ); + expect(stripTerminalControls('prev line\n\x1b_Gf=100;xx\x18user@host repo % ')).toBe( + 'prev line\nuser@host repo % ', + ); + }); + + it('swallows the rest of the input after an unterminated string control', () => { + // A chunk boundary or a scrollback trim can cut mid-sequence; the payload + // must not be promoted to text by the ESC catch-all below it. + expect(stripTerminalControls('done\n\x1b]0;claude --resume evil\nuser$ ')).toBe('done\n'); + expect(stripTerminalControls('done\n\x1bPtmux;still payload')).toBe('done\n'); + expect(stripTerminalControls('done\n\x1b_Gf=100;claude --resume evil')).toBe('done\n'); + }); + + it('removes CSI sequences including the private parameter bytes', () => { + expect(stripTerminalControls('\x1b[1;31mred\x1b[0m')).toBe('red'); + expect(stripTerminalControls('\x1b[?1049halt\x1b[?1049l')).toBe('alt'); + // `<`, `=`, `>`, `:` are legal parameter bytes (SGR mouse, colon subparams). + expect(stripTerminalControls('\x1b[<35;10;4Mmouse')).toBe('mouse'); + expect(stripTerminalControls('\x1b[38:2:255:0:0mcolor')).toBe('color'); + }); + + it('removes charset designators and stray two-byte escapes', () => { + expect(stripTerminalControls('\x1b(Bplain')).toBe('plain'); + expect(stripTerminalControls('a\x1bMb')).toBe('ab'); + }); + + it('keeps LF, CR and TAB as text boundaries and drops other control bytes', () => { + expect(stripTerminalControls('a\r\nb\tc')).toBe('a\r\nb\tc'); + expect(stripTerminalControls('a\x00\x07\x7f\x9fb')).toBe('ab'); + }); +}); diff --git a/lib/src/lib/terminal-controls.ts b/lib/src/lib/terminal-controls.ts new file mode 100644 index 00000000..a865accd --- /dev/null +++ b/lib/src/lib/terminal-controls.ts @@ -0,0 +1,38 @@ +/** + * Remove terminal presentation controls, leaving the text a user would see. + * + * Shared by every consumer that interprets raw PTY output as *content* rather + * than as a stream: resume-hint detection (`resume-patterns.ts`) and the + * keystroke-fallback prompt detector (`terminal-state-store.ts`). Both read a + * tail slice of a buffer, so both are routinely handed input that starts or + * ends mid-sequence — and a payload that leaks through reads as terminal output + * in a place where that decides whether a command is offered or a shell is + * called idle. One implementation so a hardening step can't reach only one of + * them. + */ +export function stripTerminalControls(input: string): string { + return ( + input + // String controls: OSC (BEL or ST terminated); DCS/SOS/PM/APC (ST + // terminated). ST is `\x1b\\` or its 8-bit form `\x9c`; xterm also ends a + // string control on CAN/SUB (abort) or a bare ESC, so the text behind one + // is visible output, not payload. `\x1b\\` must be tried before the + // bare-ESC lookahead so a 7-bit ST is consumed whole; the lookahead + // leaves a following sequence for the rules below. + .replace(/\x1b\][\s\S]*?(?:\x07|[\x18\x1a\x9c]|\x1b\\|(?=\x1b))/g, '') + .replace(/\x1b[PX^_][\s\S]*?(?:[\x18\x1a\x9c]|\x1b\\|(?=\x1b))/g, '') + // An UNterminated string control (a chunk or trim cut mid-sequence) + // swallows the rest of the input. Without this the ESC catch-all below + // would strip only the introducer and promote the payload — an OSC window + // title, say — into text that reads as terminal output. Nothing before the + // cut is lost: the terminated forms above have already been removed, so + // whatever remains really is an unclosed payload. + .replace(/\x1b[\]PX^_][\s\S]*$/, '') + // CSI, charset designators, and remaining two-byte ESC sequences. + .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '') + .replace(/\x1b[()][A-Za-z0-9]/g, '') + .replace(/\x1b[@-_]/g, '') + // Preserve LF/CR/TAB as text boundaries; discard other C0/C1 controls. + .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, '') + ); +} diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index 3b5a9e37..267ea03a 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -14,6 +14,8 @@ import { setSelection as setMouseSelection, } from './mouse-selection'; import { extractSelectionText } from './selection-text'; +import { clearResumeOffer } from './resume-offers'; +import { normalizeResumeCommand } from './resume-patterns'; import { pendingShellOpts, registry, @@ -231,6 +233,9 @@ function wirePtyEvents(id: string, terminal: Terminal): () => void { // The process is gone, so any command we seeded for this pane is no longer // live; clear it so `dor ensure` stops matching a dead surface. finishLaunchedCommandByPtyId(id, detail.exitCode); + // Same reason retires the resume offer: it types into a shell, and there is + // no shell left (docs/specs/layout.md -> Resume offer). + clearResumeOffer(id); }; platform.onPtyData(handleData); platform.onPtyExit(handleExit); @@ -261,6 +266,8 @@ function wireXtermHandlers( if (!isReplayTerminalReport) { markSessionTouched(id); + // Answered by doing something else (docs/specs/layout.md -> Resume offer). + clearResumeOffer(id); } const isSyntheticTerminalReport = inputIsSyntheticTerminalReport(input); @@ -550,9 +557,32 @@ export function disposeSession(id: string): void { registry.delete(id); removeTerminalPaneState(id); removeMouseSelectionState(id); + clearResumeOffer(id); notifyActivityListeners(); } +/** + * Take a restored pane's resume offer: type the command at its fresh shell and + * run it. Written straight to the PTY rather than through a bracketed paste — + * bracketed paste exists to stop an embedded newline from executing, which is + * the opposite of what this button is for. + */ +export function runResumeCommand(id: string, command: string): void { + const normalized = normalizeResumeCommand(command); + clearResumeOffer(id); + const entry = registry.get(id); + // A gone shell can't run anything, and the seed below would then be a command + // start nothing ever finishes — `countRunningSessions` would count this pane as + // running forever (a spurious quit confirmation, a phantom running header). + if (!normalized || !entry || entry.exited) return; + markSessionTouched(id); + // This direct platform write bypasses xterm's onData keystroke fallback. + // Seed the same semantic command state first so non-integrated shells still + // report a running agent for headers, grouping, and quit protection. + seedLaunchedCommand(id, normalized); + getPlatform().writePty(id, `${normalized}\r`); +} + export function refitSession(id: string): void { const entry = registry.get(id); if (!entry) return; diff --git a/lib/src/lib/terminal-registry.alert.test.ts b/lib/src/lib/terminal-registry.alert.test.ts index 771eefac..cac9621c 100644 --- a/lib/src/lib/terminal-registry.alert.test.ts +++ b/lib/src/lib/terminal-registry.alert.test.ts @@ -95,6 +95,7 @@ import { makeAlertScenario, type FakePtyAdapter, type FakeScenario } from './pla import { DEFAULT_ACTIVITY_STATE, applyTerminalSemanticEvents, + countRunningSessions, isPaneOscDriven, mountElement, clearLocalSurfaceActivity, @@ -109,6 +110,7 @@ import { focusSession, getOrCreateTerminal, getActivity, + getTerminalPaneState, getWatchedCommands, initAlertStateReceiver, setCommandWatched, @@ -117,6 +119,7 @@ import { markSessionTodo, resumeTerminal, restoreTerminal, + runResumeCommand, setPendingShellOpts, subscribeToActivity, toggleSessionAlert, @@ -372,6 +375,57 @@ describe('terminal-registry alert behavior', () => { expect(isUntouched(id)).toBe(false); }); + it('rejects an unsafe persisted resume command before PTY execution', () => { + const id = 'unsafe-resume-command'; + const received: string[] = []; + createSession(id); + fakePlatform.setInputHandler(id, (data) => received.push(data)); + + runResumeCommand(id, 'claude --resume $(touch${IFS}/tmp/pwn)'); + + expect(received).toEqual([]); + expect(isUntouched(id)).toBe(true); + }); + + it('seeds semantic command state before writing a resume command', () => { + const id = 'tracked-resume-command'; + const received: string[] = []; + let stateAtWrite: ReturnType | null = null; + createSession(id); + fakePlatform.setInputHandler(id, (data) => { + received.push(data); + stateAtWrite = getTerminalPaneState(id); + }); + + runResumeCommand(id, 'claude --resume 4f2c9b1e-6a03'); + + expect(received).toEqual(['claude --resume 4f2c9b1e-6a03\r']); + expect(stateAtWrite).toMatchObject({ + activity: { kind: 'running' }, + currentCommand: { + rawCommandLine: 'claude --resume 4f2c9b1e-6a03', + source: 'user_input', + }, + }); + expect(countRunningSessions()).toBe(1); + }); + + it('does not run a resume command in a pane whose process has exited', () => { + const id = 'dead-resume-command'; + const received: string[] = []; + const entry = createSession(id); + fakePlatform.setInputHandler(id, (data) => received.push(data)); + // The shell a restore spawned died (e.g. its saved cwd is gone), so there is + // nothing to type into — and seeding a command start nothing will ever + // finish would leave this pane counted as running forever. + entry.exited = true; + + runResumeCommand(id, 'claude --resume 4f2c9b1e-6a03'); + + expect(received).toEqual([]); + expect(countRunningSessions()).toBe(0); + }); + it('seeds untouched state on resume and restore while defaulting missing state to touched', () => { resumeTerminal('resume-untouched', null, { alive: true, untouched: true }); resumeTerminal('resume-legacy', null, { alive: true }); diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index 8bb5a296..3abd5a8e 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -37,6 +37,8 @@ export { export { resolveTerminalSessionId } from './terminal-store'; +export { clearResumeOffer } from './resume-offers'; + export { disposeAllSessions, disposeSession, @@ -51,6 +53,7 @@ export { registerSurfaceFocusHandle, restoreTerminal, resumeTerminal, + runResumeCommand, setPendingShellOpts, unmountElement, } from './terminal-lifecycle'; diff --git a/lib/src/lib/terminal-state-store.test.ts b/lib/src/lib/terminal-state-store.test.ts index afd24a36..33c7e026 100644 --- a/lib/src/lib/terminal-state-store.test.ts +++ b/lib/src/lib/terminal-state-store.test.ts @@ -116,6 +116,23 @@ describe('terminal semantic state store command input fallback', () => { expect(getTerminalPaneState('pane').currentCommand?.displayCommand).toBe('lazygit'); }); + it('does not read a half-arrived title OSC as a returned prompt', () => { + // The 1024-char tail this detector reads cuts mid-sequence routinely, and a + // title that carries the prompt string would otherwise land as the last + // visible line and flip a running command back to idle. + submit('pane', 'lazygit'); + recordTerminalOutput('pane', 'still working\r\n\x1b]0;user@host:~/repo$ '); + + expect(getTerminalPaneState('pane').currentCommand?.displayCommand).toBe('lazygit'); + }); + + it('still sees a real prompt trailed by a half-arrived title OSC', () => { + submit('pane', 'lazygit'); + recordTerminalOutput('pane', '\r\nuser@host repo % \x1b]0;~/re'); + + expect(getTerminalPaneState('pane').currentCommand).toBeNull(); + }); + it('ignores prompt-shaped lines emitted inside the alt-screen buffer', () => { submit('pane', 'lazygit'); recordTerminalOutput( diff --git a/lib/src/lib/terminal-state-store.ts b/lib/src/lib/terminal-state-store.ts index 9d86a340..e8b22e5e 100644 --- a/lib/src/lib/terminal-state-store.ts +++ b/lib/src/lib/terminal-state-store.ts @@ -16,6 +16,7 @@ import { type PromptSubmitState, } from './terminal-command-input'; import { derivePromptShape, extractCommand, type PromptShape } from './terminal-prompt-shape'; +import { stripTerminalControls } from './terminal-controls'; import { getSessionIdByPtyId } from './terminal-store'; const paneStates = new Map(); @@ -193,14 +194,16 @@ export function recordTerminalUserInputByPtyId(ptyId: string, input: string, rea recordTerminalUserInput(resolvePaneStateIdByPtyId(ptyId), input, reader); } -// `dor split/ensure -- ` spawns a real interactive shell and types the -// command into it once it reaches a prompt (see typeCommandWhenPromptReady), -// rather than running `shell -c command`. We seed that command here at spawn, -// before it is typed, for two reasons. First, it is the readiness sentinel: +// Programmatically launched interactive commands bypass xterm's onData +// keystroke fallback, so callers seed their semantic command state here before +// the PTY write. `dor split/ensure -- ` does this at spawn, before +// typeCommandWhenPromptReady types it. The cold-restore resume action does it +// immediately before its direct write. For split/ensure it is also the readiness +// sentinel: // typeCommandWhenPromptReady waits for this currentCommand to clear, which // happens when the shell draws its first prompt (OSC promptStart, or the // keystroke heuristic's prompt detector for shells without integration) — the -// signal the shell can take input. Second, it bridges the matching window until +// signal the shell can take input. It then bridges the matching window until // the command is typed and the integration re-reports it via OSC 633, so // `dor ensure` can match a surface it (or a prior ensure) created. Sourced as // `user_input` so it does not mark the pane OSC-driven and so the first-prompt @@ -332,7 +335,11 @@ function resolvePaneStateIdByPtyId(ptyId: string): string { // integration, returning the prompt line (for shape learning) or null. Custom // prompts that lack the path/user context signal (`/`, `~`, `@`, `:`) or a // recognized terminator (`$`, `#`, `%`, `>`) won't match — intentional, since -// false positives would prematurely flip a running command back to idle. +// false positives would prematurely flip a running command back to idle. The +// 1024-char tail this reads lands mid-sequence routinely, which is why the +// shared `stripTerminalControls` swallows an unterminated string control: a +// buffer ending in a half-arrived title OSC would otherwise offer its payload +// up as the last visible line. function detectReturnedShellPrompt(output: string): string | null { const visible = stripAltScreenSpans(output); const text = stripTerminalControls(visible).replace(/\r\n/g, '\n').replace(/\r/g, '\n'); @@ -406,15 +413,6 @@ function stripAltScreenSpans(input: string): string { return result; } -function stripTerminalControls(input: string): string { - return input - .replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '') - .replace(/\x1bP[\s\S]*?\x1b\\/g, '') - .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '') - .replace(/\x1b[()][A-Za-z0-9]/g, '') - .replace(/\x1b[@-_]/g, ''); -} - function notifyTerminalPaneStateListeners(): void { cachedSnapshot = null; listeners.forEach((listener) => listener()); diff --git a/lib/src/remote/host/remote-api.ts b/lib/src/remote/host/remote-api.ts index 327327fb..af6da0ff 100644 --- a/lib/src/remote/host/remote-api.ts +++ b/lib/src/remote/host/remote-api.ts @@ -35,6 +35,7 @@ import { type TerminalWriteParams, } from 'server-lib-common'; import { getPlatform } from '../../lib/platform'; +import { clearResumeOffer } from '../../lib/resume-offers'; import { registry } from '../../lib/terminal-store'; import type { TerminalEntry } from '../../lib/terminal-store'; import { subscribeToActivity } from '../../lib/session-activity-store'; @@ -324,6 +325,10 @@ export class RemoteApiSession { if (!resolved) return; const { params, attachment } = resolved; // Feed the existing PTY input path; the local echo returns via onPtyData. + // This bypasses xterm's onData, so retire the Host's cold-restore resume + // offer here — a phone keystroke answers it exactly as a local one does + // (docs/specs/layout.md -> Resume offer). + clearResumeOffer(attachment.ptyId); getPlatform().writePty(attachment.ptyId, utf8Decode(fromBase64Url(params.bytes))); this.#ok(request, {}); } diff --git a/lib/src/stories/ResumeBanner.stories.tsx b/lib/src/stories/ResumeBanner.stories.tsx new file mode 100644 index 00000000..6fc540e8 --- /dev/null +++ b/lib/src/stories/ResumeBanner.stories.tsx @@ -0,0 +1,98 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useEffect } from 'react'; +import { ResumeBannerView } from '../components/wall/ResumeBanner'; +import { TerminalPane } from '../components/TerminalPane'; +import { clearResumeOffer, offerResumeCommand } from '../lib/resume-offers'; +import { BOLD, DIM, PROMPT, RESET, fg } from '../lib/ansi'; +import type { FakeScenario } from '../lib/platform'; +import { settleTerminals } from './settle-terminals'; + +// Scrollback as a cold restore replays it: the agent's parting resume hint is +// the last thing the old process wrote, and below it sits the prompt of the +// FRESH shell the restore spawned. The offer exists to close that gap. +const RESTORED_AGENT_SCROLLBACK = [ + `${fg(35)}✻${RESET} Compacting conversation…`, + ``, + ` Wrote ${BOLD}lib/src/lib/resume-patterns.ts${RESET}`, + ` Wrote ${BOLD}lib/src/lib/resume-patterns.test.ts${RESET}`, + ``, + `${fg(32)}✔${RESET} 9 tests passed`, + ``, + `${DIM}Session ended. Resume it with:${RESET}`, + `${DIM} claude --resume 4f2c9b1e-6a03-4d5e-9c17-0b8ad2e15f44${RESET}`, + ``, +].join('\r\n'); + +function restoredScenario(): FakeScenario { + return { + name: 'restored-agent-pane', + chunks: [{ delay: 0, data: `${RESTORED_AGENT_SCROLLBACK}\r\n${PROMPT}` }], + endsWithPrompt: true, + }; +} + +const CLAUDE_RESUME = 'claude --resume 4f2c9b1e-6a03-4d5e-9c17-0b8ad2e15f44'; + +/** + * The offer in the only place it ever appears: over a cold-restored terminal. + * Seeds the store exactly as `restoreSession` does and renders a bare + * `TerminalPane` — the offer shows up because the pane mounts the connected + * `ResumeBanner` itself, so this is the shipping path, not a mock of it. Run + * writes to the fake PTY, which has no shell behind it, so nothing echoes back. + */ +function RestoredPane({ id, command }: { id: string; command: string }) { + useEffect(() => { + offerResumeCommand(id, command); + return () => clearResumeOffer(id); + }, [id, command]); + + return ( +
+ +
+ ); +} + +const meta: Meta = { + title: 'Terminal/ResumeBanner', + component: RestoredPane, + parameters: { fakePty: { scenario: restoredScenario() } }, + play: () => settleTerminals(), +}; + +export default meta; +type Story = StoryObj; + +/** The shipping case: a `claude --resume ` offer over restored scrollback. */ +export const InRestoredPane: Story = { + args: { id: 'resume-claude', command: CLAUDE_RESUME }, +}; + +/** Every pattern `detectResumeCommand` knows, on the terminal surface the offer + * floats over. No terminal — this is the chrome. The run label is the + * invocation, so a long session id never changes the button's width. */ +export const CommandVariants: StoryObj = { + parameters: { fakePty: { scenario: undefined } }, + // No terminal in this one, so meta's settleTerminals gate would just burn its + // whole timeout waiting for content that never comes. + play: async () => {}, + render: () => ( +
+ {[ + { label: 'claude --resume (long uuid)', command: CLAUDE_RESUME }, + { label: 'claude --continue', command: 'claude --continue' }, + { label: 'codex resume', command: 'codex resume 01JCX8ZK5Q7M3N' }, + ].map(({ label, command }) => ( +
+ {label} + {}} onDismiss={() => {}} /> +
+ ))} + {/* Narrow pane: both buttons must still fit inside the pane. */} +
+ narrow pane + {}} onDismiss={() => {}} /> +
+
+ ), +}; diff --git a/vscode-ext/src/session-state.ts b/vscode-ext/src/session-state.ts index 7593a999..e50a12d4 100644 --- a/vscode-ext/src/session-state.ts +++ b/vscode-ext/src/session-state.ts @@ -1,8 +1,7 @@ import * as vscode from 'vscode'; import * as ptyManager from './pty-manager'; import type { AlertState } from '../../lib/src/lib/alert-manager'; -import { browserPersistedPane, readPersistedSession, type PersistedAlertState, type PersistedPane, type PersistedSession } from '../../lib/src/lib/session-types'; -import { trimPersistedScrollback } from '../../lib/src/lib/scrollback-trim'; +import { browserPersistedPane, readPersistedSession, terminalPersistedContent, type PersistedAlertState, type PersistedPane, type PersistedSession } from '../../lib/src/lib/session-types'; import { log } from './log'; const SESSION_STATE_KEY = 'dormouse.session'; @@ -81,8 +80,10 @@ export async function refreshSavedSessionStateFromPtys( return { ...pane, cwd: cwd ?? pane.cwd ?? null, - // Mirrors the frontend save path (session-save.ts). - scrollback: trimPersistedScrollback(scrollback ?? pane.scrollback ?? null), + // Re-derives the resume command from the scrollback being written rather + // than carrying the saved one forward — this is the save VS Code restores + // from, and a pane that resumed again has a newer hint at the tail. + ...terminalPersistedContent(scrollback ?? pane.scrollback ?? null), alert, }; }),