From f7d28b53e5ef1cbc7fee4d6cd8ab4a51fdac7f99 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 7 May 2026 17:59:12 -0700 Subject: [PATCH 01/50] Add terminal CWD and command state model --- AGENTS.md | 1 + docs/specs/iTerm2.md | 10 +- docs/specs/layout.md | 12 +- docs/specs/terminal-state.md | 201 ++++++ docs/specs/vscode.md | 7 +- lib/src/components/Baseboard.tsx | 29 +- lib/src/components/Wall.tsx | 12 +- .../components/wall/TerminalPaneHeader.tsx | 24 +- lib/src/lib/platform/fake-adapter.ts | 7 + lib/src/lib/platform/vscode-adapter.ts | 28 +- lib/src/lib/terminal-lifecycle.ts | 20 + lib/src/lib/terminal-protocol.test.ts | 108 ++- lib/src/lib/terminal-protocol.ts | 114 ++- lib/src/lib/terminal-registry.ts | 40 ++ lib/src/lib/terminal-state-store.ts | 128 ++++ lib/src/lib/terminal-state.test.ts | 210 ++++++ lib/src/lib/terminal-state.ts | 658 ++++++++++++++++++ standalone/src/tauri-adapter.ts | 12 +- vscode-ext/src/message-router.ts | 18 + vscode-ext/src/message-types.ts | 2 + 20 files changed, 1622 insertions(+), 19 deletions(-) create mode 100644 docs/specs/terminal-state.md create mode 100644 lib/src/lib/terminal-state-store.ts create mode 100644 lib/src/lib/terminal-state.test.ts create mode 100644 lib/src/lib/terminal-state.ts diff --git a/AGENTS.md b/AGENTS.md index 527390f..3390138 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,7 @@ The primary job of a spec is to be an accurate reference for the current state o - **`docs/specs/layout.md`** — Tiling layout, pane/door containers, dockview configuration, modes (passthrough/command), keyboard shortcuts, selection overlay, spatial navigation, minimize/reattach, inline rename, session lifecycle, session persistence, and theming. Read this when touching: `Wall.tsx`, `Baseboard.tsx`, `Door.tsx`, `TerminalPane.tsx`, `spatial-nav.ts`, `layout-snapshot.ts`, `terminal-registry.ts`, `session-save.ts`, `session-restore.ts`, `reconnect.ts`, `index.css`, `theme.css`, or any keyboard/navigation/mode behavior. - **`docs/specs/alert.md`** — Activity monitoring state machine, alert trigger/clearing rules, attention model, TODO lifecycle (soft/hard), bell button visual states and interaction, door alert indicators, and hardening (a11y, motion, i18n, overflow). Read this when touching: `activity-monitor.ts`, `alert-manager.ts`, the alert bell or TODO pill in `Wall.tsx` (TerminalPaneHeader), alert indicators in `Door.tsx`, or the `a`/`t` keyboard shortcuts. Layout.md defers to this spec for all alert/TODO behavior. - **`docs/specs/iTerm2.md`** — iTerm2-compatible identity, terminal notification protocols (`OSC 9`, `OSC 99`, `OSC 777`), and `OSC 9;4` progress arming, including how protocol signals force or cock the alert/TODO system. Read this when touching PTY environment identity, terminal device/version reports, OSC parsing, `AlertManager` protocol notification/progress paths, `ActivityState` metadata, or TODO notification preview UI. +- **`docs/specs/terminal-state.md`** — Terminal semantic state for CWD, shell prompt/editing/running/finished lifecycle, command runs, terminal title fallback, normalized OSC events (`OSC 7`, `OSC 9;9`, `OSC 133`, `OSC 633`, `OSC 1337`, `OSC 0/2`), header derivation, and grouping keys. Read this when touching `terminal-state.ts`, `terminal-state-store.ts`, semantic event parsing in `terminal-protocol.ts`, adapter semantic event forwarding, or derived pane/door labels. - **`docs/specs/vscode.md`** — VS Code extension architecture: hosting modes (WebviewView + WebviewPanel), PTY lifecycle and buffering, message protocol between webview and extension host, session persistence flow, reconnection protocol, theme integration, CSP, build pipeline, and invariants (save-before-kill ordering, PTY ownership, alert state merging). Read this when touching: `extension.ts`, `webview-view-provider.ts`, `message-router.ts`, `message-types.ts`, `pty-manager.ts`, `pty-host.js`, `session-state.ts`, `webview-html.ts`, `vscode-adapter.ts`, or `pty-core.js`. - **`docs/specs/tutorial.md`** — Playground tutorial on the website: 3-pane layout, interactive `tut` TUI runner with three sections (keyboard navigation, alerts/TODOs, copy/paste), per-item detection wired to `WallEvent` / activity store / mouse-selection store, single-key `mouseterm-tut-v3` localStorage scheme, theme picker, and FakePtyAdapter extensions (`sendOutput`, `pumpActivity`, `setInputHandler`). Read this when touching: `website/src/pages/Playground.tsx`, `website/src/lib/tut-runner.ts`, `website/src/lib/tut-detector.ts`, `website/src/lib/tutorial-state.ts`, `website/src/lib/tut-items.ts`, `website/src/lib/tutorial-shell.ts`, `lib/src/components/ThemePicker.tsx`, `lib/src/lib/themes/`, `lib/src/lib/platform/fake-scenarios.ts` (tutorial scenarios), the `WallEvent` union, or the `onApiReady`/`onEvent`/`initialPaneIds` props on Wall. - **`docs/specs/theme.md`** — Theme system: two-layer CSS variable strategy, theme data model, conversion pipeline, bundled themes, localStorage store, shared ThemePicker component, standalone AppBar picker, runtime OpenVSX installer. Read this when touching: `lib/src/lib/themes/`, `lib/src/components/ThemePicker.tsx`, `lib/src/theme.css`, `lib/scripts/bundle-themes.mjs`, `standalone/src/AppBar.tsx` (theme picker), `standalone/src/main.tsx` (theme restore), or `website/src/components/SiteHeader.tsx` (themeAware mode). diff --git a/docs/specs/iTerm2.md b/docs/specs/iTerm2.md index 45eb7d2..c34edb8 100644 --- a/docs/specs/iTerm2.md +++ b/docs/specs/iTerm2.md @@ -1,6 +1,6 @@ # iTerm2 Compatibility Spec -> See `docs/specs/ontology.md` for canonical Session vocabulary and `docs/specs/alert.md` for the Activity state machine. This spec defines terminal-emulator identity and explicit terminal notification escape sequences. +> See `docs/specs/ontology.md` for canonical Session vocabulary and `docs/specs/alert.md` for the Activity state machine. This spec defines terminal-emulator identity and explicit terminal notification escape sequences. CWD, shell lifecycle, OSC 133, OSC 633, OSC 1337 `CurrentDir`, and OSC 0/2 title fallback are defined in `docs/specs/terminal-state.md`. ## Goal @@ -48,6 +48,14 @@ Because this identity can cause tools to emit more iTerm2 escape codes, unsuppor The OSC notification families use sequences introduced by `ESC ]`. MouseTerm must accept either `BEL` (`\x07`) or `ST` (`ESC \`) terminators for these notification families. A `BEL` that terminates an OSC is part of that OSC sequence, not a standalone bell notification. +The same streaming parser also recognizes semantic terminal OSCs from `docs/specs/terminal-state.md`: + +- `OSC 7` / `OSC 9;9` / `OSC 633;P;Cwd=` / `OSC 1337;CurrentDir=` for CWD +- `OSC 133` and `OSC 633` prompt/command boundaries +- `OSC 0` and `OSC 2` title fallback + +Those semantic sequences are normalized into `TerminalSemanticEvent` and consumed by terminal state, not by the Activity alert machine. + | Protocol | Shape | Fields | Notes | |---|---|---|---| | `BEL` | `BEL` outside an OSC sequence | none | Generic terminal-bell notification. | diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 7bd4b6f..36aab25 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -4,7 +4,7 @@ ## Conceptual model -A **Session** is a single PTY instance — a running shell process with its scrollback, environment, and working directory. Sessions are managed by the terminal registry and persist independently of how they are displayed. Each session also carries Activity state (projected alert status, optional TODO flag, and optional protocol notification detail). +A **Session** is a single PTY instance — a running shell process with its scrollback, environment, and semantic terminal state. Sessions are managed by the terminal registry and persist independently of how they are displayed. Each session also carries Activity state (projected alert status, optional TODO flag, and optional protocol notification detail). CWD, foreground-command lifecycle, command titles, terminal titles, header derivation, and grouping keys are defined in `docs/specs/terminal-state.md`. A Session's **View** state places it in one of two containers: @@ -72,9 +72,11 @@ The content area is a tiling layout of panes, powered by dockview. Each pane occ Each pane has a 30px header that doubles as a drag handle. The header uses `cursor-grab` / `active:cursor-grabbing`, `select-none`, and the shared terminal top radius from `lib/src/components/design.tsx`. Background and foreground use the `--color-header-active-*` / `--color-header-inactive-*` token pairs, which map to VSCode file-tree list colors. Dockview's default close button and right-actions container are hidden via CSS. +The header label is derived from `TerminalPaneState`: user-pinned title first, then current/freshly-finished command title, then terminal title or shell fallback. When visible panes would have duplicate primary labels, the header adds a compact directory disambiguator using the running command's `cwdAtStart` or the idle pane's latest `cwd`. + Elements from left to right: -- Session name (click to rename, truncates with ellipsis) +- Derived session label (click to rename/pin, truncates with ellipsis) - Alert bell button (reflects session activity status) - TODO pill (if todo state is set; hidden in minimal tier) - Flexible gap @@ -102,7 +104,7 @@ The header adapts to available width via ResizeObserver in three tiers: Below the content area is the baseboard (`h-7`, 28px). It is always visible and has no top divider. The dockview area ends 2px above it, leaving a narrow theme-colored gap that keeps rounded pane corners distinct from the baseboard. Its horizontal padding matches the Dockview wrapper's 6px inset, so doors align with the panes above. When empty, it shows keyboard shortcut hints when there are no doors and the container is wider than 350px (currently: `LCmd → RCmd to enter command mode`). -When a session is minimized, it becomes a **door** on the baseboard. The door displays the session's title, a TODO badge (if set), and an alert bell icon with activity dot. It uses the bottom edge of the window as its bottom border, with left, top, and right borders using the shared terminal top radius from `lib/src/components/design.tsx` — resembling a mouse hole and matching pane rounding. Door dimensions: `min-w-[68px] max-w-[220px] h-6`. +When a session is minimized, it becomes a **door** on the baseboard. The door displays the same derived terminal label as the pane header, a TODO badge (if set), and an alert bell icon with activity dot. It uses the bottom edge of the window as its bottom border, with left, top, and right borders using the shared terminal top radius from `lib/src/components/design.tsx` — resembling a mouse hole and matching pane rounding. Door dimensions: `min-w-[68px] max-w-[220px] h-6`. ### Door interaction @@ -283,6 +285,8 @@ On startup, recovery is priority-based: Each session carries `ActivityState` with `status: SessionStatus`, `todo: TodoState`, and `notification: ActivityNotification | null`. `status` is the projected public status from the timer-based visual track plus the terminal-report protocol track described in `docs/specs/alert.md`; it may be `OSC_NOTIF_BUSY` when OSC progress has cocked the bell. These are synced to React via `useSyncExternalStore`. State that arrives from the platform before a registry entry exists (resume scenario) is held as "primed state" and applied when the registry entry is created. +Each session also carries `TerminalPaneState` from `docs/specs/terminal-state.md`. The frontend store is keyed by the current pane/session id, and PTY-originated semantic events are resolved through `ptyId` so swapped sessions keep their CWD and command state with the terminal content. + ## Theme Custom `mousetermTheme` extends dockview's `themeAbyss`: @@ -362,6 +366,8 @@ The deferred spawn also only calls `selectPane` if selection is null. The kill h | `lib/src/lib/terminal-registry.ts` | Public facade preserving registry imports | | `lib/src/lib/terminal-store.ts` | Registry maps, terminal entry shape, pending shell opts, overlay dimension types | | `lib/src/lib/terminal-lifecycle.ts` | Session lifecycle: create, resume, restore, mount, unmount, dispose, swap, focus, refit | +| `lib/src/lib/terminal-state.ts` | Pure semantic terminal model: CWD normalization, command reducer, header derivation, grouping helpers | +| `lib/src/lib/terminal-state-store.ts` | React-facing terminal semantic state store and PTY-id to pane-id resolution | | `lib/src/lib/session-activity-store.ts` | React activity snapshot store, primed alert state, alert/TODO platform delegates | | `lib/src/lib/terminal-theme.ts` | xterm theme extraction, terminal host painting, theme MutationObserver | | `lib/src/lib/terminal-report-filter.ts` | Synthetic/replay terminal report detection and replay writer | diff --git a/docs/specs/terminal-state.md b/docs/specs/terminal-state.md new file mode 100644 index 0000000..56452ac --- /dev/null +++ b/docs/specs/terminal-state.md @@ -0,0 +1,201 @@ +# Terminal CWD and Command State + +> See `docs/specs/ontology.md` for Session vocabulary. This spec defines the per-Session terminal semantic state that layout and grouping consume. Alert/TODO behavior remains in `docs/specs/alert.md`; notification OSCs remain in `docs/specs/iTerm2.md`. + +## Goal + +MouseTerm models terminal panes by: + +- latest reported working directory +- current command line +- whether the shell is at a prompt, editing, running a foreground command, or waiting after command finish +- command exit status +- command directory at start time +- terminal title as a fallback label + +Session CWD and command execution state are separate. `cwd` means "the shell/session reported this directory"; it is not necessarily the internal CWD of a foreground program. A command snapshots `cwdAtStart` when it starts, and that snapshot is used for grouping and header disambiguation while the command is running or freshly finished. + +## Core Model + +```ts +type TerminalPaneState = { + cwd: CwdState | null; + activity: ShellActivity; + pendingCommandLine: string | null; + currentCommand: CommandRun | null; + lastCommand: CommandRun | null; + title: TerminalTitle | null; +}; +``` + +```ts +type CwdState = { + uri?: string; + path: string; + host?: string; + scheme?: "file"; + pathKind: "posix" | "windows" | "unknown"; + isRemote: boolean; + source: "osc7" | "osc9_9" | "osc633" | "osc1337" | "process" | "manual"; + updatedAt: number; +}; +``` + +Host identity is part of directory identity. `file://localhost/Users/me/project` and `file://prod-box/home/me/project` are different locations even if their display labels can be compact. + +```ts +type ShellActivity = + | { kind: "unknown" } + | { kind: "prompt" } + | { kind: "editing" } + | { kind: "running" } + | { kind: "finished"; exitCode?: number }; +``` + +This intentionally is not `isRunning`. The shell process normally keeps running; the important state is whether a foreground command is active. + +```ts +type CommandRun = { + id: string; + rawCommandLine: string | null; + displayCommand: string; + cwdAtStart: CwdState | null; + startedAt: number; + finishedAt?: number; + exitCode?: number; + source: + | "osc633_E" + | "osc633_boundaries" + | "osc133_boundaries" + | "foreground_process" + | "title"; + outputRange?: { + startMarkId?: string; + endMarkId?: string; + }; +}; +``` + +```ts +type TerminalTitle = { + title: string; + source: "osc0" | "osc2" | "user" | "profile" | "derived"; + updatedAt: number; +}; +``` + +Terminal title is separate from command state. It is useful as a fallback label, but it is not a command lifecycle signal. + +## Normalized Events + +All protocol parsing emits normalized semantic events before feature code sees the state: + +```ts +type TerminalSemanticEvent = + | { type: "cwd"; cwd: CwdState } + | { type: "promptStart" } + | { type: "promptEnd" } + | { type: "commandLine"; commandLine: string } + | { type: "commandStart"; source?: CommandRun["source"] } + | { type: "commandFinish"; exitCode?: number } + | { type: "title"; title: TerminalTitle }; +``` + +Feature code consumes `TerminalPaneState` or `TerminalSemanticEvent`, never raw OSC sequences. + +## Supported OSC Inputs + +CWD: + +| Sequence | Source | Notes | +|---|---|---| +| `OSC 7 ; file://host/path ST` | `osc7` | Parses as `file:` URI, decodes the path, preserves host. | +| `OSC 9 ; 9 ; ST` | `osc9_9` | Windows Terminal / ConEmu-style CWD. Drive-letter and UNC paths are Windows paths; other paths are `unknown`. | +| `OSC 633 ; P ; Cwd= ST` | `osc633` | VS Code-style CWD. | +| `OSC 1337 ; CurrentDir= ST` | `osc1337` | iTerm2-style CWD compatibility. | + +Command lifecycle: + +| Sequence | Event | +|---|---| +| `OSC 133 ; A ST` / `OSC 633 ; A ST` | `promptStart` | +| `OSC 133 ; B ST` / `OSC 633 ; B ST` | `promptEnd` | +| `OSC 133 ; C ST` | `commandStart(source: "osc133_boundaries")` | +| `OSC 633 ; E ; ST` | `commandLine` | +| `OSC 633 ; C ST` | `commandStart(source: "osc633_E"` when a pending command line exists, otherwise `"osc633_boundaries")` | +| `OSC 133 ; D ; ST` / `OSC 633 ; D ; ST` | `commandFinish` | + +Title fallback: + +| Sequence | Event | +|---|---| +| `OSC 0 ; ST` | `title(source: "osc0")` | +| `OSC 2 ; <title> ST` | `title(source: "osc2")` | + +The parser accepts both BEL and ST terminators and handles split chunks. Unsupported OSCs pass through to xterm unchanged; supported-but-malformed semantic OSCs are consumed without changing state. + +## Reducer + +`reduceTerminalState(state, event)` is the only state transition surface. + +- `cwd` replaces the latest session CWD. +- `promptStart` sets `{ kind: "prompt" }`. +- `promptEnd` sets `{ kind: "editing" }`. +- `commandLine` stores `pendingCommandLine`. +- `commandStart` creates `currentCommand`, snapshots `cwdAtStart`, clears `pendingCommandLine`, and sets `{ kind: "running" }`. +- `commandFinish` moves `currentCommand` to `lastCommand`, stores `finishedAt`/`exitCode`, clears `currentCommand`, and sets `{ kind: "finished", exitCode }`. +- A later prompt signal moves the pane out of `finished`. + +CWD fallback order is: + +1. OSC-reported CWD +2. process CWD, if available +3. initial launch or restored directory +4. `null` + +Process-derived CWD may fill `null` or replace manual/restored CWD, but it must not overwrite explicit OSC CWD. + +## Header Derivation + +```ts +type DerivedHeader = { + primary: string; + secondary?: string; + status: "unknown" | "idle" | "running" | "finished"; + exitCode?: number; +}; +``` + +Rules: + +- A user-pinned title is primary. +- A running command uses `currentCommand.displayCommand`. +- A freshly finished command uses `lastCommand.displayCommand` until the next prompt signal. +- Idle terminals use title or shell fallback. +- Duplicate primary labels get a shortest unique directory label. +- Running and finished commands disambiguate with `cwdAtStart`. +- Idle terminals disambiguate with `pane.cwd`. + +## Grouping + +Supported grouping modes are `none`, `directory`, `command`, and `status`. + +Directory grouping uses: + +```ts +pane.currentCommand?.cwdAtStart ?? pane.cwd +``` + +Command grouping uses: + +```ts +pane.currentCommand?.displayCommand ?? idleLabel(pane) +``` + +Status grouping uses: + +```ts +unknown | idle | running | finished +``` + +Directory group keys use `cwdIdentity(cwd)` so remote hosts and Windows/POSIX path kinds remain distinct. diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 1617041..d0329e6 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -39,6 +39,8 @@ Frontend Library (lib/src/) ├── terminal-lifecycle.ts — xterm lifecycle, PTY wiring, mount/dispose/swap/focus ├── terminal-theme.ts — xterm theme observer and host painting ├── terminal-report-filter.ts — replay/synthetic report filtering + ├── terminal-state.ts — terminal CWD/command semantic model and derivation helpers + ├── terminal-state-store.ts — frontend semantic state store keyed by pane/session id ├── terminal-mouse-router.ts — mouse selection routing ├── session-activity-store.ts — alert/TODO projection and delegates ├── reconnect.ts — resume (live-PTY) + restore (cold-start) entry point @@ -181,10 +183,11 @@ All types defined in `message-types.ts`. Webview-side handling in `vscode-adapte | Message | Purpose | |---------|---------| -| `pty:data` | PTY output (routed only to owning router) | +| `pty:data` | PTY output after supported OSC sequences have been parsed/stripped (routed only to owning router) | | `pty:exit` | PTY process exited (with exitCode) | +| `terminal:semanticEvents` | Normalized CWD/title/prompt/command events parsed in the extension host from live PTY data | | `pty:list` | List of all resumable PTYs (response to `mouseterm:init`) | -| `pty:replay` | Buffered output since spawn (response to `mouseterm:init`) | +| `pty:replay` | Buffered raw output since spawn (response to `mouseterm:init`); the webview parses semantic OSCs during replay reconstruction without triggering alerts | | `pty:cwd` | CWD query response (matched by requestId) | | `pty:scrollback` | Scrollback query response (matched by requestId) | | `pty:shells` | Available shells list response (matched by requestId) | diff --git a/lib/src/components/Baseboard.tsx b/lib/src/components/Baseboard.tsx index a986956..abecec6 100644 --- a/lib/src/components/Baseboard.tsx +++ b/lib/src/components/Baseboard.tsx @@ -4,7 +4,15 @@ import { Door } from './Door'; import { DoorElementsContext } from './wall/wall-context'; import type { DooredItem } from './wall/wall-types'; import { IS_MAC } from '../lib/platform'; -import { DEFAULT_ACTIVITY_STATE, getActivitySnapshot, subscribeToActivity } from '../lib/terminal-registry'; +import { + DEFAULT_ACTIVITY_STATE, + deriveHeader, + getActivitySnapshot, + getTerminalPaneStateSnapshot, + subscribeToActivity, + subscribeToTerminalPaneState, +} from '../lib/terminal-registry'; +import { createTerminalPaneState, type TerminalPaneState } from '../lib/terminal-state'; export interface BaseboardProps { items: DooredItem[]; @@ -15,6 +23,7 @@ export interface BaseboardProps { export function Baseboard({ items, onReattach, notice }: BaseboardProps) { const { elements: doorElements, bumpVersion } = useContext(DoorElementsContext); const activityStates = useSyncExternalStore(subscribeToActivity, getActivitySnapshot); + const terminalStates = useSyncExternalStore(subscribeToTerminalPaneState, getTerminalPaneStateSnapshot); const containerRef = useRef<HTMLDivElement>(null); const [containerWidth, setContainerWidth] = useState(0); const [startIndex, setStartIndex] = useState(0); @@ -52,7 +61,7 @@ export function Baseboard({ items, onReattach, notice }: BaseboardProps) { if (arrowMeasureEl.current) { layoutMetrics.current.arrowWidth = arrowMeasureEl.current.offsetWidth; } - }, [items, activityStates]); + }, [items, activityStates, terminalStates]); // Reset startIndex when the set of door items changes (not just count) const itemKey = useMemo(() => items.map(i => i.id).join('\0'), [items]); @@ -140,10 +149,11 @@ export function Baseboard({ items, onReattach, notice }: BaseboardProps) { <div ref={measureEl} className="absolute -left-[9999px] flex gap-1.5" aria-hidden> {items.map(item => { const activity = activityStates.get(item.id) ?? DEFAULT_ACTIVITY_STATE; + const title = deriveDoorTitle(item.title, item.id, terminalStates); return ( <Door key={item.id} - title={item.title} + title={title} status={activity.status} todo={activity.todo} @@ -173,11 +183,12 @@ export function Baseboard({ items, onReattach, notice }: BaseboardProps) { {items.slice(startIndex, endIndex).map(item => { const activity = activityStates.get(item.id) ?? DEFAULT_ACTIVITY_STATE; + const title = deriveDoorTitle(item.title, item.id, terminalStates); return ( <Door key={item.id} doorId={item.id} - title={item.title} + title={title} status={activity.status} todo={activity.todo} onClick={() => onReattach(item)} @@ -199,3 +210,13 @@ export function Baseboard({ items, onReattach, notice }: BaseboardProps) { </div> ); } + +function deriveDoorTitle( + savedTitle: string, + id: string, + terminalStates: Map<string, TerminalPaneState>, +): string { + const paneState = terminalStates.get(id) ?? createTerminalPaneState(); + const derived = deriveHeader(paneState, terminalStates.size > 0 ? [...terminalStates.values()] : [paneState]).primary; + return derived === 'shell' && savedTitle !== '<unnamed>' ? savedTitle : derived; +} diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 08f2557..97ce7ae 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -16,6 +16,10 @@ import { toggleSessionTodo, setPendingShellOpts, getDefaultShellOpts, + getTerminalPaneState, + getTerminalPaneStateSnapshot, + deriveHeader, + setTerminalUserTitle, type SessionStatus, } from '../lib/terminal-registry'; import { findReattachNeighbor } from '../lib/spatial-nav'; @@ -260,7 +264,12 @@ export function Wall({ if (!api) return; const panel = api.getPanel(id); if (!panel) return; - const title = panel.title ?? id; + const derivedTitle = deriveHeader( + getTerminalPaneState(id), + [...getTerminalPaneStateSnapshot().values()], + ).primary; + const panelTitle = panel.title ?? id; + const title = derivedTitle === 'shell' && panelTitle !== '<unnamed>' ? panelTitle : derivedTitle; const layoutAtMinimize = cloneLayout(api.toJSON()); // Capture the nearest adjacent pane and our actual relative position @@ -527,6 +536,7 @@ export function Wall({ const trimmed = value.trim(); if (trimmed) { apiRef.current?.getPanel(id)?.api.setTitle(trimmed); + setTerminalUserTitle(id, trimmed); } setRenamingPaneId(null); }, diff --git a/lib/src/components/wall/TerminalPaneHeader.tsx b/lib/src/components/wall/TerminalPaneHeader.tsx index 34f7ef6..797e082 100644 --- a/lib/src/components/wall/TerminalPaneHeader.tsx +++ b/lib/src/components/wall/TerminalPaneHeader.tsx @@ -29,9 +29,12 @@ import { clearSessionTodo, DEFAULT_ACTIVITY_STATE, getActivitySnapshot, + getTerminalPaneStateSnapshot, subscribeToActivity, + subscribeToTerminalPaneState, type SessionStatus, } from '../../lib/terminal-registry'; +import { createTerminalPaneState, deriveHeader } from '../../lib/terminal-state'; import { DialogKeyboardContext, ModeContext, @@ -75,9 +78,19 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { const windowFocused = useContext(WindowFocusedContext); const setDialogKeyboardActive = useContext(DialogKeyboardContext); const activityStates = useSyncExternalStore(subscribeToActivity, getActivitySnapshot); + const terminalStates = useSyncExternalStore(subscribeToTerminalPaneState, getTerminalPaneStateSnapshot); const mouseStates = useSyncExternalStore(subscribeToMouseSelection, getMouseSelectionSnapshot); const actions = useContext(WallActionsContext); const activity = activityStates.get(api.id) ?? DEFAULT_ACTIVITY_STATE; + const paneState = terminalStates.get(api.id) ?? createTerminalPaneState(); + const visiblePaneStates = terminalStates.size > 0 ? [...terminalStates.values()] : [paneState]; + const derivedHeader = deriveHeader(paneState, visiblePaneStates); + const apiTitle = api.title ?? ''; + const displayTitle = paneState.title?.source === 'user' + ? paneState.title.title + : derivedHeader.primary === 'shell' && apiTitle && apiTitle !== '<unnamed>' + ? apiTitle + : derivedHeader.primary; const mouseState = mouseStates.get(api.id) ?? DEFAULT_MOUSE_SELECTION_STATE; const showMouseIcon = mouseState.mouseReporting !== 'none'; const inOverride = mouseState.override !== 'off'; @@ -147,7 +160,7 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { {isRenaming ? ( <input className="bg-transparent outline-none border-none text-inherit font-medium font-mono w-full min-w-0 p-0 m-0" - defaultValue={api.title} + defaultValue={displayTitle} autoFocus ref={(el) => el?.select()} onKeyDown={(e) => { @@ -163,10 +176,15 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { /> ) : ( <span - className="min-w-0 truncate cursor-text font-medium text-inherit decoration-current/50 underline-offset-2 hover:underline" + className="flex min-w-0 cursor-text items-baseline font-medium text-inherit decoration-current/50 underline-offset-2 hover:underline" onMouseDown={(e) => e.stopPropagation()} onClick={(e) => { e.stopPropagation(); actions.onStartRename(api.id); }} - >{api.title}</span> + > + <span className="min-w-0 truncate">{displayTitle}</span> + {derivedHeader.secondary && ( + <span className="ml-1 max-w-[45%] shrink-0 truncate opacity-70">{derivedHeader.secondary}</span> + )} + </span> )} <HeaderActionButton className={[ diff --git a/lib/src/lib/platform/fake-adapter.ts b/lib/src/lib/platform/fake-adapter.ts index c0f0b85..4679601 100644 --- a/lib/src/lib/platform/fake-adapter.ts +++ b/lib/src/lib/platform/fake-adapter.ts @@ -2,9 +2,14 @@ import type { AlertStateDetail, PlatformAdapter, PtyInfo } from './types'; import { AlertManager, type SessionStatus } from '../alert-manager'; import { applyTerminalProtocolEvents, + collectTerminalSemanticEvents, collectTerminalProtocolResponses, TerminalProtocolParser, } from '../terminal-protocol'; +import { + applyTerminalSemanticEventsByPtyId, + removeTerminalPaneState, +} from '../terminal-state-store'; export interface FakeScenario { name: string; @@ -153,6 +158,7 @@ export class FakePtyAdapter implements PlatformAdapter { this.terminalSizes.delete(id); this.inputHandlers.delete(id); this.protocolParsers.delete(id); + removeTerminalPaneState(id); for (const handler of this.exitHandlers) { handler({ id, exitCode: 0 }); } @@ -344,6 +350,7 @@ export class FakePtyAdapter implements PlatformAdapter { private emitPtyData(id: string, data: string, options: { skipActivity?: boolean } = {}): void { const parsed = this.getProtocolParser(id).process(data); applyTerminalProtocolEvents(this.alertManager, id, parsed.events); + applyTerminalSemanticEventsByPtyId(id, collectTerminalSemanticEvents(parsed.events)); const inputHandler = this.inputHandlers.get(id); for (const response of collectTerminalProtocolResponses(parsed.events)) { inputHandler?.(response); diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index cbca67d..a7cf0e0 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -1,5 +1,13 @@ import type { AlertStateDetail, PlatformAdapter, PtyInfo } from './types'; import { setDefaultShellOpts } from '../shell-defaults'; +import { + collectTerminalSemanticEvents, + TerminalProtocolParser, +} from '../terminal-protocol'; +import { + applyTerminalSemanticEventsByPtyId, + removeTerminalPaneState, +} from '../terminal-state-store'; export class VSCodeAdapter implements PlatformAdapter { private vscode: ReturnType<typeof acquireVsCodeApi>; @@ -10,6 +18,7 @@ export class VSCodeAdapter implements PlatformAdapter { private replayHandlers = new Set<(detail: { id: string; data: string }) => void>(); private flushRequestHandlers = new Set<(detail: { requestId: string }) => void>(); private alertStateHandlers = new Set<(detail: AlertStateDetail) => void>(); + private replayProtocolParsers = new Map<string, TerminalProtocolParser>(); constructor() { this.vscode = acquireVsCodeApi(); @@ -41,9 +50,14 @@ export class VSCodeAdapter implements PlatformAdapter { handler({ ptys: msg.ptys }); } } else if (msg.type === 'pty:replay') { + const parser = this.getReplayProtocolParser(msg.id); + const parsed = parser.process(msg.data); + applyTerminalSemanticEventsByPtyId(msg.id, collectTerminalSemanticEvents(parsed.events)); for (const handler of this.replayHandlers) { - handler({ id: msg.id, data: msg.data }); + handler({ id: msg.id, data: parsed.visibleData }); } + } else if (msg.type === 'terminal:semanticEvents') { + applyTerminalSemanticEventsByPtyId(msg.id, msg.events ?? []); } else if (msg.type === 'mouseterm:flushSessionSave') { for (const handler of this.flushRequestHandlers) { handler({ requestId: msg.requestId }); @@ -115,6 +129,7 @@ export class VSCodeAdapter implements PlatformAdapter { } spawnPty(id: string, options?: { cols?: number; rows?: number; cwd?: string; shell?: string; args?: string[] }): void { + this.replayProtocolParsers.set(id, new TerminalProtocolParser()); this.vscode.postMessage({ type: 'pty:spawn', id, options }); } @@ -127,6 +142,8 @@ export class VSCodeAdapter implements PlatformAdapter { } killPty(id: string): void { + this.replayProtocolParsers.delete(id); + removeTerminalPaneState(id); this.vscode.postMessage({ type: 'pty:kill', id }); } @@ -273,4 +290,13 @@ export class VSCodeAdapter implements PlatformAdapter { // first load, before any setState has run. return this.vscode.getState() ?? this.hostState; } + + private getReplayProtocolParser(id: string): TerminalProtocolParser { + let parser = this.replayProtocolParsers.get(id); + if (!parser) { + parser = new TerminalProtocolParser(); + this.replayProtocolParsers.set(id, parser); + } + return parser; + } } diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index d83f978..b30276a 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -25,6 +25,15 @@ import { writeReplay, } from './terminal-report-filter'; import { getTerminalTheme, paintTerminalHost, startThemeObserver } from './terminal-theme'; +import { + ensureTerminalPaneState, + fillTerminalProcessCwd, + removeTerminalPaneState, + resetTerminalPaneState, + seedTerminalManualCwd, + setTerminalUserTitle, + swapTerminalPaneStates, +} from './terminal-state-store'; function createXtermHost(): { terminal: Terminal; fit: FitAddon; element: HTMLDivElement } { const styles = getComputedStyle(document.body); @@ -174,6 +183,7 @@ function setupTerminalEntry(id: string): TerminalEntry { } registry.set(id, entry); + ensureTerminalPaneState(id); notifyActivityListeners(); startThemeObserver(); return entry; @@ -188,6 +198,7 @@ export function getOrCreateTerminal(id: string): TerminalEntry { if (existing) return existing; const entry = setupTerminalEntry(id); + resetTerminalPaneState(id); const shellOpts = pendingShellOpts.get(id); pendingShellOpts.delete(id); @@ -198,6 +209,7 @@ export function getOrCreateTerminal(id: string): TerminalEntry { rows: dims?.rows || 30, ...shellOpts, }); + void getPlatform().getCwd(id).then((cwd) => fillTerminalProcessCwd(id, cwd)); return entry; } @@ -230,6 +242,11 @@ export function restoreTerminal( if (existing) return existing; const entry = setupTerminalEntry(id); + resetTerminalPaneState(id); + seedTerminalManualCwd(id, opts.cwd); + if (opts.title && opts.title !== '<unnamed>') { + setTerminalUserTitle(id, opts.title); + } if (opts.scrollback) { writeReplay(entry, opts.scrollback, '\r\n'); @@ -246,6 +263,7 @@ export function restoreTerminal( shell: opts.shell, args: opts.args, }); + void getPlatform().getCwd(id).then((cwd) => fillTerminalProcessCwd(id, cwd)); return entry; } @@ -278,6 +296,7 @@ export function disposeSession(id: string): void { entry.element.remove(); entry.terminal.dispose(); registry.delete(id); + removeTerminalPaneState(id); removeMouseSelectionState(id); notifyActivityListeners(); } @@ -295,6 +314,7 @@ export function swapTerminals(idA: string, idB: string): void { registry.set(idA, entryB); registry.set(idB, entryA); + swapTerminalPaneStates(idA, idB); if (containerA) { containerA.appendChild(entryB.element); diff --git a/lib/src/lib/terminal-protocol.test.ts b/lib/src/lib/terminal-protocol.test.ts index 3a73e92..ccb9759 100644 --- a/lib/src/lib/terminal-protocol.test.ts +++ b/lib/src/lib/terminal-protocol.test.ts @@ -125,12 +125,116 @@ describe('TerminalProtocolParser', () => { it('passes unsupported OSC sequences through to xterm', () => { const parser = new TerminalProtocolParser(); - const result = parser.process('\x1b]0;title\x07text'); + const result = parser.process('\x1b]555;unknown\x07text'); - expect(result.visibleData).toBe('\x1b]0;title\x07text'); + expect(result.visibleData).toBe('\x1b]555;unknown\x07text'); expect(result.events).toEqual([]); }); + it('parses and strips CWD OSC sequences into semantic events', () => { + const parser = new TerminalProtocolParser(); + const result = parser.process('a\x1b]7;file://prod-box/home/me/project\x1b\\b\x1b]9;9;C:\\repo\x07c'); + + expect(result.visibleData).toBe('abc'); + expect(result.events).toEqual([ + { + kind: 'semantic', + event: { + type: 'cwd', + cwd: { + uri: 'file://prod-box/home/me/project', + path: '/home/me/project', + host: 'prod-box', + scheme: 'file', + pathKind: 'posix', + isRemote: true, + source: 'osc7', + updatedAt: expect.any(Number), + }, + }, + }, + { + kind: 'semantic', + event: { + type: 'cwd', + cwd: { + path: 'C:\\repo', + pathKind: 'windows', + isRemote: false, + source: 'osc9_9', + updatedAt: expect.any(Number), + }, + }, + }, + ]); + }); + + it('parses OSC 133 and 633 command lifecycle events', () => { + const parser = new TerminalProtocolParser(); + + expect(parser.process('\x1b]133;A\x07\x1b]133;B\x07\x1b]133;C\x07\x1b]133;D;2\x07').events).toEqual([ + { kind: 'semantic', event: { type: 'promptStart' } }, + { kind: 'semantic', event: { type: 'promptEnd' } }, + { kind: 'semantic', event: { type: 'commandStart', source: 'osc133_boundaries' } }, + { kind: 'semantic', event: { type: 'commandFinish', exitCode: 2 } }, + ]); + + expect(parser.process('\x1b]633;E;pnpm test --watch\x07\x1b]633;C\x07\x1b]633;D\x07').events).toEqual([ + { kind: 'semantic', event: { type: 'commandLine', commandLine: 'pnpm test --watch' } }, + { kind: 'semantic', event: { type: 'commandStart', source: 'osc633_boundaries' } }, + { kind: 'semantic', event: { type: 'commandFinish', exitCode: undefined } }, + ]); + }); + + it('parses OSC 633 and 1337 CWD plus title fallbacks', () => { + const parser = new TerminalProtocolParser(); + const result = parser.process('\x1b]633;P;Cwd=/tmp/with%20space\x07\x1b]1337;CurrentDir=/Users/me/app\x07\x1b]0;zsh\x07\x1b]2;vim\x07'); + + expect(result.visibleData).toBe(''); + expect(result.events).toEqual([ + { + kind: 'semantic', + event: { + type: 'cwd', + cwd: { + path: '/tmp/with space', + pathKind: 'posix', + isRemote: false, + source: 'osc633', + updatedAt: expect.any(Number), + }, + }, + }, + { + kind: 'semantic', + event: { + type: 'cwd', + cwd: { + path: '/Users/me/app', + pathKind: 'posix', + isRemote: false, + source: 'osc1337', + updatedAt: expect.any(Number), + }, + }, + }, + { + kind: 'semantic', + event: { + type: 'title', + title: { title: 'zsh', source: 'osc0', updatedAt: expect.any(Number) }, + }, + }, + { + kind: 'semantic', + event: { + type: 'title', + title: { title: 'vim', source: 'osc2', updatedAt: expect.any(Number) }, + }, + }, + ]); + }); + it('responds to iTerm2 extended device attribute queries', () => { const parser = new TerminalProtocolParser(); const result = parser.process(`before\x1b[>qafter`); diff --git a/lib/src/lib/terminal-protocol.ts b/lib/src/lib/terminal-protocol.ts index 7a3c52b..1e1c96d 100644 --- a/lib/src/lib/terminal-protocol.ts +++ b/lib/src/lib/terminal-protocol.ts @@ -1,9 +1,19 @@ import type { ActivityNotification, ProtocolProgressUpdate } from './alert-manager'; +import { + cwdFromOsc1337, + cwdFromOsc633, + cwdFromOsc7, + cwdFromOsc9_9, + type CommandRunSource, + type TerminalSemanticEvent, + type TerminalTitle, +} from './terminal-state'; export type TerminalProtocolEvent = | { kind: 'notification'; notification: ActivityNotification } | { kind: 'progress'; progress: ProtocolProgressUpdate } - | { kind: 'response'; data: string }; + | { kind: 'response'; data: string } + | { kind: 'semantic'; event: TerminalSemanticEvent }; export interface TerminalProtocolAlertSink { notifyFromProtocol(id: string, notification: ActivityNotification): void; @@ -88,7 +98,13 @@ export class TerminalProtocolParser { } private parseOsc(content: string): TerminalProtocolEvent[] | null { + if (content === '7' || content.startsWith('7;')) return parseOsc7(content); if (content === '9' || content.startsWith('9;')) return this.parseOsc9(content); + if (content === '133' || content.startsWith('133;')) return parseOsc133(content); + if (content === '633' || content.startsWith('633;')) return parseOsc633(content); + if (content === '1337' || content.startsWith('1337;')) return parseOsc1337(content); + if (content === '0' || content.startsWith('0;')) return parseOscTitle(content, 'osc0'); + if (content === '2' || content.startsWith('2;')) return parseOscTitle(content, 'osc2'); if (content === '99' || content.startsWith('99;')) return this.parseOsc99(content); if (content === '777' || content.startsWith('777;')) return this.parseOsc777(content); return null; @@ -97,6 +113,11 @@ export class TerminalProtocolParser { private parseOsc9(content: string): TerminalProtocolEvent[] { if (!content.startsWith('9;')) return []; + if (content.startsWith('9;9;')) { + const cwd = cwdFromOsc9_9(content.slice('9;9;'.length)); + return cwd ? [{ kind: 'semantic', event: { type: 'cwd', cwd } }] : []; + } + if (content === '9;4' || content.startsWith('9;4;')) { const progress = parseOsc94(content); return progress ? [{ kind: 'progress', progress }] : []; @@ -208,6 +229,10 @@ export function collectTerminalProtocolResponses(events: TerminalProtocolEvent[] return events.flatMap((event) => (event.kind === 'response' ? [event.data] : [])); } +export function collectTerminalSemanticEvents(events: TerminalProtocolEvent[]): TerminalSemanticEvent[] { + return events.flatMap((event) => (event.kind === 'semantic' ? [event.event] : [])); +} + function stripStandaloneBells(segment: string, events: TerminalProtocolEvent[]): string { const bellIndex = segment.indexOf('\x07'); if (bellIndex === -1) return segment; @@ -260,6 +285,93 @@ function findOscTerminator(text: string, from: number): { index: number; end: nu return { index: bestIndex, end: bestIndex + bestEndOffset }; } +function parseOsc7(content: string): TerminalProtocolEvent[] { + if (!content.startsWith('7;')) return []; + const cwd = cwdFromOsc7(content.slice(2)); + return cwd ? [{ kind: 'semantic', event: { type: 'cwd', cwd } }] : []; +} + +function parseOsc133(content: string): TerminalProtocolEvent[] { + const fields = content.split(';'); + if (fields[0] !== '133') return []; + switch (fields[1]) { + case 'A': + return [{ kind: 'semantic', event: { type: 'promptStart' } }]; + case 'B': + return [{ kind: 'semantic', event: { type: 'promptEnd' } }]; + case 'C': + return [commandStartEvent('osc133_boundaries')]; + case 'D': + return [{ kind: 'semantic', event: { type: 'commandFinish', exitCode: parseExitCode(fields[2]) } }]; + default: + return []; + } +} + +function parseOsc633(content: string): TerminalProtocolEvent[] { + const fields = content.split(';'); + if (fields[0] !== '633') return []; + switch (fields[1]) { + case 'A': + return [{ kind: 'semantic', event: { type: 'promptStart' } }]; + case 'B': + return [{ kind: 'semantic', event: { type: 'promptEnd' } }]; + case 'C': + return [commandStartEvent('osc633_boundaries')]; + case 'D': + return [{ kind: 'semantic', event: { type: 'commandFinish', exitCode: parseExitCode(fields[2]) } }]; + case 'E': { + const prefix = '633;E;'; + if (!content.startsWith(prefix)) return []; + return [{ kind: 'semantic', event: { type: 'commandLine', commandLine: content.slice(prefix.length) } }]; + } + case 'P': + return parseOsc633Property(content.slice('633;P;'.length)); + default: + return []; + } +} + +function parseOsc633Property(rawProperties: string): TerminalProtocolEvent[] { + for (const property of rawProperties.split(';')) { + if (!property.startsWith('Cwd=')) continue; + const cwd = cwdFromOsc633(property.slice('Cwd='.length)); + return cwd ? [{ kind: 'semantic', event: { type: 'cwd', cwd } }] : []; + } + return []; +} + +function parseOsc1337(content: string): TerminalProtocolEvent[] { + const prefix = '1337;CurrentDir='; + if (!content.startsWith(prefix)) return []; + const cwd = cwdFromOsc1337(content.slice(prefix.length)); + return cwd ? [{ kind: 'semantic', event: { type: 'cwd', cwd } }] : []; +} + +function parseOscTitle(content: string, source: TerminalTitle['source']): TerminalProtocolEvent[] { + const prefix = source === 'osc0' ? '0;' : '2;'; + if (!content.startsWith(prefix)) return []; + const titleText = sanitizeText(content.slice(prefix.length), TITLE_LIMIT); + if (!titleText) return []; + return [{ + kind: 'semantic', + event: { + type: 'title', + title: { title: titleText, source, updatedAt: Date.now() }, + }, + }]; +} + +function commandStartEvent(source: CommandRunSource): TerminalProtocolEvent { + return { kind: 'semantic', event: { type: 'commandStart', source } }; +} + +function parseExitCode(raw: string | undefined): number | undefined { + if (raw === undefined || raw === '') return undefined; + const value = Number(raw); + return Number.isInteger(value) ? value : undefined; +} + // OSC 9;4 state code → progress shape. Codes 1 and 4 require a percent // (drop the update if missing); 2 accepts a missing/invalid percent as null. const OSC94_STATE_TABLE: Record<string, (raw: string | null) => ProtocolProgressUpdate | null> = { diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index b86b7ba..62269fd 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -2,6 +2,15 @@ export type { SessionStatus } from './activity-monitor'; export type { TodoState, AlertButtonActionResult } from './alert-manager'; export type { ActivityState } from './session-activity-store'; export type { TerminalEntry, TerminalOverlayDims } from './terminal-store'; +export type { + CommandRun, + CwdState, + DerivedHeader, + ShellActivity, + TerminalPaneState, + TerminalSemanticEvent, + TerminalTitle, +} from './terminal-state'; export { clearPrimedActivity, @@ -42,3 +51,34 @@ export { } from './terminal-lifecycle'; export { setDefaultShellOpts, getDefaultShellOpts } from './shell-defaults'; + +export { + applyTerminalSemanticEvents, + applyTerminalSemanticEventsByPtyId, + ensureTerminalPaneState, + fillTerminalProcessCwd, + getTerminalPaneState, + getTerminalPaneStateSnapshot, + removeTerminalPaneState, + resetTerminalPaneState, + seedTerminalManualCwd, + setTerminalUserTitle, + subscribeToTerminalPaneState, +} from './terminal-state-store'; + +export { + cwdDisplay, + cwdFromManualPath, + cwdFromOsc1337, + cwdFromOsc633, + cwdFromOsc7, + cwdFromOsc9_9, + cwdFromProcessPath, + cwdIdentity, + deriveFallbackCommandTitle, + deriveHeader, + groupTerminalPanes, + reduceTerminalState, + shortestUniqueCwdLabels, + summarizeCommandLine, +} from './terminal-state'; diff --git a/lib/src/lib/terminal-state-store.ts b/lib/src/lib/terminal-state-store.ts new file mode 100644 index 0000000..f58f99a --- /dev/null +++ b/lib/src/lib/terminal-state-store.ts @@ -0,0 +1,128 @@ +import { + createTerminalPaneState, + cwdFromManualPath, + cwdFromProcessPath, + reduceTerminalState, + type CwdState, + type TerminalPaneState, + type TerminalSemanticEvent, + type TerminalTitle, +} from './terminal-state'; +import { registry } from './terminal-store'; + +const paneStates = new Map<string, TerminalPaneState>(); +const listeners = new Set<() => void>(); +let cachedSnapshot: Map<string, TerminalPaneState> | null = null; + +export function subscribeToTerminalPaneState(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function getTerminalPaneStateSnapshot(): Map<string, TerminalPaneState> { + if (cachedSnapshot) return cachedSnapshot; + cachedSnapshot = new Map(paneStates); + return cachedSnapshot; +} + +export function getTerminalPaneState(id: string): TerminalPaneState { + return paneStates.get(id) ?? createTerminalPaneState(); +} + +export function ensureTerminalPaneState(id: string, initial?: Partial<TerminalPaneState>): TerminalPaneState { + const existing = paneStates.get(id); + if (existing) return existing; + const next = createTerminalPaneState(initial); + paneStates.set(id, next); + notifyTerminalPaneStateListeners(); + return next; +} + +export function resetTerminalPaneState(id: string, initial?: Partial<TerminalPaneState>): void { + paneStates.set(id, createTerminalPaneState(initial)); + notifyTerminalPaneStateListeners(); +} + +export function removeTerminalPaneState(id: string): void { + if (!paneStates.delete(id)) return; + notifyTerminalPaneStateListeners(); +} + +export function applyTerminalSemanticEventsByPtyId(ptyId: string, events: TerminalSemanticEvent[]): void { + const id = resolvePaneStateIdByPtyId(ptyId); + applyTerminalSemanticEvents(id, events); +} + +export function applyTerminalSemanticEvents(id: string, events: TerminalSemanticEvent[]): void { + if (events.length === 0) return; + let next = paneStates.get(id) ?? createTerminalPaneState(); + for (const event of events) { + next = reduceTerminalState(next, event); + } + paneStates.set(id, next); + notifyTerminalPaneStateListeners(); +} + +export function setTerminalUserTitle(id: string, title: string): void { + const trimmed = title.trim(); + if (!trimmed) return; + const terminalTitle: TerminalTitle = { + title: trimmed, + source: 'user', + updatedAt: Date.now(), + }; + applyTerminalSemanticEvents(id, [{ type: 'title', title: terminalTitle }]); +} + +export function seedTerminalManualCwd(id: string, path: string | null | undefined): void { + if (!path) { + ensureTerminalPaneState(id); + return; + } + const cwd = cwdFromManualPath(path); + if (!cwd) { + ensureTerminalPaneState(id); + return; + } + const existing = paneStates.get(id); + if (existing?.cwd) return; + ensureTerminalPaneState(id, { cwd }); +} + +export function fillTerminalProcessCwd(id: string, path: string | null | undefined): void { + if (!path) return; + const cwd = cwdFromProcessPath(path); + if (!cwd) return; + updateCwdIfAllowed(id, cwd); +} + +export function swapTerminalPaneStates(idA: string, idB: string): void { + const stateA = paneStates.get(idA); + const stateB = paneStates.get(idB); + if (!stateA && !stateB) return; + if (stateB) paneStates.set(idA, stateB); + else paneStates.delete(idA); + if (stateA) paneStates.set(idB, stateA); + else paneStates.delete(idB); + notifyTerminalPaneStateListeners(); +} + +function updateCwdIfAllowed(id: string, cwd: CwdState): void { + const current = paneStates.get(id) ?? createTerminalPaneState(); + const currentSource = current.cwd?.source; + if (currentSource && currentSource !== 'manual' && currentSource !== 'process') return; + paneStates.set(id, { ...current, cwd }); + notifyTerminalPaneStateListeners(); +} + +function resolvePaneStateIdByPtyId(ptyId: string): string { + for (const [id, entry] of registry) { + if (entry.ptyId === ptyId) return id; + } + return ptyId; +} + +function notifyTerminalPaneStateListeners(): void { + cachedSnapshot = null; + listeners.forEach((listener) => listener()); +} diff --git a/lib/src/lib/terminal-state.test.ts b/lib/src/lib/terminal-state.test.ts new file mode 100644 index 0000000..c49c758 --- /dev/null +++ b/lib/src/lib/terminal-state.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from 'vitest'; +import { + createTerminalPaneState, + cwdDisplay, + cwdFromManualPath, + cwdFromOsc7, + cwdFromOsc9_9, + cwdIdentity, + deriveHeader, + groupTerminalPanes, + reduceTerminalState, + shortestUniqueCwdLabels, + summarizeCommandLine, + type CwdState, +} from './terminal-state'; + +describe('terminal CWD normalization', () => { + it('parses OSC 7 file URIs with host identity and decoded paths', () => { + expect(cwdFromOsc7('file://prod-box/home/me/with%20space', 100)).toEqual({ + uri: 'file://prod-box/home/me/with%20space', + path: '/home/me/with space', + host: 'prod-box', + scheme: 'file', + pathKind: 'posix', + isRemote: true, + source: 'osc7', + updatedAt: 100, + }); + + expect(cwdFromOsc7('file://localhost/C:/Users/me/project', 100)).toEqual({ + uri: 'file://localhost/C:/Users/me/project', + path: 'C:/Users/me/project', + host: 'localhost', + scheme: 'file', + pathKind: 'windows', + isRemote: false, + source: 'osc7', + updatedAt: 100, + }); + }); + + it('marks OSC 9;9 Windows paths and leaves other paths unknown', () => { + expect(cwdFromOsc9_9('C:\\repo', 100)?.pathKind).toBe('windows'); + expect(cwdFromOsc9_9('\\\\server\\share\\repo', 100)).toMatchObject({ + pathKind: 'windows', + isRemote: true, + }); + expect(cwdFromOsc9_9('/mnt/c/repo', 100)).toMatchObject({ + pathKind: 'unknown', + isRemote: false, + }); + }); + + it('builds shortest unique labels without losing remote hosts', () => { + const local = cwd('/Users/me/app', 'localhost'); + const remote = cwd('/Users/me/app', 'prod-box'); + const sibling = cwd('/Users/me/other/app', 'localhost'); + const labels = shortestUniqueCwdLabels([local, remote, sibling]); + + expect(labels.get(cwdIdentity(local))).toBe('localhost:me/app'); + expect(labels.get(cwdIdentity(remote))).toBe('prod-box:me/app'); + expect(labels.get(cwdIdentity(sibling))).toBe('other/app'); + expect(cwdDisplay(remote)).toBe('prod-box:me/app'); + }); +}); + +describe('terminal command state reducer', () => { + it('tracks full OSC 633 lifecycle with command CWD snapshot', () => { + const startCwd = cwdFromManualPath('/repo/app', 10)!; + let state = createTerminalPaneState({ cwd: startCwd }); + + state = reduceTerminalState(state, { type: 'promptStart' }); + state = reduceTerminalState(state, { type: 'promptEnd' }); + state = reduceTerminalState(state, { type: 'commandLine', commandLine: 'pnpm test --watch' }); + state = reduceTerminalState(state, { type: 'commandStart', source: 'osc633_boundaries' }, { + now: () => 20, + createId: () => 'cmd-1', + }); + + expect(state.activity).toEqual({ kind: 'running' }); + expect(state.pendingCommandLine).toBeNull(); + expect(state.currentCommand).toMatchObject({ + id: 'cmd-1', + rawCommandLine: 'pnpm test --watch', + displayCommand: 'pnpm test --watch', + cwdAtStart: startCwd, + startedAt: 20, + source: 'osc633_E', + }); + + state = reduceTerminalState(state, { type: 'cwd', cwd: cwdFromManualPath('/repo/other', 30)! }); + state = reduceTerminalState(state, { type: 'commandFinish', exitCode: 1 }, { now: () => 40 }); + + expect(state.activity).toEqual({ kind: 'finished', exitCode: 1 }); + expect(state.currentCommand).toBeNull(); + expect(state.lastCommand).toMatchObject({ + displayCommand: 'pnpm test --watch', + cwdAtStart: startCwd, + finishedAt: 40, + exitCode: 1, + }); + }); + + it('handles OSC 133 lifecycle without command line and finish without current command', () => { + let state = createTerminalPaneState({ title: { title: 'zsh', source: 'osc0', updatedAt: 1 } }); + state = reduceTerminalState(state, { type: 'commandStart', source: 'osc133_boundaries' }, { + now: () => 2, + createId: () => 'cmd-2', + }); + + expect(state.currentCommand).toMatchObject({ + displayCommand: 'zsh', + source: 'osc133_boundaries', + }); + + state = reduceTerminalState(state, { type: 'commandFinish' }); + expect(state.activity).toEqual({ kind: 'finished' }); + + state = reduceTerminalState(state, { type: 'commandFinish', exitCode: 0 }); + expect(state.activity).toEqual({ kind: 'finished', exitCode: 0 }); + + state = reduceTerminalState(state, { type: 'promptStart' }); + expect(state.activity).toEqual({ kind: 'prompt' }); + }); +}); + +describe('command title summarizer', () => { + it('summarizes common commands compactly', () => { + expect(summarizeCommandLine('npm run dev')).toBe('npm run dev'); + expect(summarizeCommandLine('FOO=1 pnpm test --watch --reporter verbose')).toBe('pnpm test --watch'); + expect(summarizeCommandLine('docker compose up --build')).toBe('docker compose up'); + expect(summarizeCommandLine('cargo watch -x test')).toBe('cargo watch -x test'); + expect(summarizeCommandLine('pytest tests/unit -q')).toBe('pytest'); + expect(summarizeCommandLine('ssh prod-box')).toBe('ssh prod-box'); + }); + + it('keeps pipelines and compound commands recognizable', () => { + expect(summarizeCommandLine('cat package.json | jq .name')).toBe('cat package.json | ...'); + expect(summarizeCommandLine('cd lib && pnpm test')).toBe('cd lib ...'); + expect(summarizeCommandLine('"my command" "quoted arg"')).toBe('my command quoted arg'); + }); +}); + +describe('header and grouping derivation', () => { + it('uses command start CWD for running headers and disambiguates duplicates', () => { + const app = runningPane('/repo/app', 'pnpm test --watch'); + const api = runningPane('/repo/api', 'pnpm test --watch'); + + expect(deriveHeader(app, [app, api])).toEqual({ + primary: 'pnpm test --watch', + secondary: 'app', + status: 'running', + }); + expect(deriveHeader(api, [app, api])).toEqual({ + primary: 'pnpm test --watch', + secondary: 'api', + status: 'running', + }); + }); + + it('preserves remote identity when two panes have the same path', () => { + const local = runningPane('/home/me/app', 'npm run dev', 'localhost'); + const remote = runningPane('/home/me/app', 'npm run dev', 'prod-box'); + + expect(deriveHeader(local, [local, remote]).secondary).toBe('localhost:app'); + expect(deriveHeader(remote, [local, remote]).secondary).toBe('prod-box:app'); + }); + + it('groups by directory, command, and status', () => { + const running = runningPane('/repo/app', 'npm run dev'); + const idle = createTerminalPaneState({ cwd: cwdFromManualPath('/repo/api', 1)! }); + const finished = reduceTerminalState(running, { type: 'commandFinish', exitCode: 0 }, { now: () => 2 }); + + expect(groupTerminalPanes([running, idle], 'directory').map((group) => group.label)).toEqual(['app', 'api']); + expect(groupTerminalPanes([running, idle], 'command').map((group) => group.label)).toEqual(['npm run dev', 'shell']); + expect(groupTerminalPanes([running, idle, finished], 'status').map((group) => group.key)).toEqual([ + 'running', + 'unknown', + 'finished', + ]); + }); +}); + +function cwd(path: string, host?: string): CwdState { + return { + path, + host, + scheme: 'file', + pathKind: path.includes(':') ? 'windows' : 'posix', + isRemote: !!host && host !== 'localhost', + source: 'manual', + updatedAt: 1, + }; +} + +function runningPane(path: string, command: string, host?: string) { + const paneCwd = cwd(path, host); + return createTerminalPaneState({ + cwd: paneCwd, + activity: { kind: 'running' }, + currentCommand: { + id: `${command}-${path}`, + rawCommandLine: command, + displayCommand: command, + cwdAtStart: paneCwd, + startedAt: 1, + source: 'osc633_E', + }, + }); +} diff --git a/lib/src/lib/terminal-state.ts b/lib/src/lib/terminal-state.ts new file mode 100644 index 0000000..9c2bbc0 --- /dev/null +++ b/lib/src/lib/terminal-state.ts @@ -0,0 +1,658 @@ +export type CwdSource = 'osc7' | 'osc9_9' | 'osc633' | 'osc1337' | 'process' | 'manual'; +export type PathKind = 'posix' | 'windows' | 'unknown'; + +export interface CwdState { + uri?: string; + path: string; + host?: string; + scheme?: 'file'; + pathKind: PathKind; + isRemote: boolean; + source: CwdSource; + updatedAt: number; +} + +export type ShellActivity = + | { kind: 'unknown' } + | { kind: 'prompt' } + | { kind: 'editing' } + | { kind: 'running' } + | { kind: 'finished'; exitCode?: number }; + +export type CommandRunSource = + | 'osc633_E' + | 'osc633_boundaries' + | 'osc133_boundaries' + | 'foreground_process' + | 'title'; + +export interface CommandRun { + id: string; + rawCommandLine: string | null; + displayCommand: string; + cwdAtStart: CwdState | null; + startedAt: number; + finishedAt?: number; + exitCode?: number; + source: CommandRunSource; + outputRange?: { + startMarkId?: string; + endMarkId?: string; + }; +} + +export type TerminalTitleSource = 'osc0' | 'osc2' | 'user' | 'profile' | 'derived'; + +export interface TerminalTitle { + title: string; + source: TerminalTitleSource; + updatedAt: number; +} + +export interface TerminalPaneState { + cwd: CwdState | null; + activity: ShellActivity; + pendingCommandLine: string | null; + currentCommand: CommandRun | null; + lastCommand: CommandRun | null; + title: TerminalTitle | null; +} + +export type TerminalSemanticEvent = + | { type: 'cwd'; cwd: CwdState } + | { type: 'promptStart' } + | { type: 'promptEnd' } + | { type: 'commandLine'; commandLine: string } + | { type: 'commandStart'; source?: CommandRunSource } + | { type: 'commandFinish'; exitCode?: number } + | { type: 'title'; title: TerminalTitle }; + +export interface DirectoryDisplayOptions { + includeHost?: 'auto' | 'always' | 'never'; + style?: 'basename' | 'short' | 'full'; + maxSegments?: number; + homePath?: string; +} + +export interface HeaderOptions extends DirectoryDisplayOptions { + shellName?: string; +} + +export interface DerivedHeader { + primary: string; + secondary?: string; + status: 'unknown' | 'idle' | 'running' | 'finished'; + exitCode?: number; +} + +export type TerminalGroupingMode = 'none' | 'directory' | 'command' | 'status'; + +export interface TerminalGroup { + key: string; + label: string; + panes: TerminalPaneState[]; +} + +export const DEFAULT_TERMINAL_PANE_STATE: TerminalPaneState = Object.freeze({ + cwd: null, + activity: Object.freeze({ kind: 'unknown' } as ShellActivity), + pendingCommandLine: null, + currentCommand: null, + lastCommand: null, + title: null, +}); + +const DEFAULT_COMMAND_TITLE = 'shell'; +const DEFAULT_DIRECTORY_LABEL = 'Unknown directory'; +const COMMAND_TITLE_LIMIT = 48; +let nextCommandRunId = 0; + +export function createTerminalPaneState(initial?: Partial<TerminalPaneState>): TerminalPaneState { + return { + cwd: initial?.cwd ?? null, + activity: initial?.activity ?? { kind: 'unknown' }, + pendingCommandLine: initial?.pendingCommandLine ?? null, + currentCommand: initial?.currentCommand ?? null, + lastCommand: initial?.lastCommand ?? null, + title: initial?.title ?? null, + }; +} + +export function reduceTerminalState( + state: TerminalPaneState, + event: TerminalSemanticEvent, + options: { now?: () => number; createId?: () => string } = {}, +): TerminalPaneState { + const now = options.now ?? Date.now; + const createId = options.createId ?? createCommandRunId; + + switch (event.type) { + case 'cwd': + return { ...state, cwd: event.cwd }; + case 'promptStart': + return { ...state, activity: { kind: 'prompt' } }; + case 'promptEnd': + return { ...state, activity: { kind: 'editing' } }; + case 'commandLine': + return { ...state, pendingCommandLine: event.commandLine }; + case 'commandStart': { + const raw = state.pendingCommandLine; + const source = event.source === 'osc633_boundaries' && raw + ? 'osc633_E' + : event.source ?? (raw ? 'osc633_E' : 'osc133_boundaries'); + return { + ...state, + currentCommand: { + id: createId(), + rawCommandLine: raw, + displayCommand: raw ? summarizeCommandLine(raw) : deriveFallbackCommandTitle(state), + cwdAtStart: state.cwd, + startedAt: now(), + source, + }, + activity: { kind: 'running' }, + pendingCommandLine: null, + }; + } + case 'commandFinish': { + if (!state.currentCommand) { + return { ...state, activity: finishedActivity(event.exitCode) }; + } + const finishedCommand: CommandRun = { + ...state.currentCommand, + finishedAt: now(), + exitCode: event.exitCode, + }; + return { + ...state, + currentCommand: null, + lastCommand: finishedCommand, + activity: finishedActivity(event.exitCode), + }; + } + case 'title': + return { ...state, title: event.title }; + } +} + +export function cwdFromOsc7(rawUri: string, now = Date.now()): CwdState | null { + let parsed: URL; + try { + parsed = new URL(rawUri); + } catch { + return null; + } + if (parsed.protocol !== 'file:') return null; + + const decodedPath = normalizeFileUriPath(safeDecodeURIComponent(parsed.pathname)); + const host = extractFileUriHost(rawUri) || parsed.hostname || undefined; + return { + uri: rawUri, + path: decodedPath, + host, + scheme: 'file', + pathKind: inferPathKind(decodedPath), + isRemote: isRemoteFileHost(host), + source: 'osc7', + updatedAt: now, + }; +} + +export function cwdFromOsc9_9(rawPath: string, now = Date.now()): CwdState | null { + const path = safeDecodeURIComponent(rawPath.trim()); + if (!path) return null; + return { + path, + pathKind: isWindowsPath(path) ? 'windows' : 'unknown', + isRemote: isUncPath(path), + source: 'osc9_9', + updatedAt: now, + }; +} + +export function cwdFromOsc633(rawPath: string, now = Date.now()): CwdState | null { + return cwdFromDecodedPath(rawPath, 'osc633', now); +} + +export function cwdFromOsc1337(rawPath: string, now = Date.now()): CwdState | null { + return cwdFromDecodedPath(rawPath, 'osc1337', now); +} + +export function cwdFromProcessPath(rawPath: string, now = Date.now()): CwdState | null { + return cwdFromDecodedPath(rawPath, 'process', now); +} + +export function cwdFromManualPath(rawPath: string, now = Date.now()): CwdState | null { + return cwdFromDecodedPath(rawPath, 'manual', now); +} + +export function cwdIdentity(cwd: CwdState): string { + const scheme = cwd.scheme ?? 'path'; + const host = cwd.host ?? ''; + return `${scheme}|${host}|${cwd.pathKind}|${cwd.path}`; +} + +export function cwdDisplay(cwd: CwdState, options: DirectoryDisplayOptions = {}): string { + const style = options.style ?? 'short'; + const hostMode = options.includeHost ?? 'auto'; + const pathLabel = style === 'full' + ? formatFullPath(cwd.path, options.homePath) + : formatTrailingPath(cwd.path, cwd.pathKind, style === 'basename' ? 1 : options.maxSegments ?? 2); + const shouldIncludeHost = + hostMode === 'always' || + (hostMode === 'auto' && cwd.isRemote && !!cwd.host); + return shouldIncludeHost && cwd.host ? `${cwd.host}:${pathLabel}` : pathLabel; +} + +export function shortestUniqueCwdLabels( + cwds: CwdState[], + options: DirectoryDisplayOptions = {}, +): Map<string, string> { + const uniqueCwds = uniqueByIdentity(cwds); + let labels = new Map<string, string>(); + if (uniqueCwds.length === 0) return labels; + + const maxDepth = Math.max(...uniqueCwds.map((cwd) => pathParts(cwd.path, cwd.pathKind).segments.length), 1); + for (let depth = 1; depth <= maxDepth; depth += 1) { + const baseLabels = new Map<string, string>(); + for (const cwd of uniqueCwds) { + baseLabels.set(cwdIdentity(cwd), formatTrailingPath(cwd.path, cwd.pathKind, depth)); + } + labels = withRequiredHostPrefixes(uniqueCwds, baseLabels, options); + if (findLabelCollisions(uniqueCwds, labels).size === 0) return labels; + } + + const remainingCollisions = findLabelCollisions(uniqueCwds, labels); + const includeHost = options.includeHost ?? 'auto'; + for (const cwd of uniqueCwds) { + const id = cwdIdentity(cwd); + const label = labels.get(id) ?? cwdDisplay(cwd, options); + const needsHost = + includeHost === 'always' || + (includeHost === 'auto' && (cwd.isRemote || remainingCollisions.has(label))); + labels.set(id, needsHost && cwd.host ? `${cwd.host}:${label}` : label); + } + + return labels; +} + +export function summarizeCommandLine(raw: string): string { + const tokens = tokenizeCommand(raw.trim()); + if (tokens.length === 0) return DEFAULT_COMMAND_TITLE; + + const commandTokens = takePrimaryCommandTokens(tokens); + if (commandTokens.length === 0) return DEFAULT_COMMAND_TITLE; + + const hasPipeline = tokens.includes('|'); + const hasCompound = tokens.some((token) => token === '&&' || token === '||' || token === ';'); + const visibleTokens = commandTitleTokens(commandTokens); + const suffix = hasPipeline ? ' | ...' : hasCompound ? ' ...' : ''; + return truncateCommandTitle(`${visibleTokens.join(' ')}${suffix}`); +} + +export function deriveFallbackCommandTitle( + state?: TerminalPaneState | null, + options: { shellName?: string } = {}, +): string { + const title = state?.title?.title?.trim(); + if (title) return title; + return options.shellName?.trim() || DEFAULT_COMMAND_TITLE; +} + +export function deriveHeader( + pane: TerminalPaneState, + visiblePanes: TerminalPaneState[], + options: HeaderOptions = {}, +): DerivedHeader { + const primary = headerPrimary(pane, options); + const status = headerStatus(pane); + const samePrimary = visiblePanes.filter((candidate) => headerPrimary(candidate, options) === primary); + const cwd = cwdForHeader(pane); + let secondary: string | undefined; + + if (samePrimary.length > 1) { + const candidateCwds = samePrimary.map(cwdForHeader).filter((value): value is CwdState => !!value); + if (cwd) { + secondary = shortestUniqueCwdLabels(candidateCwds, options).get(cwdIdentity(cwd)) ?? cwdDisplay(cwd, options); + } else { + secondary = DEFAULT_DIRECTORY_LABEL; + } + } + + const exitCode = pane.activity.kind === 'finished' ? pane.activity.exitCode : undefined; + return exitCode === undefined + ? { primary, secondary, status } + : { primary, secondary, status, exitCode }; +} + +export function groupTerminalPanes( + panes: TerminalPaneState[], + mode: TerminalGroupingMode, + options: DirectoryDisplayOptions = {}, +): TerminalGroup[] { + if (mode === 'none') { + return [{ key: 'all', label: 'All', panes }]; + } + + if (mode === 'directory') { + const cwds = panes.map(directoryGroupCwd).filter((cwd): cwd is CwdState => !!cwd); + const labels = shortestUniqueCwdLabels(cwds, options); + return groupBy(panes, (pane) => { + const cwd = directoryGroupCwd(pane); + if (!cwd) return { key: 'unknown', label: DEFAULT_DIRECTORY_LABEL }; + const key = cwdIdentity(cwd); + return { key, label: labels.get(key) ?? cwdDisplay(cwd, options) }; + }); + } + + if (mode === 'command') { + return groupBy(panes, (pane) => { + const label = pane.currentCommand?.displayCommand ?? idleLabel(pane); + return { key: label, label }; + }); + } + + return groupBy(panes, (pane) => { + const status = headerStatus(pane); + return { key: status, label: status }; + }); +} + +function cwdFromDecodedPath(rawPath: string, source: CwdSource, now: number): CwdState | null { + const path = safeDecodeURIComponent(rawPath.trim()); + if (!path) return null; + return { + path, + pathKind: inferPathKind(path), + isRemote: isUncPath(path), + source, + updatedAt: now, + }; +} + +function createCommandRunId(): string { + nextCommandRunId += 1; + return `cmd-${Date.now().toString(36)}-${nextCommandRunId.toString(36)}`; +} + +function finishedActivity(exitCode: number | undefined): ShellActivity { + return exitCode === undefined ? { kind: 'finished' } : { kind: 'finished', exitCode }; +} + +function normalizeFileUriPath(pathname: string): string { + if (/^\/[A-Za-z]:\//.test(pathname)) return pathname.slice(1); + return pathname; +} + +function extractFileUriHost(uri: string): string | undefined { + const match = uri.match(/^file:\/\/([^/]*)(?:\/|$)/i); + if (!match || !match[1]) return undefined; + return safeDecodeURIComponent(match[1]); +} + +function safeDecodeURIComponent(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function inferPathKind(path: string): PathKind { + if (isWindowsPath(path)) return 'windows'; + if (path.startsWith('/') || path.startsWith('~/')) return 'posix'; + return 'unknown'; +} + +function isWindowsPath(path: string): boolean { + return /^[A-Za-z]:(?:[\\/]|$)/.test(path) || isUncPath(path); +} + +function isUncPath(path: string): boolean { + return path.startsWith('\\\\') || path.startsWith('//'); +} + +function isRemoteFileHost(host: string | undefined): boolean { + return !!host && host.toLowerCase() !== 'localhost'; +} + +function formatFullPath(path: string, homePath?: string): string { + if (homePath && (path === homePath || path.startsWith(`${homePath}/`))) { + return `~${path.slice(homePath.length)}`; + } + return path; +} + +function formatTrailingPath(path: string, kind: PathKind, depth: number): string { + const parts = pathParts(path, kind); + if (parts.segments.length === 0) return parts.root || path || DEFAULT_DIRECTORY_LABEL; + const tail = parts.segments.slice(-Math.max(1, depth)).join(parts.separator); + if (kind === 'windows' && parts.root && depth >= parts.segments.length) { + return `${parts.root}${tail}`; + } + return tail; +} + +function pathParts(path: string, kind: PathKind): { root: string; segments: string[]; separator: string } { + if (kind === 'windows') { + const normalized = path.replace(/\//g, '\\'); + const unc = normalized.match(/^\\\\([^\\]+)\\([^\\]+)\\?(.*)$/); + if (unc) { + const rest = unc[3] ? unc[3].split('\\').filter(Boolean) : []; + return { root: `\\\\${unc[1]}\\${unc[2]}\\`, segments: [unc[1], unc[2], ...rest], separator: '\\' }; + } + const drive = normalized.match(/^([A-Za-z]:)\\?(.*)$/); + if (drive) { + return { root: `${drive[1]}\\`, segments: drive[2].split('\\').filter(Boolean), separator: '\\' }; + } + return { root: '', segments: normalized.split('\\').filter(Boolean), separator: '\\' }; + } + + return { + root: path.startsWith('/') ? '/' : '', + segments: path.split('/').filter(Boolean), + separator: '/', + }; +} + +function uniqueByIdentity(cwds: CwdState[]): CwdState[] { + const result = new Map<string, CwdState>(); + for (const cwd of cwds) { + const id = cwdIdentity(cwd); + if (!result.has(id)) result.set(id, cwd); + } + return [...result.values()]; +} + +function findLabelCollisions(cwds: CwdState[], labels: Map<string, string>): Set<string> { + const counts = new Map<string, number>(); + for (const cwd of cwds) { + const label = labels.get(cwdIdentity(cwd)); + if (!label) continue; + counts.set(label, (counts.get(label) ?? 0) + 1); + } + return new Set([...counts].filter(([, count]) => count > 1).map(([label]) => label)); +} + +function withRequiredHostPrefixes( + cwds: CwdState[], + baseLabels: Map<string, string>, + options: DirectoryDisplayOptions, +): Map<string, string> { + const result = new Map(baseLabels); + const hostMode = options.includeHost ?? 'auto'; + const groups = new Map<string, CwdState[]>(); + for (const cwd of cwds) { + const label = baseLabels.get(cwdIdentity(cwd)); + if (!label) continue; + const group = groups.get(label) ?? []; + group.push(cwd); + groups.set(label, group); + } + + for (const [label, group] of groups) { + const hasCollision = group.length > 1; + const samePathDifferentHosts = new Set(group.map((cwd) => cwd.path)).size < group.length && + new Set(group.map((cwd) => cwd.host ?? '')).size > 1; + for (const cwd of group) { + const shouldIncludeHost = + hostMode === 'always' || + (hostMode === 'auto' && !!cwd.host && (cwd.isRemote || (hasCollision && samePathDifferentHosts))); + if (shouldIncludeHost && cwd.host) { + result.set(cwdIdentity(cwd), `${cwd.host}:${label}`); + } + } + } + + return result; +} + +function tokenizeCommand(input: string): string[] { + const tokens: string[] = []; + let current = ''; + let quote: '"' | "'" | null = null; + let escaping = false; + + const push = () => { + if (!current) return; + tokens.push(current); + current = ''; + }; + + for (let i = 0; i < input.length; i += 1) { + const char = input[i]; + + if (escaping) { + current += char; + escaping = false; + continue; + } + if (char === '\\' && quote !== "'") { + escaping = true; + continue; + } + if (quote) { + if (char === quote) quote = null; + else current += char; + continue; + } + if (char === '"' || char === "'") { + quote = char; + continue; + } + if (/\s/.test(char)) { + push(); + continue; + } + if (char === '&' && input[i + 1] === '&') { + push(); + tokens.push('&&'); + i += 1; + continue; + } + if (char === '|' && input[i + 1] === '|') { + push(); + tokens.push('||'); + i += 1; + continue; + } + if (char === '|' || char === ';' || char === '&') { + push(); + tokens.push(char); + continue; + } + current += char; + } + + push(); + return tokens; +} + +function takePrimaryCommandTokens(tokens: string[]): string[] { + const firstBoundary = tokens.findIndex((token) => token === '|' || token === '&&' || token === '||' || token === ';' || token === '&'); + const command = (firstBoundary === -1 ? tokens : tokens.slice(0, firstBoundary)).filter(Boolean); + let index = 0; + while (isEnvAssignment(command[index])) index += 1; + if (command[index] === 'env') { + index += 1; + while (isEnvAssignment(command[index])) index += 1; + } + return command.slice(index); +} + +function isEnvAssignment(token: string | undefined): boolean { + return !!token && /^[A-Za-z_][A-Za-z0-9_]*=/.test(token); +} + +function commandTitleTokens(tokens: string[]): string[] { + const command = tokens[0]; + if (!command) return []; + const basename = command.split(/[\\/]/).pop() ?? command; + const rest = tokens.slice(1); + + if (basename === 'npm' && rest[0] === 'run') return [basename, ...rest.slice(0, 2)]; + if (basename === 'pnpm' || basename === 'yarn' || basename === 'bun') return [basename, ...rest.slice(0, 2)]; + if (basename === 'docker' && rest[0] === 'compose') return [basename, ...rest.slice(0, 2)]; + if (basename === 'cargo' && rest[0] === 'watch') return [basename, ...rest.slice(0, 3)]; + if (basename === 'ssh') return [basename, ...rest.slice(0, 1)]; + if (basename === 'vim' || basename === 'nvim' || basename === 'vi' || basename === 'pytest') return [basename]; + return [basename, ...rest.slice(0, 2)]; +} + +function truncateCommandTitle(title: string): string { + if (title.length <= COMMAND_TITLE_LIMIT) return title; + return `${Array.from(title).slice(0, COMMAND_TITLE_LIMIT - 3).join('').trimEnd()}...`; +} + +function headerPrimary(pane: TerminalPaneState, options: HeaderOptions): string { + if (pane.title?.source === 'user') return pane.title.title; + if (pane.currentCommand) return pane.currentCommand.displayCommand; + if (pane.activity.kind === 'finished' && pane.lastCommand) return pane.lastCommand.displayCommand; + return idleLabel(pane, options); +} + +function idleLabel(pane: TerminalPaneState, options: { shellName?: string } = {}): string { + const title = pane.title?.title?.trim(); + if (title) return title; + return options.shellName?.trim() || DEFAULT_COMMAND_TITLE; +} + +function headerStatus(pane: TerminalPaneState): DerivedHeader['status'] { + switch (pane.activity.kind) { + case 'running': + return 'running'; + case 'finished': + return 'finished'; + case 'unknown': + return 'unknown'; + default: + return 'idle'; + } +} + +function cwdForHeader(pane: TerminalPaneState): CwdState | null { + if (pane.currentCommand?.cwdAtStart) return pane.currentCommand.cwdAtStart; + if (pane.activity.kind === 'finished' && pane.lastCommand?.cwdAtStart) return pane.lastCommand.cwdAtStart; + return pane.cwd; +} + +function directoryGroupCwd(pane: TerminalPaneState): CwdState | null { + return pane.currentCommand?.cwdAtStart ?? pane.cwd; +} + +function groupBy( + panes: TerminalPaneState[], + keyForPane: (pane: TerminalPaneState) => { key: string; label: string }, +): TerminalGroup[] { + const groups = new Map<string, TerminalGroup>(); + for (const pane of panes) { + const { key, label } = keyForPane(pane); + const existing = groups.get(key); + if (existing) { + existing.panes.push(pane); + } else { + groups.set(key, { key, label, panes: [pane] }); + } + } + return [...groups.values()]; +} diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index d11e72c..40297e9 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -4,9 +4,14 @@ import type { AlertStateDetail, PlatformAdapter, PtyInfo } from "mouseterm-lib/l import { AlertManager, type SessionStatus } from "mouseterm-lib/lib/alert-manager"; import { applyTerminalProtocolEvents, + collectTerminalSemanticEvents, collectTerminalProtocolResponses, TerminalProtocolParser, } from "mouseterm-lib/lib/terminal-protocol"; +import { + applyTerminalSemanticEventsByPtyId, + removeTerminalPaneState, +} from "mouseterm-lib/lib/terminal-state-store"; function invoke(cmd: string, args?: Record<string, unknown>): void { rawInvoke(cmd, args).catch((err) => @@ -54,6 +59,7 @@ export class TauriAdapter implements PlatformAdapter { const { id, data } = event.payload; const parsed = this.getProtocolParser(id).process(data); applyTerminalProtocolEvents(this.alertManager, id, parsed.events); + applyTerminalSemanticEventsByPtyId(id, collectTerminalSemanticEvents(parsed.events)); for (const response of collectTerminalProtocolResponses(parsed.events)) { invoke("pty_write", { id, data: response }); } @@ -85,8 +91,11 @@ export class TauriAdapter implements PlatformAdapter { this.unlistenFns.push( await listen<{ id: string; data: string }>("pty:replay", (event) => { + const { id, data } = event.payload; + const parsed = this.getProtocolParser(id).process(data); + applyTerminalSemanticEventsByPtyId(id, collectTerminalSemanticEvents(parsed.events)); for (const handler of this.replayHandlers) { - handler(event.payload); + handler({ id, data: parsed.visibleData }); } }), ); @@ -132,6 +141,7 @@ export class TauriAdapter implements PlatformAdapter { killPty(id: string): void { this.protocolParsers.delete(id); + removeTerminalPaneState(id); invoke("pty_kill", { id }); } diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index 38c3a46..a8386d8 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -3,9 +3,11 @@ import * as ptyManager from './pty-manager'; import { AlertManager, type SessionStatus } from '../../lib/src/lib/alert-manager'; import { applyTerminalProtocolEvents, + collectTerminalSemanticEvents, collectTerminalProtocolResponses, TerminalProtocolParser, } from '../../lib/src/lib/terminal-protocol'; +import type { TerminalSemanticEvent } from '../../lib/src/lib/terminal-state'; import type { PersistedSession } from '../../lib/src/lib/session-types'; import type { WebviewMessage, ExtensionMessage } from './message-types'; import { log } from './log'; @@ -31,12 +33,19 @@ const alertProtocolParsers = new Map<string, TerminalProtocolParser>(); // the protocol parser once per chunk regardless of webview count. type ProcessedDataListener = (id: string, visibleData: string) => void; const processedDataListeners = new Set<ProcessedDataListener>(); +type SemanticEventsListener = (id: string, events: TerminalSemanticEvent[]) => void; +const semanticEventsListeners = new Set<SemanticEventsListener>(); function onProcessedPtyData(listener: ProcessedDataListener): () => void { processedDataListeners.add(listener); return () => { processedDataListeners.delete(listener); }; } +function onTerminalSemanticEvents(listener: SemanticEventsListener): () => void { + semanticEventsListeners.add(listener); + return () => { semanticEventsListeners.delete(listener); }; +} + // Log all alert state transitions (including timer-driven ones) alertManager.onStateChange((id, state) => { log.info(`[alert] ${id}: → ${state.status} (todo=${state.todo})`); @@ -49,6 +58,10 @@ ptyManager.addCallbacks({ const before = alertManager.getState(id).status; const parsed = getAlertProtocolParser(id).process(data); applyTerminalProtocolEvents(alertManager, id, parsed.events); + const semanticEvents = collectTerminalSemanticEvents(parsed.events); + if (semanticEvents.length > 0) { + for (const listener of semanticEventsListeners) listener(id, semanticEvents); + } for (const response of collectTerminalProtocolResponses(parsed.events)) { ptyManager.write(id, response); } @@ -160,6 +173,10 @@ export function attachRouter( if (!ownedPtyIds.has(id)) return; webview.postMessage({ type: 'pty:data', id, data: visibleData } satisfies ExtensionMessage); }); + const removeSemanticListener = onTerminalSemanticEvents((id, events) => { + if (!ownedPtyIds.has(id)) return; + webview.postMessage({ type: 'terminal:semanticEvents', id, events } satisfies ExtensionMessage); + }); const removePtyCallbacks = ptyManager.addCallbacks({ onData() {}, onExit(id: string, exitCode: number) { @@ -182,6 +199,7 @@ export function attachRouter( return () => { removeProcessedListener(); + removeSemanticListener(); removePtyCallbacks(); removeAlertListener(); }; diff --git a/vscode-ext/src/message-types.ts b/vscode-ext/src/message-types.ts index 3679783..1a66bd3 100644 --- a/vscode-ext/src/message-types.ts +++ b/vscode-ext/src/message-types.ts @@ -1,4 +1,5 @@ import type { ActivityNotification, SessionStatus, TodoState } from '../../lib/src/lib/alert-manager'; +import type { TerminalSemanticEvent } from '../../lib/src/lib/terminal-state'; // Messages from webview → extension host export type WebviewMessage = @@ -37,6 +38,7 @@ export interface PtyInfo { export type ExtensionMessage = | { type: 'pty:data'; id: string; data: string } | { type: 'pty:exit'; id: string; exitCode: number } + | { type: 'terminal:semanticEvents'; id: string; events: TerminalSemanticEvent[] } | { type: 'pty:list'; ptys: PtyInfo[] } | { type: 'pty:replay'; id: string; data: string } | { type: 'pty:cwd'; id: string; cwd: string | null; requestId?: string } From 4cbaad3e363c49057c2364607b65430e5f40eaa6 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Thu, 7 May 2026 18:20:20 -0700 Subject: [PATCH 02/50] Tighten terminal-state hot path and dedupe header derivation Short-circuit no-op semantic events in the reducer and skip listener notification when state is unchanged so prompt/CWD/title events on busy shells stop triggering Baseboard and TerminalPaneHeader re-render storms. Memoize the all-pane-states array in those components so the per-render allocation is amortized. Clean up replay parsers and pane state on natural pty:exit in the vscode and tauri adapters. Collapse the triplicated "derived === 'shell' && X !== '<unnamed>'" ternary into a shared resolveDisplayPrimary helper, export DEFAULT_COMMAND_TITLE and a new UNNAMED_PANEL_TITLE constant, and replace the magic strings across the components and lifecycle. Hoist the duplicated getCwd().then(fillTerminalProcessCwd) into a helper. Add getSessionIdByPtyId so terminal-state-store no longer iterates the registry directly. Unify parseOsc133 and parseOsc633 prompt boundary cases via a parsePromptBoundary helper. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- lib/src/components/Baseboard.tsx | 11 +++--- lib/src/components/Wall.tsx | 9 ++--- .../components/wall/TerminalPaneHeader.tsx | 14 +++----- lib/src/components/wall/use-dockview-ready.ts | 6 ++-- .../wall/use-session-persistence.ts | 3 +- lib/src/lib/platform/vscode-adapter.ts | 2 ++ lib/src/lib/terminal-lifecycle.ts | 11 ++++-- lib/src/lib/terminal-protocol.ts | 34 ++++++++---------- lib/src/lib/terminal-registry.ts | 3 ++ lib/src/lib/terminal-state-store.ts | 29 +++++++-------- lib/src/lib/terminal-state.ts | 36 +++++++++++++++++-- lib/src/lib/terminal-store.ts | 7 ++++ standalone/src/tauri-adapter.ts | 2 ++ 13 files changed, 104 insertions(+), 63 deletions(-) diff --git a/lib/src/components/Baseboard.tsx b/lib/src/components/Baseboard.tsx index abecec6..a9f1076 100644 --- a/lib/src/components/Baseboard.tsx +++ b/lib/src/components/Baseboard.tsx @@ -9,6 +9,7 @@ import { deriveHeader, getActivitySnapshot, getTerminalPaneStateSnapshot, + resolveDisplayPrimary, subscribeToActivity, subscribeToTerminalPaneState, } from '../lib/terminal-registry'; @@ -24,6 +25,7 @@ export function Baseboard({ items, onReattach, notice }: BaseboardProps) { const { elements: doorElements, bumpVersion } = useContext(DoorElementsContext); const activityStates = useSyncExternalStore(subscribeToActivity, getActivitySnapshot); const terminalStates = useSyncExternalStore(subscribeToTerminalPaneState, getTerminalPaneStateSnapshot); + const allPaneStates = useMemo(() => [...terminalStates.values()], [terminalStates]); const containerRef = useRef<HTMLDivElement>(null); const [containerWidth, setContainerWidth] = useState(0); const [startIndex, setStartIndex] = useState(0); @@ -149,7 +151,7 @@ export function Baseboard({ items, onReattach, notice }: BaseboardProps) { <div ref={measureEl} className="absolute -left-[9999px] flex gap-1.5" aria-hidden> {items.map(item => { const activity = activityStates.get(item.id) ?? DEFAULT_ACTIVITY_STATE; - const title = deriveDoorTitle(item.title, item.id, terminalStates); + const title = deriveDoorTitle(item.title, item.id, terminalStates, allPaneStates); return ( <Door key={item.id} @@ -183,7 +185,7 @@ export function Baseboard({ items, onReattach, notice }: BaseboardProps) { {items.slice(startIndex, endIndex).map(item => { const activity = activityStates.get(item.id) ?? DEFAULT_ACTIVITY_STATE; - const title = deriveDoorTitle(item.title, item.id, terminalStates); + const title = deriveDoorTitle(item.title, item.id, terminalStates, allPaneStates); return ( <Door key={item.id} @@ -215,8 +217,9 @@ function deriveDoorTitle( savedTitle: string, id: string, terminalStates: Map<string, TerminalPaneState>, + allPaneStates: TerminalPaneState[], ): string { const paneState = terminalStates.get(id) ?? createTerminalPaneState(); - const derived = deriveHeader(paneState, terminalStates.size > 0 ? [...terminalStates.values()] : [paneState]).primary; - return derived === 'shell' && savedTitle !== '<unnamed>' ? savedTitle : derived; + const visible = allPaneStates.length > 0 ? allPaneStates : [paneState]; + return resolveDisplayPrimary(deriveHeader(paneState, visible).primary, savedTitle); } diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 97ce7ae..96147c4 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -19,7 +19,9 @@ import { getTerminalPaneState, getTerminalPaneStateSnapshot, deriveHeader, + resolveDisplayPrimary, setTerminalUserTitle, + UNNAMED_PANEL_TITLE, type SessionStatus, } from '../lib/terminal-registry'; import { findReattachNeighbor } from '../lib/spatial-nav'; @@ -268,8 +270,7 @@ export function Wall({ getTerminalPaneState(id), [...getTerminalPaneStateSnapshot().values()], ).primary; - const panelTitle = panel.title ?? id; - const title = derivedTitle === 'shell' && panelTitle !== '<unnamed>' ? panelTitle : derivedTitle; + const title = resolveDisplayPrimary(derivedTitle, panel.title ?? id); const layoutAtMinimize = cloneLayout(api.toJSON()); // Capture the nearest adjacent pane and our actual relative position @@ -453,7 +454,7 @@ export function Wall({ id: newId, component: 'terminal', tabComponent: 'terminal', - title: '<unnamed>', + title: UNNAMED_PANEL_TITLE, position: active ? { referencePanel: active.id, direction: pickSplitDirection(active) } : undefined, }); selectPane(newId); @@ -484,7 +485,7 @@ export function Wall({ id: newId, component: 'terminal', tabComponent: 'terminal', - title: '<unnamed>', + title: UNNAMED_PANEL_TITLE, position: ref ? { referencePanel: ref, direction } : undefined, }); selectPane(newId); diff --git a/lib/src/components/wall/TerminalPaneHeader.tsx b/lib/src/components/wall/TerminalPaneHeader.tsx index 797e082..314681a 100644 --- a/lib/src/components/wall/TerminalPaneHeader.tsx +++ b/lib/src/components/wall/TerminalPaneHeader.tsx @@ -1,4 +1,4 @@ -import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type CSSProperties } from 'react'; +import { useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties } from 'react'; import { createPortal } from 'react-dom'; import type { IDockviewPanelHeaderProps } from 'dockview-react'; import { tv } from 'tailwind-variants'; @@ -34,7 +34,7 @@ import { subscribeToTerminalPaneState, type SessionStatus, } from '../../lib/terminal-registry'; -import { createTerminalPaneState, deriveHeader } from '../../lib/terminal-state'; +import { createTerminalPaneState, deriveHeader, resolveDisplayPrimary } from '../../lib/terminal-state'; import { DialogKeyboardContext, ModeContext, @@ -83,14 +83,10 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { const actions = useContext(WallActionsContext); const activity = activityStates.get(api.id) ?? DEFAULT_ACTIVITY_STATE; const paneState = terminalStates.get(api.id) ?? createTerminalPaneState(); - const visiblePaneStates = terminalStates.size > 0 ? [...terminalStates.values()] : [paneState]; + const allPaneStates = useMemo(() => [...terminalStates.values()], [terminalStates]); + const visiblePaneStates = allPaneStates.length > 0 ? allPaneStates : [paneState]; const derivedHeader = deriveHeader(paneState, visiblePaneStates); - const apiTitle = api.title ?? ''; - const displayTitle = paneState.title?.source === 'user' - ? paneState.title.title - : derivedHeader.primary === 'shell' && apiTitle && apiTitle !== '<unnamed>' - ? apiTitle - : derivedHeader.primary; + const displayTitle = resolveDisplayPrimary(derivedHeader.primary, api.title); const mouseState = mouseStates.get(api.id) ?? DEFAULT_MOUSE_SELECTION_STATE; const showMouseIcon = mouseState.mouseReporting !== 'none'; const inOverride = mouseState.override !== 'off'; diff --git a/lib/src/components/wall/use-dockview-ready.ts b/lib/src/components/wall/use-dockview-ready.ts index c439f3e..daf2794 100644 --- a/lib/src/components/wall/use-dockview-ready.ts +++ b/lib/src/components/wall/use-dockview-ready.ts @@ -6,7 +6,7 @@ import type { DockviewWillDropEvent, SerializedDockview, } from 'dockview-react'; -import { getDefaultShellOpts, setPendingShellOpts, swapTerminals } from '../../lib/terminal-registry'; +import { getDefaultShellOpts, setPendingShellOpts, swapTerminals, UNNAMED_PANEL_TITLE } from '../../lib/terminal-registry'; import { prefersReducedMotion } from '../../lib/ui-geometry'; import type { DooredItem, WallMode, WallSelectionKind, SpawnDirection } from './wall-types'; import { pickSplitDirection, swapPanelTitles } from './dockview-helpers'; @@ -76,7 +76,7 @@ export function useDockviewReady({ id, component: 'terminal', tabComponent: 'terminal', - title: '<unnamed>', + title: UNNAMED_PANEL_TITLE, position: referencePanel ? { referencePanel: referencePanel.id, direction } : undefined, }); }; @@ -152,7 +152,7 @@ export function useDockviewReady({ const id = generatePaneId(); primeDefaultShell(id); freshlySpawnedRef.current.set(id, 'top-left'); - e.api.addPanel({ id, component: 'terminal', tabComponent: 'terminal', title: '<unnamed>' }); + e.api.addPanel({ id, component: 'terminal', tabComponent: 'terminal', title: UNNAMED_PANEL_TITLE }); if (selectedIdRef.current === null) { selectPane(id); } diff --git a/lib/src/components/wall/use-session-persistence.ts b/lib/src/components/wall/use-session-persistence.ts index 8135c39..05a3687 100644 --- a/lib/src/components/wall/use-session-persistence.ts +++ b/lib/src/components/wall/use-session-persistence.ts @@ -3,6 +3,7 @@ import type { DockviewApi } from 'dockview-react'; import { pasteFilePaths } from '../../lib/clipboard'; import { getPlatform } from '../../lib/platform'; import { saveSession } from '../../lib/session-save'; +import { UNNAMED_PANEL_TITLE } from '../../lib/terminal-registry'; import type { DooredItem, WallSelectionKind } from './wall-types'; export function useSessionPersistence({ @@ -26,7 +27,7 @@ export function useSessionPersistence({ const api = apiRef.current; if (!api) return Promise.resolve(); - const panes = api.panels.map((p) => ({ id: p.id, title: p.title ?? '<unnamed>' })); + const panes = api.panels.map((p) => ({ id: p.id, title: p.title ?? UNNAMED_PANEL_TITLE })); return saveSession(getPlatform(), api.toJSON(), panes, doorsRef.current ?? []); }, [apiRef, doorsRef]); diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index a7cf0e0..2c9bedb 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -42,6 +42,8 @@ export class VSCodeAdapter implements PlatformAdapter { handler({ id: msg.id, data: msg.data }); } } else if (msg.type === 'pty:exit') { + this.replayProtocolParsers.delete(msg.id); + removeTerminalPaneState(msg.id); for (const handler of this.exitHandlers) { handler({ id: msg.id, exitCode: msg.exitCode }); } diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index b30276a..36b1e49 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -34,6 +34,11 @@ import { setTerminalUserTitle, swapTerminalPaneStates, } from './terminal-state-store'; +import { UNNAMED_PANEL_TITLE } from './terminal-state'; + +function seedProcessCwdAfterSpawn(id: string): void { + void getPlatform().getCwd(id).then((cwd) => fillTerminalProcessCwd(id, cwd)); +} function createXtermHost(): { terminal: Terminal; fit: FitAddon; element: HTMLDivElement } { const styles = getComputedStyle(document.body); @@ -209,7 +214,7 @@ export function getOrCreateTerminal(id: string): TerminalEntry { rows: dims?.rows || 30, ...shellOpts, }); - void getPlatform().getCwd(id).then((cwd) => fillTerminalProcessCwd(id, cwd)); + seedProcessCwdAfterSpawn(id); return entry; } @@ -244,7 +249,7 @@ export function restoreTerminal( const entry = setupTerminalEntry(id); resetTerminalPaneState(id); seedTerminalManualCwd(id, opts.cwd); - if (opts.title && opts.title !== '<unnamed>') { + if (opts.title && opts.title !== UNNAMED_PANEL_TITLE) { setTerminalUserTitle(id, opts.title); } @@ -263,7 +268,7 @@ export function restoreTerminal( shell: opts.shell, args: opts.args, }); - void getPlatform().getCwd(id).then((cwd) => fillTerminalProcessCwd(id, cwd)); + seedProcessCwdAfterSpawn(id); return entry; } diff --git a/lib/src/lib/terminal-protocol.ts b/lib/src/lib/terminal-protocol.ts index 1e1c96d..ebef54d 100644 --- a/lib/src/lib/terminal-protocol.ts +++ b/lib/src/lib/terminal-protocol.ts @@ -294,39 +294,33 @@ function parseOsc7(content: string): TerminalProtocolEvent[] { function parseOsc133(content: string): TerminalProtocolEvent[] { const fields = content.split(';'); if (fields[0] !== '133') return []; - switch (fields[1]) { - case 'A': - return [{ kind: 'semantic', event: { type: 'promptStart' } }]; - case 'B': - return [{ kind: 'semantic', event: { type: 'promptEnd' } }]; - case 'C': - return [commandStartEvent('osc133_boundaries')]; - case 'D': - return [{ kind: 'semantic', event: { type: 'commandFinish', exitCode: parseExitCode(fields[2]) } }]; - default: - return []; - } + return parsePromptBoundary(fields, 'osc133_boundaries'); } function parseOsc633(content: string): TerminalProtocolEvent[] { const fields = content.split(';'); if (fields[0] !== '633') return []; + if (fields[1] === 'E') { + const prefix = '633;E;'; + if (!content.startsWith(prefix)) return []; + return [{ kind: 'semantic', event: { type: 'commandLine', commandLine: content.slice(prefix.length) } }]; + } + if (fields[1] === 'P') { + return parseOsc633Property(content.slice('633;P;'.length)); + } + return parsePromptBoundary(fields, 'osc633_boundaries'); +} + +function parsePromptBoundary(fields: string[], commandStartSource: CommandRunSource): TerminalProtocolEvent[] { switch (fields[1]) { case 'A': return [{ kind: 'semantic', event: { type: 'promptStart' } }]; case 'B': return [{ kind: 'semantic', event: { type: 'promptEnd' } }]; case 'C': - return [commandStartEvent('osc633_boundaries')]; + return [commandStartEvent(commandStartSource)]; case 'D': return [{ kind: 'semantic', event: { type: 'commandFinish', exitCode: parseExitCode(fields[2]) } }]; - case 'E': { - const prefix = '633;E;'; - if (!content.startsWith(prefix)) return []; - return [{ kind: 'semantic', event: { type: 'commandLine', commandLine: content.slice(prefix.length) } }]; - } - case 'P': - return parseOsc633Property(content.slice('633;P;'.length)); default: return []; } diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index 62269fd..a69f164 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -75,10 +75,13 @@ export { cwdFromOsc9_9, cwdFromProcessPath, cwdIdentity, + DEFAULT_COMMAND_TITLE, deriveFallbackCommandTitle, deriveHeader, groupTerminalPanes, reduceTerminalState, + resolveDisplayPrimary, shortestUniqueCwdLabels, summarizeCommandLine, + UNNAMED_PANEL_TITLE, } from './terminal-state'; diff --git a/lib/src/lib/terminal-state-store.ts b/lib/src/lib/terminal-state-store.ts index f58f99a..8757a50 100644 --- a/lib/src/lib/terminal-state-store.ts +++ b/lib/src/lib/terminal-state-store.ts @@ -8,7 +8,7 @@ import { type TerminalSemanticEvent, type TerminalTitle, } from './terminal-state'; -import { registry } from './terminal-store'; +import { getSessionIdByPtyId } from './terminal-store'; const paneStates = new Map<string, TerminalPaneState>(); const listeners = new Set<() => void>(); @@ -16,7 +16,9 @@ let cachedSnapshot: Map<string, TerminalPaneState> | null = null; export function subscribeToTerminalPaneState(listener: () => void): () => void { listeners.add(listener); - return () => listeners.delete(listener); + return () => { + listeners.delete(listener); + }; } export function getTerminalPaneStateSnapshot(): Map<string, TerminalPaneState> { @@ -55,10 +57,12 @@ export function applyTerminalSemanticEventsByPtyId(ptyId: string, events: Termin export function applyTerminalSemanticEvents(id: string, events: TerminalSemanticEvent[]): void { if (events.length === 0) return; - let next = paneStates.get(id) ?? createTerminalPaneState(); + const prev = paneStates.get(id) ?? createTerminalPaneState(); + let next = prev; for (const event of events) { next = reduceTerminalState(next, event); } + if (next === prev && paneStates.has(id)) return; paneStates.set(id, next); notifyTerminalPaneStateListeners(); } @@ -75,18 +79,12 @@ export function setTerminalUserTitle(id: string, title: string): void { } export function seedTerminalManualCwd(id: string, path: string | null | undefined): void { - if (!path) { - ensureTerminalPaneState(id); - return; - } - const cwd = cwdFromManualPath(path); - if (!cwd) { + const cwd = path ? cwdFromManualPath(path) : null; + if (cwd && !paneStates.get(id)?.cwd) { + ensureTerminalPaneState(id, { cwd }); + } else { ensureTerminalPaneState(id); - return; } - const existing = paneStates.get(id); - if (existing?.cwd) return; - ensureTerminalPaneState(id, { cwd }); } export function fillTerminalProcessCwd(id: string, path: string | null | undefined): void { @@ -116,10 +114,7 @@ function updateCwdIfAllowed(id: string, cwd: CwdState): void { } function resolvePaneStateIdByPtyId(ptyId: string): string { - for (const [id, entry] of registry) { - if (entry.ptyId === ptyId) return id; - } - return ptyId; + return getSessionIdByPtyId(ptyId) ?? ptyId; } function notifyTerminalPaneStateListeners(): void { diff --git a/lib/src/lib/terminal-state.ts b/lib/src/lib/terminal-state.ts index 9c2bbc0..a76c01e 100644 --- a/lib/src/lib/terminal-state.ts +++ b/lib/src/lib/terminal-state.ts @@ -102,7 +102,8 @@ export const DEFAULT_TERMINAL_PANE_STATE: TerminalPaneState = Object.freeze({ title: null, }); -const DEFAULT_COMMAND_TITLE = 'shell'; +export const DEFAULT_COMMAND_TITLE = 'shell'; +export const UNNAMED_PANEL_TITLE = '<unnamed>'; const DEFAULT_DIRECTORY_LABEL = 'Unknown directory'; const COMMAND_TITLE_LIMIT = 48; let nextCommandRunId = 0; @@ -128,12 +129,16 @@ export function reduceTerminalState( switch (event.type) { case 'cwd': + if (state.cwd && sameCwd(state.cwd, event.cwd)) return state; return { ...state, cwd: event.cwd }; case 'promptStart': + if (state.activity.kind === 'prompt') return state; return { ...state, activity: { kind: 'prompt' } }; case 'promptEnd': + if (state.activity.kind === 'editing') return state; return { ...state, activity: { kind: 'editing' } }; case 'commandLine': + if (state.pendingCommandLine === event.commandLine) return state; return { ...state, pendingCommandLine: event.commandLine }; case 'commandStart': { const raw = state.pendingCommandLine; @@ -156,7 +161,9 @@ export function reduceTerminalState( } case 'commandFinish': { if (!state.currentCommand) { - return { ...state, activity: finishedActivity(event.exitCode) }; + const next = finishedActivity(event.exitCode); + if (sameActivity(state.activity, next)) return state; + return { ...state, activity: next }; } const finishedCommand: CommandRun = { ...state.currentCommand, @@ -171,10 +178,25 @@ export function reduceTerminalState( }; } case 'title': + if (state.title && sameTitle(state.title, event.title)) return state; return { ...state, title: event.title }; } } +function sameCwd(a: CwdState, b: CwdState): boolean { + return cwdIdentity(a) === cwdIdentity(b) && a.source === b.source; +} + +function sameTitle(a: TerminalTitle, b: TerminalTitle): boolean { + return a.title === b.title && a.source === b.source; +} + +function sameActivity(a: ShellActivity, b: ShellActivity): boolean { + if (a.kind !== b.kind) return false; + if (a.kind === 'finished' && b.kind === 'finished') return a.exitCode === b.exitCode; + return true; +} + export function cwdFromOsc7(rawUri: string, now = Date.now()): CwdState | null { let parsed: URL; try { @@ -299,6 +321,16 @@ export function deriveFallbackCommandTitle( return options.shellName?.trim() || DEFAULT_COMMAND_TITLE; } +export function resolveDisplayPrimary( + derivedPrimary: string, + fallbackTitle: string | null | undefined, +): string { + if (derivedPrimary !== DEFAULT_COMMAND_TITLE) return derivedPrimary; + const trimmed = fallbackTitle?.trim(); + if (trimmed && trimmed !== UNNAMED_PANEL_TITLE) return trimmed; + return derivedPrimary; +} + export function deriveHeader( pane: TerminalPaneState, visiblePanes: TerminalPaneState[], diff --git a/lib/src/lib/terminal-store.ts b/lib/src/lib/terminal-store.ts index 4a29011..45daf7e 100644 --- a/lib/src/lib/terminal-store.ts +++ b/lib/src/lib/terminal-store.ts @@ -47,6 +47,13 @@ export function getEntryByPtyId(ptyId: string): TerminalEntry | null { return null; } +export function getSessionIdByPtyId(ptyId: string): string | null { + for (const [id, entry] of registry) { + if (entry.ptyId === ptyId) return id; + } + return null; +} + export function resolveTerminalSessionId(id: string): string { return registry.get(id)?.ptyId ?? id; } diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index 40297e9..67907eb 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -75,6 +75,8 @@ export class TauriAdapter implements PlatformAdapter { this.unlistenFns.push( await listen<{ id: string; exitCode: number }>("pty:exit", (event) => { this.alertManager.onExit(event.payload.id); + this.protocolParsers.delete(event.payload.id); + removeTerminalPaneState(event.payload.id); for (const handler of this.exitHandlers) { handler(event.payload); } From 08d583a30eb385cddf4c76c2d11bac7c7f6edeee Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Fri, 8 May 2026 10:37:39 -0700 Subject: [PATCH 03/50] Add terminal shell and CWD state coverage --- docs/specs/layout.md | 2 +- docs/specs/terminal-state.md | 16 +- lib/.storybook/preview.ts | 21 +- lib/src/lib/terminal-command-input.test.ts | 41 +++ lib/src/lib/terminal-command-input.ts | 113 ++++++ lib/src/lib/terminal-lifecycle.ts | 8 +- lib/src/lib/terminal-registry.ts | 1 + lib/src/lib/terminal-state-store.test.ts | 50 +++ lib/src/lib/terminal-state-store.ts | 77 +++- lib/src/lib/terminal-state.test.ts | 75 +++- lib/src/lib/terminal-state.ts | 28 +- lib/src/stories/ShellCwd.stories.tsx | 389 +++++++++++++++++++++ 12 files changed, 802 insertions(+), 19 deletions(-) create mode 100644 lib/src/lib/terminal-command-input.test.ts create mode 100644 lib/src/lib/terminal-command-input.ts create mode 100644 lib/src/lib/terminal-state-store.test.ts create mode 100644 lib/src/stories/ShellCwd.stories.tsx diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 36aab25..fc872c3 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -72,7 +72,7 @@ The content area is a tiling layout of panes, powered by dockview. Each pane occ Each pane has a 30px header that doubles as a drag handle. The header uses `cursor-grab` / `active:cursor-grabbing`, `select-none`, and the shared terminal top radius from `lib/src/components/design.tsx`. Background and foreground use the `--color-header-active-*` / `--color-header-inactive-*` token pairs, which map to VSCode file-tree list colors. Dockview's default close button and right-actions container are hidden via CSS. -The header label is derived from `TerminalPaneState`: user-pinned title first, then current/freshly-finished command title, then terminal title or shell fallback. When visible panes would have duplicate primary labels, the header adds a compact directory disambiguator using the running command's `cwdAtStart` or the idle pane's latest `cwd`. +The header label is derived from `TerminalPaneState`: user-pinned title first, then current/freshly-finished command title, then `<idle>` for idle panes. Terminal titles remain a fallback for unknown active commands, not the default idle label. When visible panes would have duplicate primary labels, the header adds a compact directory disambiguator using the running command's `cwdAtStart` or the idle pane's latest `cwd`. Elements from left to right: diff --git a/docs/specs/terminal-state.md b/docs/specs/terminal-state.md index 56452ac..1301d4b 100644 --- a/docs/specs/terminal-state.md +++ b/docs/specs/terminal-state.md @@ -11,7 +11,7 @@ MouseTerm models terminal panes by: - whether the shell is at a prompt, editing, running a foreground command, or waiting after command finish - command exit status - command directory at start time -- terminal title as a fallback label +- terminal title as a fallback label for unknown active commands Session CWD and command execution state are separate. `cwd` means "the shell/session reported this directory"; it is not necessarily the internal CWD of a foreground program. A command snapshots `cwdAtStart` when it starts, and that snapshot is used for grouping and header disambiguation while the command is running or freshly finished. @@ -68,6 +68,7 @@ type CommandRun = { | "osc633_boundaries" | "osc133_boundaries" | "foreground_process" + | "user_input" | "title"; outputRange?: { startMarkId?: string; @@ -139,12 +140,14 @@ The parser accepts both BEL and ST terminators and handles split chunks. Unsuppo `reduceTerminalState(state, event)` is the only state transition surface. - `cwd` replaces the latest session CWD. -- `promptStart` sets `{ kind: "prompt" }`. -- `promptEnd` sets `{ kind: "editing" }`. +- `promptStart` sets `{ kind: "prompt" }` and clears stale pending command-line fallback. +- `promptEnd` sets `{ kind: "editing" }` and clears stale pending command-line fallback. - `commandLine` stores `pendingCommandLine`. +- User-entered prompt input may also store `pendingCommandLine` as an explicit fallback before an OSC 133/633 command-start boundary. This fallback is only used while the shell is idle/editing; foreground-program input is ignored. If the submitted line is non-empty, the input fallback may create a `currentCommand` immediately with `source: "user_input"` so shells without command-start integration still show the active command. - `commandStart` creates `currentCommand`, snapshots `cwdAtStart`, clears `pendingCommandLine`, and sets `{ kind: "running" }`. - `commandFinish` moves `currentCommand` to `lastCommand`, stores `finishedAt`/`exitCode`, clears `currentCommand`, and sets `{ kind: "finished", exitCode }`. -- A later prompt signal moves the pane out of `finished`. +- A later prompt signal moves the pane out of `finished`. If a command was started from `user_input` and no explicit `commandFinish` arrived, the prompt signal also clears `currentCommand` so the header returns to `<idle>`. +- For `user_input` fallback commands only, visible output that looks like a returned shell prompt may synthesize the same prompt transition. This is a scoped fallback for shells that do not emit command finish/start OSCs. CWD fallback order is: @@ -171,7 +174,8 @@ Rules: - A user-pinned title is primary. - A running command uses `currentCommand.displayCommand`. - A freshly finished command uses `lastCommand.displayCommand` until the next prompt signal. -- Idle terminals use title or shell fallback. +- Idle terminals use `<idle>` unless a user-pinned title exists. +- Unknown active commands may use terminal title or shell fallback. - Duplicate primary labels get a shortest unique directory label. - Running and finished commands disambiguate with `cwdAtStart`. - Idle terminals disambiguate with `pane.cwd`. @@ -189,7 +193,7 @@ pane.currentCommand?.cwdAtStart ?? pane.cwd Command grouping uses: ```ts -pane.currentCommand?.displayCommand ?? idleLabel(pane) +pane.currentCommand?.displayCommand ?? "<idle>" ``` Status grouping uses: diff --git a/lib/.storybook/preview.ts b/lib/.storybook/preview.ts index 5aac2ca..fa96760 100644 --- a/lib/.storybook/preview.ts +++ b/lib/.storybook/preview.ts @@ -8,8 +8,12 @@ import { clearPrimedActivity, disposeAllSessions, getActivitySnapshot, + getTerminalPaneStateSnapshot, primeActivity, + removeTerminalPaneState, + resetTerminalPaneState, type ActivityState, + type TerminalPaneState, } from '../src/lib/terminal-registry'; import { computeDynamicPalette } from '../src/lib/themes/dynamic-palette'; import { VSCODE_THEMES, VSCODE_THEME_TYPES } from './themes'; @@ -157,6 +161,11 @@ const preview: Preview = { byIndex?: Partial<ActivityState>[]; } | undefined; + const primedTerminalState = context.parameters?.primedTerminalState as + | { + byId?: Record<string, Partial<TerminalPaneState>>; + } + | undefined; const platform = fakePlatform as FakePtyAdapter; if (scenario) platform.setDefaultScenario(scenario); @@ -167,6 +176,13 @@ const preview: Preview = { const applyPrimedState = () => { clearPrimedActivity(); + for (const id of getTerminalPaneStateSnapshot().keys()) { + removeTerminalPaneState(id); + } + + for (const [id, state] of Object.entries(primedTerminalState?.byId ?? {})) { + resetTerminalPaneState(id, state); + } for (const [id, state] of Object.entries(primedSessionState?.byId ?? {})) { primeActivity(id, state); @@ -189,10 +205,13 @@ const preview: Preview = { window.cancelAnimationFrame(raf1); window.cancelAnimationFrame(raf2); clearPrimedActivity(); + for (const id of getTerminalPaneStateSnapshot().keys()) { + removeTerminalPaneState(id); + } platform.clearDefaultScenario(); disposeAllSessions(); }; - }, [platform, primedSessionState]); + }, [platform, primedSessionState, primedTerminalState]); return createElement(Story); }, diff --git a/lib/src/lib/terminal-command-input.test.ts b/lib/src/lib/terminal-command-input.test.ts new file mode 100644 index 0000000..4fac659 --- /dev/null +++ b/lib/src/lib/terminal-command-input.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { + createPromptCommandInputState, + updatePromptCommandInput, +} from './terminal-command-input'; + +describe('terminal prompt command input tracker', () => { + it('submits a simple typed command on enter', () => { + const result = updatePromptCommandInput(createPromptCommandInputState(), 'lazygit\r'); + + expect(result.submittedCommandLine).toBe('lazygit'); + expect(result.state).toEqual(createPromptCommandInputState()); + }); + + it('tracks basic prompt editing before enter', () => { + let result = updatePromptCommandInput(createPromptCommandInputState(), 'lazygi'); + result = updatePromptCommandInput(result.state, 'x\x7ft\r'); + + expect(result.submittedCommandLine).toBe('lazygit'); + }); + + it('keeps cursor-aware edits for left and right arrow input', () => { + let result = updatePromptCommandInput(createPromptCommandInputState(), 'lazgit'); + result = updatePromptCommandInput(result.state, '\x1b[D\x1b[D\x1b[D'); + result = updatePromptCommandInput(result.state, 'y\r'); + + expect(result.submittedCommandLine).toBe('lazygit'); + }); + + it('does not trust history navigation because the visible line is shell-owned', () => { + const result = updatePromptCommandInput(createPromptCommandInputState(), '\x1b[A\r'); + + expect(result.submittedCommandLine).toBeNull(); + }); + + it('ignores bracketed paste delimiters while keeping pasted command text', () => { + const result = updatePromptCommandInput(createPromptCommandInputState(), '\x1b[200~pnpm test\x1b[201~\r'); + + expect(result.submittedCommandLine).toBe('pnpm test'); + }); +}); diff --git a/lib/src/lib/terminal-command-input.ts b/lib/src/lib/terminal-command-input.ts new file mode 100644 index 0000000..3f85f6c --- /dev/null +++ b/lib/src/lib/terminal-command-input.ts @@ -0,0 +1,113 @@ +export interface PromptCommandInputState { + line: string; + cursor: number; + trusted: boolean; +} + +export interface PromptCommandInputResult { + state: PromptCommandInputState; + submittedCommandLine: string | null; +} + +const BRACKETED_PASTE_START = '\x1b[200~'; +const BRACKETED_PASTE_END = '\x1b[201~'; +const CSI_RE = /^\x1b\[[0-9;?]*[A-Za-z~]/; + +export function createPromptCommandInputState(): PromptCommandInputState { + return { line: '', cursor: 0, trusted: true }; +} + +export function updatePromptCommandInput( + current: PromptCommandInputState, + input: string, +): PromptCommandInputResult { + let state = { ...current }; + let submittedCommandLine: string | null = null; + + for (let index = 0; index < input.length; index += 1) { + const rest = input.slice(index); + const char = input[index]; + + if (rest.startsWith(BRACKETED_PASTE_START)) { + index += BRACKETED_PASTE_START.length - 1; + continue; + } + if (rest.startsWith(BRACKETED_PASTE_END)) { + index += BRACKETED_PASTE_END.length - 1; + continue; + } + + if (char === '\x1b') { + const match = rest.match(CSI_RE); + if (match) { + state = applyCsiInput(state, match[0]); + index += match[0].length - 1; + } + continue; + } + + if (char === '\r' || char === '\n') { + const submitted = state.trusted ? state.line.trim() : ''; + if (submitted && submittedCommandLine === null) submittedCommandLine = submitted; + state = createPromptCommandInputState(); + continue; + } + + state = applyControlOrTextInput(state, char); + } + + return { state, submittedCommandLine }; +} + +function applyCsiInput(state: PromptCommandInputState, sequence: string): PromptCommandInputState { + const final = sequence[sequence.length - 1]; + if (final === 'D') return { ...state, cursor: Math.max(0, state.cursor - 1) }; + if (final === 'C') return { ...state, cursor: Math.min(state.line.length, state.cursor + 1) }; + if (final === 'A' || final === 'B') return { line: '', cursor: 0, trusted: false }; + return state; +} + +function applyControlOrTextInput( + state: PromptCommandInputState, + char: string, +): PromptCommandInputState { + if (char === '\x03' || char === '\x04' || char === '\x15') return createPromptCommandInputState(); + if (char === '\x01') return { ...state, cursor: 0 }; + if (char === '\x05') return { ...state, cursor: state.line.length }; + if (char === '\x0b') return { ...state, line: state.line.slice(0, state.cursor) }; + if (char === '\x17') return deleteWordBeforeCursor(state); + if (char === '\x7f' || char === '\b') return deleteBeforeCursor(state); + + if (char < ' ' || char === '\x7f') return state; + + const before = state.line.slice(0, state.cursor); + const after = state.line.slice(state.cursor); + return { + line: `${before}${char}${after}`, + cursor: state.cursor + char.length, + trusted: state.trusted, + }; +} + +function deleteBeforeCursor(state: PromptCommandInputState): PromptCommandInputState { + if (state.cursor === 0) return state; + return { + ...state, + line: `${state.line.slice(0, state.cursor - 1)}${state.line.slice(state.cursor)}`, + cursor: state.cursor - 1, + }; +} + +function deleteWordBeforeCursor(state: PromptCommandInputState): PromptCommandInputState { + if (state.cursor === 0) return state; + const beforeCursor = state.line.slice(0, state.cursor); + const afterCursor = state.line.slice(state.cursor); + const trimmedEnd = beforeCursor.replace(/\s+$/, ''); + const wordStart = trimmedEnd.search(/\S+$/); + const keepUntil = wordStart === -1 ? 0 : wordStart; + return { + ...state, + line: `${beforeCursor.slice(0, keepUntil)}${afterCursor}`, + cursor: keepUntil, + }; +} diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index 36b1e49..d2cca93 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -28,6 +28,8 @@ import { getTerminalTheme, paintTerminalHost, startThemeObserver } from './termi import { ensureTerminalPaneState, fillTerminalProcessCwd, + recordTerminalOutput, + recordTerminalUserInput, removeTerminalPaneState, resetTerminalPaneState, seedTerminalManualCwd, @@ -69,7 +71,10 @@ function createXtermHost(): { terminal: Terminal; fit: FitAddon; element: HTMLDi function wirePtyEvents(id: string, terminal: Terminal): () => void { const platform = getPlatform(); const handleData = (detail: { id: string; data: string }) => { - if (detail.id === id) terminal.write(detail.data); + if (detail.id === id) { + recordTerminalOutput(id, detail.data); + terminal.write(detail.data); + } }; const handleExit = (detail: { id: string; exitCode: number }) => { if (detail.id === id) terminal.write(`\r\n[Process exited with code ${detail.exitCode}]\r\n`); @@ -102,6 +107,7 @@ function wireXtermHandlers( if (inputIsReplayTerminalReport(input) && registry.get(id)?.isReplaying) return; if (!isSyntheticTerminalReport) { + recordTerminalUserInput(id, input); const entry = registry.get(id); const hadTodo = entry?.todo === true; getPlatform().alertAttend(id); diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index a69f164..05b515e 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -76,6 +76,7 @@ export { cwdFromProcessPath, cwdIdentity, DEFAULT_COMMAND_TITLE, + DEFAULT_IDLE_TITLE, deriveFallbackCommandTitle, deriveHeader, groupTerminalPanes, diff --git a/lib/src/lib/terminal-state-store.test.ts b/lib/src/lib/terminal-state-store.test.ts new file mode 100644 index 0000000..b2871d1 --- /dev/null +++ b/lib/src/lib/terminal-state-store.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + applyTerminalSemanticEvents, + getTerminalPaneState, + recordTerminalOutput, + recordTerminalUserInput, + removeTerminalPaneState, +} from './terminal-state-store'; + +describe('terminal semantic state store command input fallback', () => { + afterEach(() => { + removeTerminalPaneState('pane'); + }); + + it('promotes a submitted prompt line into the current command immediately', () => { + recordTerminalUserInput('pane', 'lazygit\r'); + + expect(getTerminalPaneState('pane').currentCommand).toMatchObject({ + rawCommandLine: 'lazygit', + displayCommand: 'lazygit', + source: 'user_input', + }); + expect(getTerminalPaneState('pane').activity).toEqual({ kind: 'running' }); + }); + + it('returns to idle when the next prompt arrives without a command finish event', () => { + recordTerminalUserInput('pane', 'lazygit\r'); + applyTerminalSemanticEvents('pane', [{ type: 'promptStart' }]); + + const state = getTerminalPaneState('pane'); + expect(state.currentCommand).toBeNull(); + expect(state.activity).toEqual({ kind: 'prompt' }); + }); + + it('returns to idle when prompt-looking output follows a user-input command', () => { + recordTerminalUserInput('pane', 'lazygit\r'); + recordTerminalOutput('pane', '\x1b[?1049l\r\nuser@host repo % '); + + const state = getTerminalPaneState('pane'); + expect(state.currentCommand).toBeNull(); + expect(state.activity).toEqual({ kind: 'editing' }); + }); + + it('does not treat arbitrary command output as a returned prompt', () => { + recordTerminalUserInput('pane', 'lazygit\r'); + recordTerminalOutput('pane', 'loading repositories...\r\n'); + + expect(getTerminalPaneState('pane').currentCommand?.displayCommand).toBe('lazygit'); + }); +}); diff --git a/lib/src/lib/terminal-state-store.ts b/lib/src/lib/terminal-state-store.ts index 8757a50..ace3e90 100644 --- a/lib/src/lib/terminal-state-store.ts +++ b/lib/src/lib/terminal-state-store.ts @@ -8,9 +8,16 @@ import { type TerminalSemanticEvent, type TerminalTitle, } from './terminal-state'; +import { + createPromptCommandInputState, + updatePromptCommandInput, + type PromptCommandInputState, +} from './terminal-command-input'; import { getSessionIdByPtyId } from './terminal-store'; const paneStates = new Map<string, TerminalPaneState>(); +const promptInputStates = new Map<string, PromptCommandInputState>(); +const promptOutputBuffers = new Map<string, string>(); const listeners = new Set<() => void>(); let cachedSnapshot: Map<string, TerminalPaneState> | null = null; @@ -41,11 +48,15 @@ export function ensureTerminalPaneState(id: string, initial?: Partial<TerminalPa } export function resetTerminalPaneState(id: string, initial?: Partial<TerminalPaneState>): void { + promptInputStates.delete(id); + promptOutputBuffers.delete(id); paneStates.set(id, createTerminalPaneState(initial)); notifyTerminalPaneStateListeners(); } export function removeTerminalPaneState(id: string): void { + promptInputStates.delete(id); + promptOutputBuffers.delete(id); if (!paneStates.delete(id)) return; notifyTerminalPaneStateListeners(); } @@ -57,6 +68,10 @@ export function applyTerminalSemanticEventsByPtyId(ptyId: string, events: Termin export function applyTerminalSemanticEvents(id: string, events: TerminalSemanticEvent[]): void { if (events.length === 0) return; + if (events.some((event) => event.type === 'promptStart' || event.type === 'promptEnd' || event.type === 'commandStart')) { + promptInputStates.delete(id); + promptOutputBuffers.delete(id); + } const prev = paneStates.get(id) ?? createTerminalPaneState(); let next = prev; for (const event of events) { @@ -67,6 +82,36 @@ export function applyTerminalSemanticEvents(id: string, events: TerminalSemantic notifyTerminalPaneStateListeners(); } +export function recordTerminalUserInput(id: string, input: string): void { + if (!input) return; + const state = paneStates.get(id) ?? createTerminalPaneState(); + if (state.currentCommand || state.activity.kind === 'running' || state.activity.kind === 'finished') return; + + const promptInputState = promptInputStates.get(id) ?? createPromptCommandInputState(); + const next = updatePromptCommandInput(promptInputState, input); + promptInputStates.set(id, next.state); + + if (next.submittedCommandLine) { + applyTerminalSemanticEvents(id, [ + { type: 'commandLine', commandLine: next.submittedCommandLine }, + { type: 'commandStart', source: 'user_input' }, + ]); + } +} + +export function recordTerminalOutput(id: string, output: string): void { + if (!output) return; + const state = paneStates.get(id); + if (state?.currentCommand?.source !== 'user_input') return; + + const buffer = `${promptOutputBuffers.get(id) ?? ''}${output}`.slice(-1024); + promptOutputBuffers.set(id, buffer); + if (!looksLikeReturnedShellPrompt(buffer)) return; + + promptOutputBuffers.delete(id); + applyTerminalSemanticEvents(id, [{ type: 'promptStart' }, { type: 'promptEnd' }]); +} + export function setTerminalUserTitle(id: string, title: string): void { const trimmed = title.trim(); if (!trimmed) return; @@ -97,11 +142,23 @@ export function fillTerminalProcessCwd(id: string, path: string | null | undefin export function swapTerminalPaneStates(idA: string, idB: string): void { const stateA = paneStates.get(idA); const stateB = paneStates.get(idB); - if (!stateA && !stateB) return; + const inputA = promptInputStates.get(idA); + const inputB = promptInputStates.get(idB); + const outputA = promptOutputBuffers.get(idA); + const outputB = promptOutputBuffers.get(idB); + if (!stateA && !stateB && !inputA && !inputB && !outputA && !outputB) return; if (stateB) paneStates.set(idA, stateB); else paneStates.delete(idA); if (stateA) paneStates.set(idB, stateA); else paneStates.delete(idB); + if (inputB) promptInputStates.set(idA, inputB); + else promptInputStates.delete(idA); + if (inputA) promptInputStates.set(idB, inputA); + else promptInputStates.delete(idB); + if (outputB) promptOutputBuffers.set(idA, outputB); + else promptOutputBuffers.delete(idA); + if (outputA) promptOutputBuffers.set(idB, outputA); + else promptOutputBuffers.delete(idB); notifyTerminalPaneStateListeners(); } @@ -117,6 +174,24 @@ function resolvePaneStateIdByPtyId(ptyId: string): string { return getSessionIdByPtyId(ptyId) ?? ptyId; } +function looksLikeReturnedShellPrompt(output: string): boolean { + const text = stripTerminalControls(output).replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + const lines = text.split('\n'); + const lastLine = lines[lines.length - 1]?.trimStart() ?? ''; + if (!lastLine || lastLine.length > 200) return false; + if (/^(?:PS\s+.+>|.+[$#%>❯λ])\s?$/.test(lastLine)) return true; + return /^[➜❯λ]\s+.+\s$/.test(lines[lines.length - 1] ?? ''); +} + +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/lib/terminal-state.test.ts b/lib/src/lib/terminal-state.test.ts index c49c758..9c2ef26 100644 --- a/lib/src/lib/terminal-state.test.ts +++ b/lib/src/lib/terminal-state.test.ts @@ -6,6 +6,7 @@ import { cwdFromOsc7, cwdFromOsc9_9, cwdIdentity, + DEFAULT_IDLE_TITLE, deriveHeader, groupTerminalPanes, reduceTerminalState, @@ -122,6 +123,69 @@ describe('terminal command state reducer', () => { state = reduceTerminalState(state, { type: 'promptStart' }); expect(state.activity).toEqual({ kind: 'prompt' }); }); + + it('uses a pending typed command line for OSC 133 command boundaries', () => { + let state = createTerminalPaneState({ cwd: cwdFromManualPath('/repo/app', 1)! }); + state = reduceTerminalState(state, { type: 'promptEnd' }); + state = reduceTerminalState(state, { type: 'commandLine', commandLine: 'lazygit' }); + state = reduceTerminalState(state, { type: 'commandStart', source: 'osc133_boundaries' }, { + now: () => 2, + createId: () => 'cmd-typed', + }); + + expect(state.currentCommand).toMatchObject({ + id: 'cmd-typed', + rawCommandLine: 'lazygit', + displayCommand: 'lazygit', + source: 'osc133_boundaries', + }); + + state = reduceTerminalState(state, { type: 'commandFinish', exitCode: 0 }, { now: () => 3 }); + expect(deriveHeader(state, [state])).toEqual({ + primary: 'lazygit', + status: 'finished', + exitCode: 0, + }); + + state = reduceTerminalState(state, { type: 'promptStart' }); + expect(deriveHeader(state, [state])).toEqual({ + primary: DEFAULT_IDLE_TITLE, + status: 'idle', + }); + }); + + it('clears stale pending typed command lines on a fresh prompt', () => { + let state = createTerminalPaneState({ pendingCommandLine: 'stale command' }); + + state = reduceTerminalState(state, { type: 'promptStart' }); + expect(state.pendingCommandLine).toBeNull(); + + state = reduceTerminalState({ ...state, pendingCommandLine: 'another stale command' }, { type: 'promptEnd' }); + expect(state.pendingCommandLine).toBeNull(); + }); + + it('moves an unclosed command back to idle when the next prompt starts', () => { + const cwd = cwdFromManualPath('/repo/app', 1)!; + let state = createTerminalPaneState({ cwd }); + state = reduceTerminalState(state, { type: 'commandLine', commandLine: 'lazygit' }); + state = reduceTerminalState(state, { type: 'commandStart', source: 'user_input' }, { + now: () => 2, + createId: () => 'cmd-user-input', + }); + + expect(deriveHeader(state, [state])).toEqual({ + primary: 'lazygit', + status: 'running', + }); + + state = reduceTerminalState(state, { type: 'promptStart' }); + + expect(state.currentCommand).toBeNull(); + expect(deriveHeader(state, [state])).toEqual({ + primary: DEFAULT_IDLE_TITLE, + status: 'idle', + }); + }); }); describe('command title summarizer', () => { @@ -142,6 +206,15 @@ describe('command title summarizer', () => { }); describe('header and grouping derivation', () => { + it('uses <idle> for terminals without a foreground command', () => { + const pane = createTerminalPaneState({ cwd: cwdFromManualPath('/repo/app', 1)!, activity: { kind: 'editing' } }); + + expect(deriveHeader(pane, [pane])).toEqual({ + primary: DEFAULT_IDLE_TITLE, + status: 'idle', + }); + }); + it('uses command start CWD for running headers and disambiguates duplicates', () => { const app = runningPane('/repo/app', 'pnpm test --watch'); const api = runningPane('/repo/api', 'pnpm test --watch'); @@ -172,7 +245,7 @@ describe('header and grouping derivation', () => { const finished = reduceTerminalState(running, { type: 'commandFinish', exitCode: 0 }, { now: () => 2 }); expect(groupTerminalPanes([running, idle], 'directory').map((group) => group.label)).toEqual(['app', 'api']); - expect(groupTerminalPanes([running, idle], 'command').map((group) => group.label)).toEqual(['npm run dev', 'shell']); + expect(groupTerminalPanes([running, idle], 'command').map((group) => group.label)).toEqual(['npm run dev', DEFAULT_IDLE_TITLE]); expect(groupTerminalPanes([running, idle, finished], 'status').map((group) => group.key)).toEqual([ 'running', 'unknown', diff --git a/lib/src/lib/terminal-state.ts b/lib/src/lib/terminal-state.ts index a76c01e..9f192c2 100644 --- a/lib/src/lib/terminal-state.ts +++ b/lib/src/lib/terminal-state.ts @@ -24,6 +24,7 @@ export type CommandRunSource = | 'osc633_boundaries' | 'osc133_boundaries' | 'foreground_process' + | 'user_input' | 'title'; export interface CommandRun { @@ -102,6 +103,7 @@ export const DEFAULT_TERMINAL_PANE_STATE: TerminalPaneState = Object.freeze({ title: null, }); +export const DEFAULT_IDLE_TITLE = '<idle>'; export const DEFAULT_COMMAND_TITLE = 'shell'; export const UNNAMED_PANEL_TITLE = '<unnamed>'; const DEFAULT_DIRECTORY_LABEL = 'Unknown directory'; @@ -132,11 +134,21 @@ export function reduceTerminalState( if (state.cwd && sameCwd(state.cwd, event.cwd)) return state; return { ...state, cwd: event.cwd }; case 'promptStart': - if (state.activity.kind === 'prompt') return state; - return { ...state, activity: { kind: 'prompt' } }; + if (state.activity.kind === 'prompt' && state.pendingCommandLine === null && state.currentCommand === null) return state; + return { + ...state, + activity: { kind: 'prompt' }, + currentCommand: null, + pendingCommandLine: null, + }; case 'promptEnd': - if (state.activity.kind === 'editing') return state; - return { ...state, activity: { kind: 'editing' } }; + if (state.activity.kind === 'editing' && state.pendingCommandLine === null && state.currentCommand === null) return state; + return { + ...state, + activity: { kind: 'editing' }, + currentCommand: null, + pendingCommandLine: null, + }; case 'commandLine': if (state.pendingCommandLine === event.commandLine) return state; return { ...state, pendingCommandLine: event.commandLine }; @@ -325,6 +337,7 @@ export function resolveDisplayPrimary( derivedPrimary: string, fallbackTitle: string | null | undefined, ): string { + if (derivedPrimary === DEFAULT_IDLE_TITLE) return derivedPrimary; if (derivedPrimary !== DEFAULT_COMMAND_TITLE) return derivedPrimary; const trimmed = fallbackTitle?.trim(); if (trimmed && trimmed !== UNNAMED_PANEL_TITLE) return trimmed; @@ -643,10 +656,9 @@ function headerPrimary(pane: TerminalPaneState, options: HeaderOptions): string return idleLabel(pane, options); } -function idleLabel(pane: TerminalPaneState, options: { shellName?: string } = {}): string { - const title = pane.title?.title?.trim(); - if (title) return title; - return options.shellName?.trim() || DEFAULT_COMMAND_TITLE; +function idleLabel(pane: TerminalPaneState, _options: { shellName?: string } = {}): string { + if (pane.title?.source === 'user') return pane.title.title; + return DEFAULT_IDLE_TITLE; } function headerStatus(pane: TerminalPaneState): DerivedHeader['status'] { diff --git a/lib/src/stories/ShellCwd.stories.tsx b/lib/src/stories/ShellCwd.stories.tsx new file mode 100644 index 0000000..2157514 --- /dev/null +++ b/lib/src/stories/ShellCwd.stories.tsx @@ -0,0 +1,389 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Baseboard } from '../components/Baseboard'; +import { + ModeContext, + RenamingIdContext, + SelectedIdContext, + TerminalPaneHeader, + WallActionsContext, + type DooredItem, + type WallActions, +} from '../components/Wall'; +import { + cwdFromManualPath, + cwdFromOsc1337, + cwdFromOsc633, + cwdFromOsc7, + cwdFromOsc9_9, + cwdFromProcessPath, + deriveHeader, + groupTerminalPanes, + UNNAMED_PANEL_TITLE, + type CommandRun, + type CwdState, + type ShellActivity, + type TerminalPaneState, + type TerminalTitle, +} from '../lib/terminal-registry'; +import { createTerminalPaneState } from '../lib/terminal-state'; + +const HEADER_WIDTH = 380; +const DOOR_WIDTH = 300; +const BASE_TIME = 1_700_000_000_000; + +interface ShellCwdCase { + id: string; + label: string; + state: TerminalPaneState; + fallbackTitle?: string; + note?: string; +} + +const noopActions: WallActions = { + onKill: () => {}, + onMinimize: () => {}, + onAlertButton: () => 'noop', + onToggleTodo: () => {}, + onSplitH: () => {}, + onSplitV: () => {}, + onZoom: () => {}, + onClickPanel: () => {}, + onStartRename: () => {}, + onFinishRename: () => {}, + onCancelRename: () => {}, +}; + +const meta: Meta<typeof ShellCwdMatrix> = { + title: 'Terminal State/Shell and CWD', + component: ShellCwdMatrix, + parameters: { + controls: { disable: true }, + }, +}; + +export default meta; +type Story = StoryObj<typeof ShellCwdMatrix>; + +export const CwdSourcesAndPathKinds: Story = storyFor([ + caseState('cwd-none', 'No CWD', idle({ activity: { kind: 'unknown' } }), 'No shell integration signal yet'), + caseState('cwd-manual', 'Manual/restored', idle({ cwd: manual('/Users/me/restored-app') }), 'Initial launch or persisted directory'), + caseState('cwd-process', 'Process fallback', idle({ cwd: processCwd('/Users/me/live-process') }), 'Local PTY process CWD'), + caseState('cwd-osc7-local', 'OSC 7 local POSIX', idle({ cwd: osc7('file://localhost/Users/me/project') }), 'file://localhost/Users/me/project'), + caseState('cwd-osc7-remote', 'OSC 7 remote POSIX', idle({ cwd: osc7('file://prod-box/home/me/project') }), 'Preserves remote host identity'), + caseState('cwd-osc7-encoded', 'OSC 7 encoded', idle({ cwd: osc7('file://localhost/Users/me/My%20Project/ma%C3%B1ana') }), 'Decoded spaces and non-ASCII'), + caseState('cwd-osc99-drive', 'OSC 9;9 drive', idle({ cwd: osc99('C:\\Users\\me\\repo') }), 'Windows drive-letter path'), + caseState('cwd-osc99-unc', 'OSC 9;9 UNC', idle({ cwd: osc99('\\\\server\\share\\repo') }), 'Windows UNC path'), + caseState('cwd-osc99-wsl', 'OSC 9;9 WSL-like', idle({ cwd: osc99('/mnt/c/Users/me/repo') }), 'Unknown path kind by design'), + caseState('cwd-osc633', 'OSC 633 Cwd', idle({ cwd: osc633('/workspaces/mouseterm') }), 'VS Code shell integration CWD'), + caseState('cwd-osc1337', 'OSC 1337 CurrentDir', idle({ cwd: osc1337('/Users/me/iterm-app') }), 'iTerm2 CurrentDir compatibility'), +]); + +export const HostAndDirectoryDisambiguation: Story = storyFor([ + caseState('host-sibling-app', 'Sibling app', running('/repo/app', 'pnpm test --watch'), 'Same command in sibling directories'), + caseState('host-sibling-api', 'Sibling api', running('/repo/api', 'pnpm test --watch'), 'Same command in sibling directories'), + caseState('host-unrelated-work', 'Unrelated app A', running('/work/client/app', 'npm run dev'), 'Same basename in unrelated tree'), + caseState('host-unrelated-tmp', 'Unrelated app B', running('/tmp/scratch/app', 'npm run dev'), 'Same basename in unrelated tree'), + caseState('host-local-same-path', 'Local same path', running('/home/me/app', 'cargo watch -x test', { host: 'localhost' }), 'Local host kept distinct'), + caseState('host-remote-same-path', 'Remote same path', running('/home/me/app', 'cargo watch -x test', { host: 'prod-box' }), 'Remote host kept distinct'), + caseState('host-long-path', 'Long path', running('/Users/me/src/company/product/apps/customer-facing-dashboard', 'docker compose up'), 'Long directory label truncates'), + caseState('host-unknown-a', 'Unknown duplicate A', running(null, 'pytest'), 'No CWD available'), + caseState('host-unknown-b', 'Unknown duplicate B', running(null, 'pytest'), 'No CWD available'), +]); + +export const ShellActivityLifecycle: Story = storyFor([ + caseState('activity-unknown', 'Unknown', idle({ activity: { kind: 'unknown' }, title: terminalTitle('shell', 'derived') }), 'No shell integration signal'), + caseState('activity-prompt', 'Prompt drawing', idle({ activity: { kind: 'prompt' }, title: terminalTitle('zsh', 'osc0') }), 'Prompt start'), + caseState('activity-editing', 'Editing', idle({ activity: { kind: 'editing' }, title: terminalTitle('zsh', 'osc0') }), 'At prompt'), + caseState('activity-running', 'Running', running('/repo/app', 'npm run dev'), 'Foreground command active'), + caseState('activity-finished-zero', 'Finished 0', finished('/repo/app', 'npm run dev', 0), 'Successful exit'), + caseState('activity-finished-nonzero', 'Finished nonzero', finished('/repo/app', 'pnpm test', 1), 'Failing exit'), + caseState('activity-finished-missing', 'Finished missing code', finished('/repo/app', 'cargo test'), 'No exit code reported'), + caseState('activity-next-prompt', 'Next prompt', idle({ cwd: manual('/repo/app'), activity: { kind: 'editing' }, title: terminalTitle('zsh', 'osc0') }), 'Finished state cleared by prompt'), +]); + +export const CommandSnapshotBehavior: Story = storyFor([ + caseState('snapshot-running', 'Running snapshot', running('/repo/app', 'pnpm test --watch'), 'Header disambiguates with command start CWD'), + caseState('snapshot-cwd-changed', 'CWD changed while running', running('/repo/app', 'pnpm test --watch', { currentCwd: '/repo/other' }), 'Still groups by /repo/app'), + caseState('snapshot-finished', 'Finished retains start CWD', finished('/repo/app', 'pnpm test --watch', 0, { currentCwd: '/repo/other' }), 'Freshly finished command keeps start CWD'), + caseState('snapshot-osc633', 'OSC 633 explicit command', running('/repo/app', 'pnpm test --watch', { source: 'osc633_E' }), 'Command line from OSC 633;E'), + caseState('snapshot-osc133-title', 'OSC 133 title fallback', running('/repo/app', null, { source: 'osc133_boundaries', title: terminalTitle('zsh', 'osc0') }), 'Boundaries without command line'), +]); + +export const TitleFallbacksAndPinnedTitles: Story = storyFor([ + caseState('title-user', 'User-pinned title', running('/repo/app', 'npm run dev', { title: terminalTitle('Production API', 'user') }), 'Pinned title overrides command and CWD'), + caseState('title-command-over-title', 'Command over title', running('/repo/app', 'npm run dev', { title: terminalTitle('zsh', 'osc0') }), 'Running command beats terminal title'), + caseState('title-osc0', 'OSC 0 unknown command', running('/repo/app', null, { title: terminalTitle('zsh', 'osc0') }), 'Terminal title fallback for unknown active command'), + caseState('title-osc2', 'OSC 2 unknown command', running('/repo/app', null, { title: terminalTitle('vim', 'osc2') }), 'Terminal title fallback for unknown active command'), + caseState('title-idle-fallback', 'Idle fallback', idle({ activity: { kind: 'editing' } }), 'No foreground command'), + caseState('title-long-user', 'Long user title', idle({ cwd: manual('/repo/app'), title: terminalTitle('my-extremely-long-running-background-process-with-a-very-descriptive-name', 'user') }), 'Truncates before controls'), +]); + +export const GroupingKeys: Story = storyFor([ + caseState('group-app-dev', 'App dev server', running('/repo/app', 'npm run dev'), 'Directory: app, command: npm run dev, status: running'), + caseState('group-api-dev', 'API dev server', running('/repo/api', 'npm run dev'), 'Directory: api, command: npm run dev, status: running'), + caseState('group-app-test', 'App test', running('/repo/app', 'pnpm test --watch'), 'Shares directory with app dev'), + caseState('group-api-idle', 'API idle', idle({ cwd: manual('/repo/api'), activity: { kind: 'editing' } }), 'Idle directory grouping uses pane CWD'), + caseState('group-docs-finished', 'Docs finished', finished('/repo/docs', 'cargo test', 0), 'Finished status group'), + caseState('group-unknown', 'Unknown', idle({ activity: { kind: 'unknown' } }), 'Unknown status group'), +], { showGroups: true }); + +function ShellCwdMatrix({ + cases, + showGroups = false, +}: { + cases: ShellCwdCase[]; + showGroups?: boolean; +}) { + const states = cases.map((item) => item.state); + + return ( + <div className="min-h-screen bg-app-bg p-4 font-mono text-app-fg"> + <div className="grid gap-2"> + {cases.map((item) => { + const header = deriveHeader(item.state, states); + return ( + <section + key={item.id} + className="grid items-center gap-3 border-b border-border/60 pb-2" + style={{ gridTemplateColumns: '220px 120px 1fr 320px' }} + > + <div className="min-w-0"> + <div className="truncate text-sm font-semibold">{item.label}</div> + {item.note && <div className="truncate text-xs text-muted">{item.note}</div>} + </div> + <DerivedBadge header={header} /> + <div style={{ width: HEADER_WIDTH }}> + <HeaderPreview id={item.id} title={item.fallbackTitle ?? UNNAMED_PANEL_TITLE} /> + </div> + <div style={{ width: DOOR_WIDTH }}> + <BaseboardPreview id={item.id} title={item.fallbackTitle ?? UNNAMED_PANEL_TITLE} /> + </div> + </section> + ); + })} + </div> + {showGroups && <GroupingPreview cases={cases} />} + </div> + ); +} + +function HeaderPreview({ id, title }: { id: string; title: string }) { + const mockApi = { id, title } as any; + return ( + <ModeContext.Provider value="command"> + <SelectedIdContext.Provider value={id}> + <WallActionsContext.Provider value={noopActions}> + <RenamingIdContext.Provider value={null}> + <div className="h-[26px] bg-app-bg"> + <TerminalPaneHeader api={mockApi} containerApi={{} as any} params={{}} tabLocation={'header' as any} /> + </div> + </RenamingIdContext.Provider> + </WallActionsContext.Provider> + </SelectedIdContext.Provider> + </ModeContext.Provider> + ); +} + +function BaseboardPreview({ id, title }: { id: string; title: string }) { + return ( + <Baseboard + items={[makeDoorItem(id, title)]} + onReattach={() => {}} + /> + ); +} + +function DerivedBadge({ header }: { header: ReturnType<typeof deriveHeader> }) { + return ( + <div className="min-w-0 text-xs text-muted"> + <div className="truncate">{header.status}{header.exitCode !== undefined ? ` ${header.exitCode}` : ''}</div> + <div className="truncate">{header.secondary ?? 'no secondary'}</div> + </div> + ); +} + +function GroupingPreview({ cases }: { cases: ShellCwdCase[] }) { + const panes = cases.map((item) => item.state); + return ( + <div className="mt-5 grid grid-cols-3 gap-3 text-sm"> + {(['directory', 'command', 'status'] as const).map((mode) => ( + <section key={mode} className="border border-border bg-surface-raised/40 p-3"> + <h3 className="mb-2 text-sm font-semibold capitalize">{mode}</h3> + <div className="grid gap-1"> + {groupTerminalPanes(panes, mode).map((group) => ( + <div key={`${mode}-${group.key}`} className="min-w-0"> + <span className="font-semibold">{group.label}</span> + <span className="text-muted">: {casesForGroup(cases, group.panes).join(', ')}</span> + </div> + ))} + </div> + </section> + ))} + </div> + ); +} + +function casesForGroup(cases: ShellCwdCase[], panes: TerminalPaneState[]): string[] { + return cases.filter((item) => panes.includes(item.state)).map((item) => item.label); +} + +function storyFor(cases: ShellCwdCase[], extraArgs: { showGroups?: boolean } = {}): Story { + return { + args: { + cases, + ...extraArgs, + }, + parameters: { + primedTerminalState: { + byId: Object.fromEntries(cases.map((item) => [item.id, item.state])), + }, + }, + }; +} + +function caseState(id: string, label: string, state: TerminalPaneState, note?: string): ShellCwdCase { + return { id, label, state, note }; +} + +function idle({ + cwd = null, + activity = { kind: 'unknown' }, + title = null, +}: { + cwd?: CwdState | null; + activity?: ShellActivity; + title?: TerminalTitle | null; +} = {}): TerminalPaneState { + return createTerminalPaneState({ cwd, activity, title }); +} + +function running( + startCwdPath: string | null, + rawCommandLine: string | null, + options: { + currentCwd?: string; + host?: string; + source?: CommandRun['source']; + title?: TerminalTitle | null; + } = {}, +): TerminalPaneState { + const cwdAtStart = startCwdPath ? cwd(startCwdPath, options.host) : null; + const currentCwd = options.currentCwd ? manual(options.currentCwd) : cwdAtStart; + const displayCommand = rawCommandLine ?? options.title?.title ?? 'shell'; + return createTerminalPaneState({ + cwd: currentCwd, + activity: { kind: 'running' }, + title: options.title ?? null, + currentCommand: commandRun({ + id: `cmd-${displayCommand}-${startCwdPath ?? 'unknown'}`, + rawCommandLine, + displayCommand, + cwdAtStart, + source: options.source ?? (rawCommandLine ? 'osc633_E' : 'osc133_boundaries'), + }), + }); +} + +function finished( + startCwdPath: string, + rawCommandLine: string, + exitCode?: number, + options: { currentCwd?: string } = {}, +): TerminalPaneState { + const cwdAtStart = manual(startCwdPath); + const currentCwd = options.currentCwd ? manual(options.currentCwd) : cwdAtStart; + return createTerminalPaneState({ + cwd: currentCwd, + activity: exitCode === undefined ? { kind: 'finished' } : { kind: 'finished', exitCode }, + lastCommand: commandRun({ + id: `cmd-finished-${rawCommandLine}-${startCwdPath}`, + rawCommandLine, + displayCommand: rawCommandLine, + cwdAtStart, + source: 'osc633_E', + finishedAt: BASE_TIME + 5_000, + exitCode, + }), + }); +} + +function commandRun({ + id, + rawCommandLine, + displayCommand, + cwdAtStart, + source, + finishedAt, + exitCode, +}: { + id: string; + rawCommandLine: string | null; + displayCommand: string; + cwdAtStart: CwdState | null; + source: CommandRun['source']; + finishedAt?: number; + exitCode?: number; +}): CommandRun { + return { + id, + rawCommandLine, + displayCommand, + cwdAtStart, + startedAt: BASE_TIME, + source, + ...(finishedAt === undefined ? {} : { finishedAt }), + ...(exitCode === undefined ? {} : { exitCode }), + }; +} + +function terminalTitle(title: string, source: TerminalTitle['source']): TerminalTitle { + return { title, source, updatedAt: BASE_TIME }; +} + +function makeDoorItem(id: string, title: string): DooredItem { + return { + id, + title, + neighborId: null, + direction: 'right', + remainingPaneIds: [], + layoutAtMinimize: null, + layoutAtMinimizeSignature: '', + }; +} + +function cwd(path: string, host?: string): CwdState { + return { + path, + host, + scheme: 'file', + pathKind: path.includes(':') ? 'windows' : 'posix', + isRemote: !!host && host !== 'localhost', + source: 'manual', + updatedAt: BASE_TIME, + }; +} + +function manual(path: string): CwdState { + return cwdFromManualPath(path, BASE_TIME)!; +} + +function processCwd(path: string): CwdState { + return cwdFromProcessPath(path, BASE_TIME)!; +} + +function osc7(uri: string): CwdState { + return cwdFromOsc7(uri, BASE_TIME)!; +} + +function osc99(path: string): CwdState { + return cwdFromOsc9_9(path, BASE_TIME)!; +} + +function osc633(path: string): CwdState { + return cwdFromOsc633(path, BASE_TIME)!; +} + +function osc1337(path: string): CwdState { + return cwdFromOsc1337(path, BASE_TIME)!; +} From fff09d18b884b61b2528aa203385d935759b9c90 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Fri, 8 May 2026 11:02:11 -0700 Subject: [PATCH 04/50] Respect app-sent terminal title overrides --- docs/specs/iTerm2.md | 2 + docs/specs/layout.md | 2 +- docs/specs/terminal-state.md | 9 ++-- lib/src/components/Baseboard.tsx | 12 +++-- lib/src/components/Wall.tsx | 6 ++- .../components/wall/TerminalPaneHeader.tsx | 8 +++- lib/src/lib/terminal-protocol.test.ts | 10 ++++- lib/src/lib/terminal-protocol.ts | 17 ++++++- lib/src/lib/terminal-registry.ts | 2 + lib/src/lib/terminal-state.test.ts | 43 ++++++++++++++++++ lib/src/lib/terminal-state.ts | 44 ++++++++++++++++++- lib/src/stories/ShellCwd.stories.tsx | 9 +++- 12 files changed, 148 insertions(+), 16 deletions(-) diff --git a/docs/specs/iTerm2.md b/docs/specs/iTerm2.md index c34edb8..ef5fe5c 100644 --- a/docs/specs/iTerm2.md +++ b/docs/specs/iTerm2.md @@ -16,6 +16,8 @@ Notification sequences and standalone terminal bells are explicit application re Progress sequences do not ring immediately. They "cock" the alarm bell: MouseTerm treats active progress as an explicit finite-work cycle, exposes `OSC_NOTIF_BUSY`, and rings when the progress cycle completes or enters an error state. +Legacy `OSC 9` message text also participates in pane header/door title derivation as an app-sent title override, even when the alert ring itself is suppressed because the Session has attention. Rich notification titles from `OSC 99` and `OSC 777` are not tab/door title overrides; they stay in the TODO notification UI. + ## Non-goals - No native OS notifications, browser notifications, or sound in this phase. "Alarm" means MouseTerm's existing `ALERT_RINGING` visual state. diff --git a/docs/specs/layout.md b/docs/specs/layout.md index fc872c3..d0bdda2 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -72,7 +72,7 @@ The content area is a tiling layout of panes, powered by dockview. Each pane occ Each pane has a 30px header that doubles as a drag handle. The header uses `cursor-grab` / `active:cursor-grabbing`, `select-none`, and the shared terminal top radius from `lib/src/components/design.tsx`. Background and foreground use the `--color-header-active-*` / `--color-header-inactive-*` token pairs, which map to VSCode file-tree list colors. Dockview's default close button and right-actions container are hidden via CSS. -The header label is derived from `TerminalPaneState`: user-pinned title first, then current/freshly-finished command title, then `<idle>` for idle panes. Terminal titles remain a fallback for unknown active commands, not the default idle label. When visible panes would have duplicate primary labels, the header adds a compact directory disambiguator using the running command's `cwdAtStart` or the idle pane's latest `cwd`. +The header label is derived from `TerminalPaneState` plus scoped protocol notification state: user-pinned title first, then app-sent title overrides, then current/freshly-finished command title, then `<idle>` for idle panes. App-sent overrides include legacy OSC 9 message text and OSC 0/2 terminal titles emitted after the current command started. Rich notification titles from OSC 99/777 stay in TODO notification UI rather than replacing the tab/door label. Older shell titles remain fallback-only and do not replace the default idle label. When visible panes would have duplicate primary labels, the header adds a compact directory disambiguator using the running command's `cwdAtStart` or the idle pane's latest `cwd`. Elements from left to right: diff --git a/docs/specs/terminal-state.md b/docs/specs/terminal-state.md index 1301d4b..16489c2 100644 --- a/docs/specs/terminal-state.md +++ b/docs/specs/terminal-state.md @@ -11,7 +11,7 @@ MouseTerm models terminal panes by: - whether the shell is at a prompt, editing, running a foreground command, or waiting after command finish - command exit status - command directory at start time -- terminal title as a fallback label for unknown active commands +- app-sent terminal or notification title as an override label Session CWD and command execution state are separate. `cwd` means "the shell/session reported this directory"; it is not necessarily the internal CWD of a foreground program. A command snapshots `cwdAtStart` when it starts, and that snapshot is used for grouping and header disambiguation while the command is running or freshly finished. @@ -80,12 +80,12 @@ type CommandRun = { ```ts type TerminalTitle = { title: string; - source: "osc0" | "osc2" | "user" | "profile" | "derived"; + source: "osc0" | "osc2" | "notification" | "user" | "profile" | "derived"; updatedAt: number; }; ``` -Terminal title is separate from command state. It is useful as a fallback label, but it is not a command lifecycle signal. +Terminal title is separate from command state. It is useful as an app-sent label override, but it is not a command lifecycle signal. ## Normalized Events @@ -172,7 +172,8 @@ type DerivedHeader = { Rules: - A user-pinned title is primary. -- A running command uses `currentCommand.displayCommand`. +- An app-sent title override is primary after user-pinned titles. This includes legacy `OSC 9` message text and OSC 0/2 terminal titles sent after the current command started. Rich notification titles from `OSC 99` and `OSC 777` stay in TODO notification UI and do not become header/door labels. +- A running command uses `currentCommand.displayCommand` when there is no app-sent title override. - A freshly finished command uses `lastCommand.displayCommand` until the next prompt signal. - Idle terminals use `<idle>` unless a user-pinned title exists. - Unknown active commands may use terminal title or shell fallback. diff --git a/lib/src/components/Baseboard.tsx b/lib/src/components/Baseboard.tsx index a9f1076..a8eada3 100644 --- a/lib/src/components/Baseboard.tsx +++ b/lib/src/components/Baseboard.tsx @@ -5,6 +5,7 @@ import { DoorElementsContext } from './wall/wall-context'; import type { DooredItem } from './wall/wall-types'; import { IS_MAC } from '../lib/platform'; import { + buildAppTitleResolver, DEFAULT_ACTIVITY_STATE, deriveHeader, getActivitySnapshot, @@ -26,6 +27,10 @@ export function Baseboard({ items, onReattach, notice }: BaseboardProps) { const activityStates = useSyncExternalStore(subscribeToActivity, getActivitySnapshot); const terminalStates = useSyncExternalStore(subscribeToTerminalPaneState, getTerminalPaneStateSnapshot); const allPaneStates = useMemo(() => [...terminalStates.values()], [terminalStates]); + const appTitleForPane = useMemo( + () => buildAppTitleResolver(terminalStates, activityStates), + [terminalStates, activityStates], + ); const containerRef = useRef<HTMLDivElement>(null); const [containerWidth, setContainerWidth] = useState(0); const [startIndex, setStartIndex] = useState(0); @@ -151,7 +156,7 @@ export function Baseboard({ items, onReattach, notice }: BaseboardProps) { <div ref={measureEl} className="absolute -left-[9999px] flex gap-1.5" aria-hidden> {items.map(item => { const activity = activityStates.get(item.id) ?? DEFAULT_ACTIVITY_STATE; - const title = deriveDoorTitle(item.title, item.id, terminalStates, allPaneStates); + const title = deriveDoorTitle(item.title, item.id, terminalStates, allPaneStates, appTitleForPane); return ( <Door key={item.id} @@ -185,7 +190,7 @@ export function Baseboard({ items, onReattach, notice }: BaseboardProps) { {items.slice(startIndex, endIndex).map(item => { const activity = activityStates.get(item.id) ?? DEFAULT_ACTIVITY_STATE; - const title = deriveDoorTitle(item.title, item.id, terminalStates, allPaneStates); + const title = deriveDoorTitle(item.title, item.id, terminalStates, allPaneStates, appTitleForPane); return ( <Door key={item.id} @@ -218,8 +223,9 @@ function deriveDoorTitle( id: string, terminalStates: Map<string, TerminalPaneState>, allPaneStates: TerminalPaneState[], + appTitleForPane: (pane: TerminalPaneState) => string | null | undefined, ): string { const paneState = terminalStates.get(id) ?? createTerminalPaneState(); const visible = allPaneStates.length > 0 ? allPaneStates : [paneState]; - return resolveDisplayPrimary(deriveHeader(paneState, visible).primary, savedTitle); + return resolveDisplayPrimary(deriveHeader(paneState, visible, { appTitleForPane }).primary, savedTitle); } diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 96147c4..2dbe837 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -15,7 +15,9 @@ import { markSessionAttention, toggleSessionTodo, setPendingShellOpts, + buildAppTitleResolver, getDefaultShellOpts, + getActivitySnapshot, getTerminalPaneState, getTerminalPaneStateSnapshot, deriveHeader, @@ -266,9 +268,11 @@ export function Wall({ if (!api) return; const panel = api.getPanel(id); if (!panel) return; + const terminalStateSnapshot = getTerminalPaneStateSnapshot(); const derivedTitle = deriveHeader( getTerminalPaneState(id), - [...getTerminalPaneStateSnapshot().values()], + [...terminalStateSnapshot.values()], + { appTitleForPane: buildAppTitleResolver(terminalStateSnapshot, getActivitySnapshot()) }, ).primary; const title = resolveDisplayPrimary(derivedTitle, panel.title ?? id); const layoutAtMinimize = cloneLayout(api.toJSON()); diff --git a/lib/src/components/wall/TerminalPaneHeader.tsx b/lib/src/components/wall/TerminalPaneHeader.tsx index 314681a..1352b9f 100644 --- a/lib/src/components/wall/TerminalPaneHeader.tsx +++ b/lib/src/components/wall/TerminalPaneHeader.tsx @@ -34,7 +34,7 @@ import { subscribeToTerminalPaneState, type SessionStatus, } from '../../lib/terminal-registry'; -import { createTerminalPaneState, deriveHeader, resolveDisplayPrimary } from '../../lib/terminal-state'; +import { buildAppTitleResolver, createTerminalPaneState, deriveHeader, resolveDisplayPrimary } from '../../lib/terminal-state'; import { DialogKeyboardContext, ModeContext, @@ -85,7 +85,11 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { const paneState = terminalStates.get(api.id) ?? createTerminalPaneState(); const allPaneStates = useMemo(() => [...terminalStates.values()], [terminalStates]); const visiblePaneStates = allPaneStates.length > 0 ? allPaneStates : [paneState]; - const derivedHeader = deriveHeader(paneState, visiblePaneStates); + const appTitleForPane = useMemo( + () => buildAppTitleResolver(terminalStates, activityStates), + [terminalStates, activityStates], + ); + const derivedHeader = deriveHeader(paneState, visiblePaneStates, { appTitleForPane }); const displayTitle = resolveDisplayPrimary(derivedHeader.primary, api.title); const mouseState = mouseStates.get(api.id) ?? DEFAULT_MOUSE_SELECTION_STATE; const showMouseIcon = mouseState.mouseReporting !== 'none'; diff --git a/lib/src/lib/terminal-protocol.test.ts b/lib/src/lib/terminal-protocol.test.ts index ccb9759..4b52e5a 100644 --- a/lib/src/lib/terminal-protocol.test.ts +++ b/lib/src/lib/terminal-protocol.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { ITERM2_DEVICE_ATTRIBUTES_RESPONSE, TerminalProtocolParser } from './terminal-protocol'; +import { collectTerminalSemanticEvents, ITERM2_DEVICE_ATTRIBUTES_RESPONSE, TerminalProtocolParser } from './terminal-protocol'; describe('TerminalProtocolParser', () => { it('parses and strips standalone terminal bells', () => { @@ -30,6 +30,12 @@ describe('TerminalProtocolParser', () => { expect(result.events).toEqual([ { kind: 'notification', notification: { source: 'OSC 9', title: null, body: 'Build finished' } }, ]); + expect(collectTerminalSemanticEvents(result.events)).toEqual([ + { + type: 'title', + title: { title: 'Build finished', source: 'notification', updatedAt: expect.any(Number) }, + }, + ]); }); it('does not add terminal bell detail for the BEL terminator of a supported OSC notification', () => { @@ -85,6 +91,7 @@ describe('TerminalProtocolParser', () => { expect(result.events).toEqual([ { kind: 'notification', notification: { source: 'OSC 777', title: 'Title', body: 'one;two;three' } }, ]); + expect(collectTerminalSemanticEvents(result.events)).toEqual([]); }); it('assembles OSC 99 title and body chunks', () => { @@ -100,6 +107,7 @@ describe('TerminalProtocolParser', () => { notification: { source: 'OSC 99', title: 'Build', body: 'Finished successfully' }, }, ]); + expect(collectTerminalSemanticEvents(result.events)).toEqual([]); }); it('responds to OSC 99 support queries with title and body support', () => { diff --git a/lib/src/lib/terminal-protocol.ts b/lib/src/lib/terminal-protocol.ts index ebef54d..92b7564 100644 --- a/lib/src/lib/terminal-protocol.ts +++ b/lib/src/lib/terminal-protocol.ts @@ -4,6 +4,7 @@ import { cwdFromOsc633, cwdFromOsc7, cwdFromOsc9_9, + notificationDisplayTitle, type CommandRunSource, type TerminalSemanticEvent, type TerminalTitle, @@ -230,7 +231,21 @@ export function collectTerminalProtocolResponses(events: TerminalProtocolEvent[] } export function collectTerminalSemanticEvents(events: TerminalProtocolEvent[]): TerminalSemanticEvent[] { - return events.flatMap((event) => (event.kind === 'semantic' ? [event.event] : [])); + const semanticEvents: TerminalSemanticEvent[] = []; + for (const event of events) { + if (event.kind === 'semantic') { + semanticEvents.push(event.event); + continue; + } + if (event.kind !== 'notification') continue; + const title = notificationDisplayTitle(event.notification); + if (!title) continue; + semanticEvents.push({ + type: 'title', + title: { title, source: 'notification', updatedAt: Date.now() }, + }); + } + return semanticEvents; } function stripStandaloneBells(segment: string, events: TerminalProtocolEvent[]): string { diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index 05b515e..0558f5c 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -75,11 +75,13 @@ export { cwdFromOsc9_9, cwdFromProcessPath, cwdIdentity, + buildAppTitleResolver, DEFAULT_COMMAND_TITLE, DEFAULT_IDLE_TITLE, deriveFallbackCommandTitle, deriveHeader, groupTerminalPanes, + notificationDisplayTitle, reduceTerminalState, resolveDisplayPrimary, shortestUniqueCwdLabels, diff --git a/lib/src/lib/terminal-state.test.ts b/lib/src/lib/terminal-state.test.ts index 9c2ef26..72d0d12 100644 --- a/lib/src/lib/terminal-state.test.ts +++ b/lib/src/lib/terminal-state.test.ts @@ -8,7 +8,9 @@ import { cwdIdentity, DEFAULT_IDLE_TITLE, deriveHeader, + buildAppTitleResolver, groupTerminalPanes, + notificationDisplayTitle, reduceTerminalState, shortestUniqueCwdLabels, summarizeCommandLine, @@ -231,6 +233,47 @@ describe('header and grouping derivation', () => { }); }); + it('lets fresh app-sent terminal titles override running command labels', () => { + const pane = runningPane('/repo/app', 'lazygit'); + pane.title = { title: 'lazygit: mouseterm', source: 'osc0', updatedAt: 2 }; + + expect(deriveHeader(pane, [pane])).toEqual({ + primary: 'lazygit: mouseterm', + status: 'running', + }); + }); + + it('ignores stale shell titles from before a command started', () => { + const pane = runningPane('/repo/app', 'lazygit'); + pane.title = { title: 'zsh', source: 'osc0', updatedAt: 0 }; + + expect(deriveHeader(pane, [pane])).toEqual({ + primary: 'lazygit', + status: 'running', + }); + }); + + it('lets legacy OSC 9 message text override derived command labels', () => { + const pane = runningPane('/repo/app', 'npm run build'); + const terminalStates = new Map([['pane', pane]]); + const activityStates = new Map([ + ['pane', { notification: { source: 'OSC 9', title: null, body: 'Build finished' } }], + ]); + + expect(notificationDisplayTitle(activityStates.get('pane')?.notification)).toBe('Build finished'); + expect(deriveHeader(pane, [pane], { + appTitleForPane: buildAppTitleResolver(terminalStates, activityStates), + })).toEqual({ + primary: 'Build finished', + status: 'running', + }); + }); + + it('does not use rich notification titles as tab title overrides', () => { + expect(notificationDisplayTitle({ source: 'OSC 777', title: 'Tests', body: '341 passed' })).toBeNull(); + expect(notificationDisplayTitle({ source: 'OSC 99', title: 'Build', body: 'Finished successfully' })).toBeNull(); + }); + it('preserves remote identity when two panes have the same path', () => { const local = runningPane('/home/me/app', 'npm run dev', 'localhost'); const remote = runningPane('/home/me/app', 'npm run dev', 'prod-box'); diff --git a/lib/src/lib/terminal-state.ts b/lib/src/lib/terminal-state.ts index 9f192c2..3784622 100644 --- a/lib/src/lib/terminal-state.ts +++ b/lib/src/lib/terminal-state.ts @@ -42,7 +42,7 @@ export interface CommandRun { }; } -export type TerminalTitleSource = 'osc0' | 'osc2' | 'user' | 'profile' | 'derived'; +export type TerminalTitleSource = 'osc0' | 'osc2' | 'notification' | 'user' | 'profile' | 'derived'; export interface TerminalTitle { title: string; @@ -77,6 +77,7 @@ export interface DirectoryDisplayOptions { export interface HeaderOptions extends DirectoryDisplayOptions { shellName?: string; + appTitleForPane?: (pane: TerminalPaneState) => string | null | undefined; } export interface DerivedHeader { @@ -94,6 +95,12 @@ export interface TerminalGroup { panes: TerminalPaneState[]; } +export interface TerminalNotificationTitleLike { + source?: string; + title?: string | null; + body?: string | null; +} + export const DEFAULT_TERMINAL_PANE_STATE: TerminalPaneState = Object.freeze({ cwd: null, activity: Object.freeze({ kind: 'unknown' } as ShellActivity), @@ -370,6 +377,28 @@ export function deriveHeader( : { primary, secondary, status, exitCode }; } +export function notificationDisplayTitle( + notification: TerminalNotificationTitleLike | null | undefined, +): string | null { + if (notification?.source === 'OSC 9') { + const body = notification.body?.trim(); + if (body) return body; + } + return null; +} + +export function buildAppTitleResolver( + terminalStates: Map<string, TerminalPaneState>, + activityStates: Map<string, { notification?: TerminalNotificationTitleLike | null }>, +): (pane: TerminalPaneState) => string | null { + const titlesByPane = new WeakMap<TerminalPaneState, string>(); + for (const [id, pane] of terminalStates) { + const title = notificationDisplayTitle(activityStates.get(id)?.notification); + if (title) titlesByPane.set(pane, title); + } + return (pane) => titlesByPane.get(pane) ?? null; +} + export function groupTerminalPanes( panes: TerminalPaneState[], mode: TerminalGroupingMode, @@ -651,6 +680,10 @@ function truncateCommandTitle(title: string): string { function headerPrimary(pane: TerminalPaneState, options: HeaderOptions): string { if (pane.title?.source === 'user') return pane.title.title; + const appTitle = options.appTitleForPane?.(pane)?.trim(); + if (appTitle) return appTitle; + const terminalTitle = activeTerminalTitle(pane); + if (terminalTitle) return terminalTitle; if (pane.currentCommand) return pane.currentCommand.displayCommand; if (pane.activity.kind === 'finished' && pane.lastCommand) return pane.lastCommand.displayCommand; return idleLabel(pane, options); @@ -661,6 +694,15 @@ function idleLabel(pane: TerminalPaneState, _options: { shellName?: string } = { return DEFAULT_IDLE_TITLE; } +function activeTerminalTitle(pane: TerminalPaneState): string | null { + if (!pane.title || pane.title.source === 'user') return null; + const title = pane.title.title.trim(); + if (!title) return null; + const command = pane.currentCommand ?? (pane.activity.kind === 'finished' ? pane.lastCommand : null); + if (!command || pane.title.updatedAt < command.startedAt) return null; + return title; +} + function headerStatus(pane: TerminalPaneState): DerivedHeader['status'] { switch (pane.activity.kind) { case 'running': diff --git a/lib/src/stories/ShellCwd.stories.tsx b/lib/src/stories/ShellCwd.stories.tsx index 2157514..ec8fba0 100644 --- a/lib/src/stories/ShellCwd.stories.tsx +++ b/lib/src/stories/ShellCwd.stories.tsx @@ -111,7 +111,8 @@ export const CommandSnapshotBehavior: Story = storyFor([ export const TitleFallbacksAndPinnedTitles: Story = storyFor([ caseState('title-user', 'User-pinned title', running('/repo/app', 'npm run dev', { title: terminalTitle('Production API', 'user') }), 'Pinned title overrides command and CWD'), - caseState('title-command-over-title', 'Command over title', running('/repo/app', 'npm run dev', { title: terminalTitle('zsh', 'osc0') }), 'Running command beats terminal title'), + caseState('title-app-over-command', 'App title over command', running('/repo/app', 'npm run dev', { title: terminalTitle('dev server: ready', 'osc0') }), 'Fresh app title beats command'), + caseState('title-stale-shell', 'Stale shell title', running('/repo/app', 'npm run dev', { title: terminalTitleAt('zsh', 'osc0', BASE_TIME - 1) }), 'Pre-command shell title does not beat command'), caseState('title-osc0', 'OSC 0 unknown command', running('/repo/app', null, { title: terminalTitle('zsh', 'osc0') }), 'Terminal title fallback for unknown active command'), caseState('title-osc2', 'OSC 2 unknown command', running('/repo/app', null, { title: terminalTitle('vim', 'osc2') }), 'Terminal title fallback for unknown active command'), caseState('title-idle-fallback', 'Idle fallback', idle({ activity: { kind: 'editing' } }), 'No foreground command'), @@ -337,7 +338,11 @@ function commandRun({ } function terminalTitle(title: string, source: TerminalTitle['source']): TerminalTitle { - return { title, source, updatedAt: BASE_TIME }; + return terminalTitleAt(title, source, BASE_TIME); +} + +function terminalTitleAt(title: string, source: TerminalTitle['source'], updatedAt: number): TerminalTitle { + return { title, source, updatedAt }; } function makeDoorItem(id: string, title: string): DooredItem { From 3f4d9bc4082dd3b02e061801671202f788d87b38 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Fri, 8 May 2026 13:00:57 -0700 Subject: [PATCH 05/50] Add terminal title candidate diagnostics --- docs/specs/iTerm2.md | 8 +- docs/specs/layout.md | 4 +- docs/specs/terminal-state.md | 25 ++- .../components/wall/TerminalPaneHeader.tsx | 135 +++++++++++++++- lib/src/lib/terminal-protocol.test.ts | 16 +- lib/src/lib/terminal-protocol.ts | 6 +- lib/src/lib/terminal-registry.ts | 4 + lib/src/lib/terminal-state.test.ts | 60 ++++++- lib/src/lib/terminal-state.ts | 152 ++++++++++++++++-- lib/src/stories/ShellCwd.stories.tsx | 49 ++++++ 10 files changed, 429 insertions(+), 30 deletions(-) diff --git a/docs/specs/iTerm2.md b/docs/specs/iTerm2.md index ef5fe5c..3241e38 100644 --- a/docs/specs/iTerm2.md +++ b/docs/specs/iTerm2.md @@ -16,7 +16,7 @@ Notification sequences and standalone terminal bells are explicit application re Progress sequences do not ring immediately. They "cock" the alarm bell: MouseTerm treats active progress as an explicit finite-work cycle, exposes `OSC_NOTIF_BUSY`, and rings when the progress cycle completes or enters an error state. -Legacy `OSC 9` message text also participates in pane header/door title derivation as an app-sent title override, even when the alert ring itself is suppressed because the Session has attention. Rich notification titles from `OSC 99` and `OSC 777` are not tab/door title overrides; they stay in the TODO notification UI. +Legacy `OSC 9` message text also participates in pane header/door title derivation as an app-sent title override, even when the alert ring itself is suppressed because the Session has attention. Rich notification titles from `OSC 99` and `OSC 777` are stored as title candidates for the header diagnostic popup, but they are not tab/door title overrides; they stay in the TODO notification UI. ## Non-goals @@ -209,6 +209,12 @@ Mapping rules: - `OSC 9;4` stores nothing while progress is active. On completion/error it generates `{ source: 'OSC 9;4', title, body }`, where `title` is a short summary such as `Progress complete`, `Progress error`, or `Progress warning`, and `body` contains the percent when available. - Standalone `BEL` stores `{ source: 'BEL', title: 'Terminal bell', body: null }`. +Title candidate side effects: + +- `OSC 9` also records `titleCandidates.osc9 = message` and may override the pane header/door label. +- `OSC 777` also records `titleCandidates.osc777 = title` for diagnostics only. +- `OSC 99` also records `titleCandidates.osc99 = title` for diagnostics only. + Persistence: - Persist the latest `ActivityNotification` with the Session's alert state. diff --git a/docs/specs/layout.md b/docs/specs/layout.md index d0bdda2..adc5baf 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -74,9 +74,11 @@ Each pane has a 30px header that doubles as a drag handle. The header uses `curs The header label is derived from `TerminalPaneState` plus scoped protocol notification state: user-pinned title first, then app-sent title overrides, then current/freshly-finished command title, then `<idle>` for idle panes. App-sent overrides include legacy OSC 9 message text and OSC 0/2 terminal titles emitted after the current command started. Rich notification titles from OSC 99/777 stay in TODO notification UI rather than replacing the tab/door label. Older shell titles remain fallback-only and do not replace the default idle label. When visible panes would have duplicate primary labels, the header adds a compact directory disambiguator using the running command's `cwdAtStart` or the idle pane's latest `cwd`. +Right-clicking the derived session label opens a diagnostic popup listing the latest title candidate per channel, including user, OSC 0, OSC 2, OSC 9, OSC 99, and OSC 777 where present. Each row shows the channel, latest candidate text, and timestamp. The popup is diagnostic only; it does not change the title priority rules. + Elements from left to right: -- Derived session label (click to rename/pin, truncates with ellipsis) +- Derived session label (click to rename/pin, right-click to inspect title candidates, truncates with ellipsis) - Alert bell button (reflects session activity status) - TODO pill (if todo state is set; hidden in minimal tier) - Flexible gap diff --git a/docs/specs/terminal-state.md b/docs/specs/terminal-state.md index 16489c2..0390ccd 100644 --- a/docs/specs/terminal-state.md +++ b/docs/specs/terminal-state.md @@ -25,6 +25,7 @@ type TerminalPaneState = { currentCommand: CommandRun | null; lastCommand: CommandRun | null; title: TerminalTitle | null; + titleCandidates: Partial<Record<TerminalTitle["source"], TerminalTitle>>; }; ``` @@ -80,12 +81,21 @@ type CommandRun = { ```ts type TerminalTitle = { title: string; - source: "osc0" | "osc2" | "notification" | "user" | "profile" | "derived"; + source: + | "osc0" + | "osc2" + | "osc9" + | "osc99" + | "osc777" + | "notification" + | "user" + | "profile" + | "derived"; updatedAt: number; }; ``` -Terminal title is separate from command state. It is useful as an app-sent label override, but it is not a command lifecycle signal. +Terminal title is separate from command state. `title` is the latest title event for compatibility. `titleCandidates` stores the latest value for each candidate channel, with its own timestamp, so app, shell, and user title sources can be inspected independently. It is useful as an app-sent label override, but it is not a command lifecycle signal. ## Normalized Events @@ -133,6 +143,14 @@ Title fallback: | `OSC 0 ; <title> ST` | `title(source: "osc0")` | | `OSC 2 ; <title> ST` | `title(source: "osc2")` | +Title candidate diagnostics: + +| Sequence | Candidate source | Header/door override | +|---|---|---| +| `OSC 9 ; <message> ST` | `osc9` | Yes | +| `OSC 99 ; ... title/body ... ST` | `osc99` | No | +| `OSC 777 ; notify ; <title> ; <body> ST` | `osc777` | No | + The parser accepts both BEL and ST terminators and handles split chunks. Unsupported OSCs pass through to xterm unchanged; supported-but-malformed semantic OSCs are consumed without changing state. ## Reducer @@ -146,6 +164,7 @@ The parser accepts both BEL and ST terminators and handles split chunks. Unsuppo - User-entered prompt input may also store `pendingCommandLine` as an explicit fallback before an OSC 133/633 command-start boundary. This fallback is only used while the shell is idle/editing; foreground-program input is ignored. If the submitted line is non-empty, the input fallback may create a `currentCommand` immediately with `source: "user_input"` so shells without command-start integration still show the active command. - `commandStart` creates `currentCommand`, snapshots `cwdAtStart`, clears `pendingCommandLine`, and sets `{ kind: "running" }`. - `commandFinish` moves `currentCommand` to `lastCommand`, stores `finishedAt`/`exitCode`, clears `currentCommand`, and sets `{ kind: "finished", exitCode }`. +- `title` updates `title` and the per-source entry in `titleCandidates`. Later OSC title events do not erase earlier user, shell, or notification channel candidates from other sources. - A later prompt signal moves the pane out of `finished`. If a command was started from `user_input` and no explicit `commandFinish` arrived, the prompt signal also clears `currentCommand` so the header returns to `<idle>`. - For `user_input` fallback commands only, visible output that looks like a returned shell prompt may synthesize the same prompt transition. This is a scoped fallback for shells that do not emit command finish/start OSCs. @@ -172,7 +191,7 @@ type DerivedHeader = { Rules: - A user-pinned title is primary. -- An app-sent title override is primary after user-pinned titles. This includes legacy `OSC 9` message text and OSC 0/2 terminal titles sent after the current command started. Rich notification titles from `OSC 99` and `OSC 777` stay in TODO notification UI and do not become header/door labels. +- An app-sent title override is primary after user-pinned titles. This includes legacy `OSC 9` message text and OSC 0/2 terminal titles sent after the current command started. Rich notification titles from `OSC 99` and `OSC 777` are stored in `titleCandidates` for diagnostics and stay in TODO notification UI; they do not become header/door labels. - A running command uses `currentCommand.displayCommand` when there is no app-sent title override. - A freshly finished command uses `lastCommand.displayCommand` until the next prompt signal. - Idle terminals use `<idle>` unless a user-pinned title exists. diff --git a/lib/src/components/wall/TerminalPaneHeader.tsx b/lib/src/components/wall/TerminalPaneHeader.tsx index 1352b9f..b70c400 100644 --- a/lib/src/components/wall/TerminalPaneHeader.tsx +++ b/lib/src/components/wall/TerminalPaneHeader.tsx @@ -34,7 +34,15 @@ import { subscribeToTerminalPaneState, type SessionStatus, } from '../../lib/terminal-registry'; -import { buildAppTitleResolver, createTerminalPaneState, deriveHeader, resolveDisplayPrimary } from '../../lib/terminal-state'; +import { + buildAppTitleResolver, + createTerminalPaneState, + deriveHeader, + resolveDisplayPrimary, + titleCandidatesForDisplay, + titleSourceLabel, + type TerminalTitle, +} from '../../lib/terminal-state'; import { DialogKeyboardContext, ModeContext, @@ -69,6 +77,8 @@ const ALERT_BUTTON_LABELS: Record<SessionStatus, { aria: string; tooltip: string }; const TODO_PREVIEW_GAP = 6; const TODO_PREVIEW_MARGIN = 8; +const TITLE_CANDIDATES_GAP = 6; +const TITLE_CANDIDATES_MARGIN = 8; export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { const mode = useContext(ModeContext); @@ -108,7 +118,9 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { const [tier, setTier] = useState<HeaderTier>('full'); const [dialogTriggerRect, setDialogTriggerRect] = useState<DOMRect | null>(null); const [todoPreviewRect, setTodoPreviewRect] = useState<DOMRect | null>(null); + const [titleCandidatesRect, setTitleCandidatesRect] = useState<DOMRect | null>(null); const todoPill = useTodoPillContent(activity.todo); + const titleCandidates = useMemo(() => titleCandidatesForDisplay(paneState), [paneState]); const showTodoPill = todoPill.visible && tier !== 'minimal'; const alertButtonLabels = ALERT_BUTTON_LABELS[activity.status]; const alertButtonAriaLabel = alertButtonLabels.aria; @@ -121,6 +133,7 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { const closeDialog = useCallback(() => setDialogTriggerRect(null), []); const closeTodoPreview = useCallback(() => setTodoPreviewRect(null), []); + const closeTitleCandidates = useCallback(() => setTitleCandidatesRect(null), []); const openTodoPreview = useCallback((button: HTMLButtonElement) => { if (!activity.notification) return; setTodoPreviewRect(button.getBoundingClientRect()); @@ -150,6 +163,24 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { if (!activity.notification) setTodoPreviewRect(null); }, [activity.notification]); + useEffect(() => { + if (!titleCandidatesRect) return; + const close = () => setTitleCandidatesRect(null); + const closeOnKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') close(); + }; + window.addEventListener('pointerdown', close); + window.addEventListener('resize', close); + window.addEventListener('scroll', close, true); + window.addEventListener('keydown', closeOnKey); + return () => { + window.removeEventListener('pointerdown', close); + window.removeEventListener('resize', close); + window.removeEventListener('scroll', close, true); + window.removeEventListener('keydown', closeOnKey); + }; + }, [titleCandidatesRect]); + return ( <div ref={tabRef} @@ -176,9 +207,15 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { /> ) : ( <span + data-title-candidates-for={api.id} className="flex min-w-0 cursor-text items-baseline font-medium text-inherit decoration-current/50 underline-offset-2 hover:underline" onMouseDown={(e) => e.stopPropagation()} onClick={(e) => { e.stopPropagation(); actions.onStartRename(api.id); }} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + setTitleCandidatesRect(e.currentTarget.getBoundingClientRect()); + }} > <span className="min-w-0 truncate">{displayTitle}</span> {derivedHeader.secondary && ( @@ -327,10 +364,92 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { anchorRect={todoPreviewRect} /> )} + {titleCandidatesRect && !dialogTriggerRect && ( + <TitleCandidatesPopover + anchorRect={titleCandidatesRect} + candidates={titleCandidates} + currentTitle={displayTitle} + onClose={closeTitleCandidates} + /> + )} </div> ); } +function TitleCandidatesPopover({ + anchorRect, + candidates, + currentTitle, + onClose, +}: { + anchorRect: DOMRect; + candidates: TerminalTitle[]; + currentTitle: string; + onClose: () => void; +}) { + const ref = useRef<HTMLDivElement>(null); + const [style, setStyle] = useState<CSSProperties>({ + position: 'fixed', + left: anchorRect.left, + top: anchorRect.bottom + TITLE_CANDIDATES_GAP, + }); + + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + const top = anchorRect.bottom + TITLE_CANDIDATES_GAP; + const maxLeft = Math.max(TITLE_CANDIDATES_MARGIN, window.innerWidth - rect.width - TITLE_CANDIDATES_MARGIN); + setStyle({ + position: 'fixed', + left: Math.min(Math.max(anchorRect.left, TITLE_CANDIDATES_MARGIN), maxLeft), + top, + maxHeight: Math.max(80, window.innerHeight - top - TITLE_CANDIDATES_MARGIN), + }); + }, [anchorRect]); + + return createPortal( + <div + ref={ref} + role="dialog" + aria-label="Title candidates" + className="z-[1000] max-w-96 overflow-auto rounded border border-border bg-surface-raised px-2.5 py-2 font-mono text-sm leading-snug text-foreground shadow-md" + style={style} + onPointerDown={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onContextMenu={(e) => e.preventDefault()} + > + <div className="mb-2 flex items-center justify-between gap-3 border-b border-border pb-1.5"> + <div className="min-w-0 truncate font-medium">{currentTitle}</div> + <button + type="button" + className="shrink-0 rounded px-1 text-muted transition-colors hover:bg-current/10 hover:text-foreground" + aria-label="Close title candidates" + onClick={onClose} + > + <XIcon size={12} /> + </button> + </div> + {candidates.length === 0 ? ( + <div className="text-muted">No title candidates</div> + ) : ( + <div className="space-y-1.5"> + {candidates.map((candidate) => ( + <div key={candidate.source} className="grid grid-cols-[4.75rem_minmax(0,1fr)_auto] items-baseline gap-2"> + <span className="text-muted">{titleSourceLabel(candidate.source)}</span> + <span className="min-w-0 truncate" title={candidate.title}>{candidate.title}</span> + <time className="text-xs text-muted" dateTime={formatTitleCandidateDateTime(candidate.updatedAt)}> + {formatTitleCandidateTime(candidate.updatedAt)} + </time> + </div> + ))} + </div> + )} + </div>, + document.body, + ); +} + function TodoNotificationPreview({ id, notification, @@ -397,3 +516,17 @@ function formatNotificationPreview(notification: { title: string | null; body: s const preview = parts.join('\n'); return preview.length > 512 ? `${preview.slice(0, 509)}...` : preview; } + +function formatTitleCandidateTime(timestamp: number): string { + if (!Number.isFinite(timestamp)) return 'unknown'; + return new Date(timestamp).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} + +function formatTitleCandidateDateTime(timestamp: number): string | undefined { + if (!Number.isFinite(timestamp)) return undefined; + return new Date(timestamp).toISOString(); +} diff --git a/lib/src/lib/terminal-protocol.test.ts b/lib/src/lib/terminal-protocol.test.ts index 4b52e5a..2e3ea02 100644 --- a/lib/src/lib/terminal-protocol.test.ts +++ b/lib/src/lib/terminal-protocol.test.ts @@ -33,7 +33,7 @@ describe('TerminalProtocolParser', () => { expect(collectTerminalSemanticEvents(result.events)).toEqual([ { type: 'title', - title: { title: 'Build finished', source: 'notification', updatedAt: expect.any(Number) }, + title: { title: 'Build finished', source: 'osc9', updatedAt: expect.any(Number) }, }, ]); }); @@ -91,7 +91,12 @@ describe('TerminalProtocolParser', () => { expect(result.events).toEqual([ { kind: 'notification', notification: { source: 'OSC 777', title: 'Title', body: 'one;two;three' } }, ]); - expect(collectTerminalSemanticEvents(result.events)).toEqual([]); + expect(collectTerminalSemanticEvents(result.events)).toEqual([ + { + type: 'title', + title: { title: 'Title', source: 'osc777', updatedAt: expect.any(Number) }, + }, + ]); }); it('assembles OSC 99 title and body chunks', () => { @@ -107,7 +112,12 @@ describe('TerminalProtocolParser', () => { notification: { source: 'OSC 99', title: 'Build', body: 'Finished successfully' }, }, ]); - expect(collectTerminalSemanticEvents(result.events)).toEqual([]); + expect(collectTerminalSemanticEvents(result.events)).toEqual([ + { + type: 'title', + title: { title: 'Build', source: 'osc99', updatedAt: expect.any(Number) }, + }, + ]); }); it('responds to OSC 99 support queries with title and body support', () => { diff --git a/lib/src/lib/terminal-protocol.ts b/lib/src/lib/terminal-protocol.ts index 92b7564..dc39a72 100644 --- a/lib/src/lib/terminal-protocol.ts +++ b/lib/src/lib/terminal-protocol.ts @@ -4,7 +4,7 @@ import { cwdFromOsc633, cwdFromOsc7, cwdFromOsc9_9, - notificationDisplayTitle, + terminalTitleFromNotification, type CommandRunSource, type TerminalSemanticEvent, type TerminalTitle, @@ -238,11 +238,11 @@ export function collectTerminalSemanticEvents(events: TerminalProtocolEvent[]): continue; } if (event.kind !== 'notification') continue; - const title = notificationDisplayTitle(event.notification); + const title = terminalTitleFromNotification(event.notification); if (!title) continue; semanticEvents.push({ type: 'title', - title: { title, source: 'notification', updatedAt: Date.now() }, + title, }); } return semanticEvents; diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index 0558f5c..4e8517f 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -10,6 +10,7 @@ export type { TerminalPaneState, TerminalSemanticEvent, TerminalTitle, + TerminalTitleCandidates, } from './terminal-state'; export { @@ -86,5 +87,8 @@ export { resolveDisplayPrimary, shortestUniqueCwdLabels, summarizeCommandLine, + terminalTitleFromNotification, + titleCandidatesForDisplay, + titleSourceLabel, UNNAMED_PANEL_TITLE, } from './terminal-state'; diff --git a/lib/src/lib/terminal-state.test.ts b/lib/src/lib/terminal-state.test.ts index 72d0d12..6b06c2e 100644 --- a/lib/src/lib/terminal-state.test.ts +++ b/lib/src/lib/terminal-state.test.ts @@ -14,6 +14,8 @@ import { reduceTerminalState, shortestUniqueCwdLabels, summarizeCommandLine, + terminalTitleFromNotification, + titleCandidatesForDisplay, type CwdState, } from './terminal-state'; @@ -126,6 +128,21 @@ describe('terminal command state reducer', () => { expect(state.activity).toEqual({ kind: 'prompt' }); }); + it('stores latest title candidates by source channel', () => { + let state = createTerminalPaneState(); + state = reduceTerminalState(state, { type: 'title', title: { title: 'zsh', source: 'osc0', updatedAt: 1 } }); + state = reduceTerminalState(state, { type: 'title', title: { title: 'vim', source: 'osc2', updatedAt: 2 } }); + state = reduceTerminalState(state, { type: 'title', title: { title: 'mouseterm', source: 'osc0', updatedAt: 3 } }); + + expect(state.title).toEqual({ title: 'mouseterm', source: 'osc0', updatedAt: 3 }); + expect(state.titleCandidates.osc0).toEqual({ title: 'mouseterm', source: 'osc0', updatedAt: 3 }); + expect(state.titleCandidates.osc2).toEqual({ title: 'vim', source: 'osc2', updatedAt: 2 }); + expect(titleCandidatesForDisplay(state).map((candidate) => [candidate.source, candidate.title])).toEqual([ + ['osc0', 'mouseterm'], + ['osc2', 'vim'], + ]); + }); + it('uses a pending typed command line for OSC 133 command boundaries', () => { let state = createTerminalPaneState({ cwd: cwdFromManualPath('/repo/app', 1)! }); state = reduceTerminalState(state, { type: 'promptEnd' }); @@ -234,8 +251,10 @@ describe('header and grouping derivation', () => { }); it('lets fresh app-sent terminal titles override running command labels', () => { - const pane = runningPane('/repo/app', 'lazygit'); - pane.title = { title: 'lazygit: mouseterm', source: 'osc0', updatedAt: 2 }; + const pane = reduceTerminalState( + runningPane('/repo/app', 'lazygit'), + { type: 'title', title: { title: 'lazygit: mouseterm', source: 'osc0', updatedAt: 2 } }, + ); expect(deriveHeader(pane, [pane])).toEqual({ primary: 'lazygit: mouseterm', @@ -244,8 +263,10 @@ describe('header and grouping derivation', () => { }); it('ignores stale shell titles from before a command started', () => { - const pane = runningPane('/repo/app', 'lazygit'); - pane.title = { title: 'zsh', source: 'osc0', updatedAt: 0 }; + const pane = reduceTerminalState( + runningPane('/repo/app', 'lazygit'), + { type: 'title', title: { title: 'zsh', source: 'osc0', updatedAt: 0 } }, + ); expect(deriveHeader(pane, [pane])).toEqual({ primary: 'lazygit', @@ -253,6 +274,18 @@ describe('header and grouping derivation', () => { }); }); + it('keeps user-pinned titles primary when newer app title candidates arrive', () => { + let pane = runningPane('/repo/app', 'npm run dev'); + pane = reduceTerminalState(pane, { type: 'title', title: { title: 'dev server', source: 'user', updatedAt: 2 } }); + pane = reduceTerminalState(pane, { type: 'title', title: { title: 'vite', source: 'osc0', updatedAt: 3 } }); + + expect(deriveHeader(pane, [pane])).toEqual({ + primary: 'dev server', + status: 'running', + }); + expect(titleCandidatesForDisplay(pane).map((candidate) => candidate.source)).toEqual(['osc0', 'user']); + }); + it('lets legacy OSC 9 message text override derived command labels', () => { const pane = runningPane('/repo/app', 'npm run build'); const terminalStates = new Map([['pane', pane]]); @@ -272,6 +305,25 @@ describe('header and grouping derivation', () => { it('does not use rich notification titles as tab title overrides', () => { expect(notificationDisplayTitle({ source: 'OSC 777', title: 'Tests', body: '341 passed' })).toBeNull(); expect(notificationDisplayTitle({ source: 'OSC 99', title: 'Build', body: 'Finished successfully' })).toBeNull(); + expect(terminalTitleFromNotification({ source: 'OSC 777', title: 'Tests', body: '341 passed' }, 2)).toEqual({ + title: 'Tests', + source: 'osc777', + updatedAt: 2, + }); + expect(terminalTitleFromNotification({ source: 'OSC 99', title: 'Build', body: 'Finished successfully' }, 3)).toEqual({ + title: 'Build', + source: 'osc99', + updatedAt: 3, + }); + + const pane = reduceTerminalState( + runningPane('/repo/app', 'npm test'), + { type: 'title', title: { title: 'Tests', source: 'osc777', updatedAt: 3 } }, + ); + expect(deriveHeader(pane, [pane])).toEqual({ + primary: 'npm test', + status: 'running', + }); }); it('preserves remote identity when two panes have the same path', () => { diff --git a/lib/src/lib/terminal-state.ts b/lib/src/lib/terminal-state.ts index 3784622..44ce0f6 100644 --- a/lib/src/lib/terminal-state.ts +++ b/lib/src/lib/terminal-state.ts @@ -42,7 +42,16 @@ export interface CommandRun { }; } -export type TerminalTitleSource = 'osc0' | 'osc2' | 'notification' | 'user' | 'profile' | 'derived'; +export type TerminalTitleSource = + | 'osc0' + | 'osc2' + | 'osc9' + | 'osc99' + | 'osc777' + | 'notification' + | 'user' + | 'profile' + | 'derived'; export interface TerminalTitle { title: string; @@ -50,6 +59,8 @@ export interface TerminalTitle { updatedAt: number; } +export type TerminalTitleCandidates = Partial<Record<TerminalTitleSource, TerminalTitle>>; + export interface TerminalPaneState { cwd: CwdState | null; activity: ShellActivity; @@ -57,6 +68,7 @@ export interface TerminalPaneState { currentCommand: CommandRun | null; lastCommand: CommandRun | null; title: TerminalTitle | null; + titleCandidates: TerminalTitleCandidates; } export type TerminalSemanticEvent = @@ -108,6 +120,7 @@ export const DEFAULT_TERMINAL_PANE_STATE: TerminalPaneState = Object.freeze({ currentCommand: null, lastCommand: null, title: null, + titleCandidates: Object.freeze({}), }); export const DEFAULT_IDLE_TITLE = '<idle>'; @@ -118,13 +131,15 @@ const COMMAND_TITLE_LIMIT = 48; let nextCommandRunId = 0; export function createTerminalPaneState(initial?: Partial<TerminalPaneState>): TerminalPaneState { + const titleCandidates = createTitleCandidates(initial?.titleCandidates, initial?.title); return { cwd: initial?.cwd ?? null, activity: initial?.activity ?? { kind: 'unknown' }, pendingCommandLine: initial?.pendingCommandLine ?? null, currentCommand: initial?.currentCommand ?? null, lastCommand: initial?.lastCommand ?? null, - title: initial?.title ?? null, + title: initial?.title ?? latestTitleCandidate(titleCandidates), + titleCandidates, }; } @@ -197,8 +212,17 @@ export function reduceTerminalState( }; } case 'title': - if (state.title && sameTitle(state.title, event.title)) return state; - return { ...state, title: event.title }; + if (state.title && sameTitle(state.title, event.title) && sameTitle(state.titleCandidates?.[event.title.source], event.title)) { + return state; + } + return { + ...state, + title: event.title, + titleCandidates: { + ...(state.titleCandidates ?? {}), + [event.title.source]: event.title, + }, + }; } } @@ -206,8 +230,8 @@ function sameCwd(a: CwdState, b: CwdState): boolean { return cwdIdentity(a) === cwdIdentity(b) && a.source === b.source; } -function sameTitle(a: TerminalTitle, b: TerminalTitle): boolean { - return a.title === b.title && a.source === b.source; +function sameTitle(a: TerminalTitle | null | undefined, b: TerminalTitle | null | undefined): boolean { + return a?.title === b?.title && a?.source === b?.source && a?.updatedAt === b?.updatedAt; } function sameActivity(a: ShellActivity, b: ShellActivity): boolean { @@ -335,7 +359,7 @@ export function deriveFallbackCommandTitle( state?: TerminalPaneState | null, options: { shellName?: string } = {}, ): string { - const title = state?.title?.title?.trim(); + const title = latestTerminalTitleCandidate(state)?.title.trim(); if (title) return title; return options.shellName?.trim() || DEFAULT_COMMAND_TITLE; } @@ -387,6 +411,26 @@ export function notificationDisplayTitle( return null; } +export function terminalTitleFromNotification( + notification: TerminalNotificationTitleLike | null | undefined, + updatedAt = Date.now(), +): TerminalTitle | null { + if (!notification) return null; + if (notification.source === 'OSC 9') { + const title = notificationDisplayTitle(notification); + return title ? { title, source: 'osc9', updatedAt } : null; + } + if (notification.source === 'OSC 99') { + const title = notification.title?.trim(); + return title ? { title, source: 'osc99', updatedAt } : null; + } + if (notification.source === 'OSC 777') { + const title = notification.title?.trim(); + return title ? { title, source: 'osc777', updatedAt } : null; + } + return null; +} + export function buildAppTitleResolver( terminalStates: Map<string, TerminalPaneState>, activityStates: Map<string, { notification?: TerminalNotificationTitleLike | null }>, @@ -399,6 +443,34 @@ export function buildAppTitleResolver( return (pane) => titlesByPane.get(pane) ?? null; } +export function titleCandidatesForDisplay(pane: TerminalPaneState): TerminalTitle[] { + return titleCandidateValues(pane.titleCandidates ?? {}, pane.title) + .sort((a, b) => b.updatedAt - a.updatedAt || titleSourceLabel(a.source).localeCompare(titleSourceLabel(b.source))); +} + +export function titleSourceLabel(source: TerminalTitleSource): string { + switch (source) { + case 'osc0': + return 'OSC 0'; + case 'osc2': + return 'OSC 2'; + case 'osc9': + return 'OSC 9'; + case 'osc99': + return 'OSC 99'; + case 'osc777': + return 'OSC 777'; + case 'notification': + return 'notification'; + case 'user': + return 'user'; + case 'profile': + return 'profile'; + case 'derived': + return 'derived'; + } +} + export function groupTerminalPanes( panes: TerminalPaneState[], mode: TerminalGroupingMode, @@ -679,7 +751,8 @@ function truncateCommandTitle(title: string): string { } function headerPrimary(pane: TerminalPaneState, options: HeaderOptions): string { - if (pane.title?.source === 'user') return pane.title.title; + const userTitle = titleCandidateForSource(pane, 'user')?.title.trim(); + if (userTitle) return userTitle; const appTitle = options.appTitleForPane?.(pane)?.trim(); if (appTitle) return appTitle; const terminalTitle = activeTerminalTitle(pane); @@ -690,17 +763,18 @@ function headerPrimary(pane: TerminalPaneState, options: HeaderOptions): string } function idleLabel(pane: TerminalPaneState, _options: { shellName?: string } = {}): string { - if (pane.title?.source === 'user') return pane.title.title; + const userTitle = titleCandidateForSource(pane, 'user')?.title.trim(); + if (userTitle) return userTitle; return DEFAULT_IDLE_TITLE; } function activeTerminalTitle(pane: TerminalPaneState): string | null { - if (!pane.title || pane.title.source === 'user') return null; - const title = pane.title.title.trim(); - if (!title) return null; const command = pane.currentCommand ?? (pane.activity.kind === 'finished' ? pane.lastCommand : null); - if (!command || pane.title.updatedAt < command.startedAt) return null; - return title; + if (!command) return null; + const title = latestTitleCandidateForSources(pane, ['osc0', 'osc2', 'osc9', 'notification']); + if (!title || title.updatedAt < command.startedAt) return null; + const text = title.title.trim(); + return text || null; } function headerStatus(pane: TerminalPaneState): DerivedHeader['status'] { @@ -742,3 +816,53 @@ function groupBy( } return [...groups.values()]; } + +function createTitleCandidates( + initialCandidates: TerminalTitleCandidates | undefined, + initialTitle: TerminalTitle | null | undefined, +): TerminalTitleCandidates { + const candidates: TerminalTitleCandidates = { ...(initialCandidates ?? {}) }; + if (initialTitle) candidates[initialTitle.source] = initialTitle; + return candidates; +} + +function titleCandidateValues(candidates: TerminalTitleCandidates, latestTitle: TerminalTitle | null): TerminalTitle[] { + const bySource = new Map<TerminalTitleSource, TerminalTitle>(); + for (const candidate of Object.values(candidates)) { + if (!candidate) continue; + bySource.set(candidate.source, candidate); + } + if (latestTitle && !bySource.has(latestTitle.source)) bySource.set(latestTitle.source, latestTitle); + return [...bySource.values()]; +} + +function latestTitleCandidate(candidates: TerminalTitleCandidates): TerminalTitle | null { + return titleCandidateValues(candidates, null) + .sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? null; +} + +function latestTerminalTitleCandidate(state: TerminalPaneState | null | undefined): TerminalTitle | null { + if (!state) return null; + return titleCandidateValues(state.titleCandidates ?? {}, state.title) + .filter((candidate) => candidate.source !== 'user') + .sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? null; +} + +function titleCandidateForSource( + pane: TerminalPaneState, + source: TerminalTitleSource, +): TerminalTitle | null { + const candidate = pane.titleCandidates?.[source]; + if (candidate) return candidate; + return pane.title?.source === source ? pane.title : null; +} + +function latestTitleCandidateForSources( + pane: TerminalPaneState, + sources: TerminalTitleSource[], +): TerminalTitle | null { + const allowed = new Set(sources); + return titleCandidateValues(pane.titleCandidates ?? {}, pane.title) + .filter((candidate) => allowed.has(candidate.source)) + .sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? null; +} diff --git a/lib/src/stories/ShellCwd.stories.tsx b/lib/src/stories/ShellCwd.stories.tsx index ec8fba0..a985f1f 100644 --- a/lib/src/stories/ShellCwd.stories.tsx +++ b/lib/src/stories/ShellCwd.stories.tsx @@ -119,6 +119,18 @@ export const TitleFallbacksAndPinnedTitles: Story = storyFor([ caseState('title-long-user', 'Long user title', idle({ cwd: manual('/repo/app'), title: terminalTitle('my-extremely-long-running-background-process-with-a-very-descriptive-name', 'user') }), 'Truncates before controls'), ]); +export const TitleCandidatePopup: Story = { + ...storyFor([ + caseState( + 'title-candidates-popup', + 'Title candidates popup', + titleCandidateState(), + 'Right-click popup lists each latest title channel', + ), + ]), + play: openTitleCandidatesPopup, +}; + export const GroupingKeys: Story = storyFor([ caseState('group-app-dev', 'App dev server', running('/repo/app', 'npm run dev'), 'Directory: app, command: npm run dev, status: running'), caseState('group-api-dev', 'API dev server', running('/repo/api', 'npm run dev'), 'Directory: api, command: npm run dev, status: running'), @@ -345,6 +357,43 @@ function terminalTitleAt(title: string, source: TerminalTitle['source'], updated return { title, source, updatedAt }; } +function titleCandidateState(): TerminalPaneState { + const pane = running('/repo/app', 'npm run dev'); + const candidates = { + user: terminalTitleAt('Pinned production API', 'user', BASE_TIME + 6_000), + osc0: terminalTitleAt('mouseterm', 'osc0', BASE_TIME + 1_000), + osc2: terminalTitleAt('zsh', 'osc2', BASE_TIME + 2_000), + osc9: terminalTitleAt('Build finished', 'osc9', BASE_TIME + 5_000), + osc99: terminalTitleAt('Codex waiting', 'osc99', BASE_TIME + 4_000), + osc777: terminalTitleAt('Tests complete', 'osc777', BASE_TIME + 3_000), + } satisfies TerminalPaneState['titleCandidates']; + return createTerminalPaneState({ + ...pane, + title: candidates.user, + titleCandidates: candidates, + }); +} + +function wait(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function openTitleCandidatesPopup() { + await wait(100); + const title = document.querySelector<HTMLElement>('[data-title-candidates-for="title-candidates-popup"]'); + if (!title) return; + + const rect = title.getBoundingClientRect(); + title.dispatchEvent(new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + button: 2, + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2, + })); + await wait(100); +} + function makeDoorItem(id: string, title: string): DooredItem { return { id, From 873edf490f3c78eecb4e81dbb51e309042249cf5 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Fri, 8 May 2026 13:14:29 -0700 Subject: [PATCH 06/50] Simplify title-candidate state and header helpers Drop defensive null guards on the non-optional titleCandidates field, collapse the seed-helper indirection in createTerminalPaneState, and swap sort-pick-first for linear max-scans across the candidate accessors. Stabilize the popover dismissal effect on a boolean so the window listeners stop re-registering on every rect change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .../components/wall/TerminalPaneHeader.tsx | 5 +- lib/src/lib/terminal-state.ts | 80 +++++++++---------- 2 files changed, 39 insertions(+), 46 deletions(-) diff --git a/lib/src/components/wall/TerminalPaneHeader.tsx b/lib/src/components/wall/TerminalPaneHeader.tsx index b70c400..4a371a8 100644 --- a/lib/src/components/wall/TerminalPaneHeader.tsx +++ b/lib/src/components/wall/TerminalPaneHeader.tsx @@ -163,8 +163,9 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { if (!activity.notification) setTodoPreviewRect(null); }, [activity.notification]); + const titleCandidatesOpen = !!titleCandidatesRect; useEffect(() => { - if (!titleCandidatesRect) return; + if (!titleCandidatesOpen) return; const close = () => setTitleCandidatesRect(null); const closeOnKey = (event: KeyboardEvent) => { if (event.key === 'Escape') close(); @@ -179,7 +180,7 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { window.removeEventListener('scroll', close, true); window.removeEventListener('keydown', closeOnKey); }; - }, [titleCandidatesRect]); + }, [titleCandidatesOpen]); return ( <div diff --git a/lib/src/lib/terminal-state.ts b/lib/src/lib/terminal-state.ts index 44ce0f6..1f6b585 100644 --- a/lib/src/lib/terminal-state.ts +++ b/lib/src/lib/terminal-state.ts @@ -131,14 +131,21 @@ const COMMAND_TITLE_LIMIT = 48; let nextCommandRunId = 0; export function createTerminalPaneState(initial?: Partial<TerminalPaneState>): TerminalPaneState { - const titleCandidates = createTitleCandidates(initial?.titleCandidates, initial?.title); + const titleCandidates: TerminalTitleCandidates = { ...initial?.titleCandidates }; + if (initial?.title) titleCandidates[initial.title.source] = initial.title; + let title = initial?.title ?? null; + if (!title) { + for (const candidate of Object.values(titleCandidates)) { + if (candidate && (!title || candidate.updatedAt > title.updatedAt)) title = candidate; + } + } return { cwd: initial?.cwd ?? null, activity: initial?.activity ?? { kind: 'unknown' }, pendingCommandLine: initial?.pendingCommandLine ?? null, currentCommand: initial?.currentCommand ?? null, lastCommand: initial?.lastCommand ?? null, - title: initial?.title ?? latestTitleCandidate(titleCandidates), + title, titleCandidates, }; } @@ -211,18 +218,20 @@ export function reduceTerminalState( activity: finishedActivity(event.exitCode), }; } - case 'title': - if (state.title && sameTitle(state.title, event.title) && sameTitle(state.titleCandidates?.[event.title.source], event.title)) { + case 'title': { + const existing = state.titleCandidates[event.title.source]; + if (state.title && existing && sameTitle(state.title, event.title) && sameTitle(existing, event.title)) { return state; } return { ...state, title: event.title, titleCandidates: { - ...(state.titleCandidates ?? {}), + ...state.titleCandidates, [event.title.source]: event.title, }, }; + } } } @@ -230,8 +239,8 @@ function sameCwd(a: CwdState, b: CwdState): boolean { return cwdIdentity(a) === cwdIdentity(b) && a.source === b.source; } -function sameTitle(a: TerminalTitle | null | undefined, b: TerminalTitle | null | undefined): boolean { - return a?.title === b?.title && a?.source === b?.source && a?.updatedAt === b?.updatedAt; +function sameTitle(a: TerminalTitle, b: TerminalTitle): boolean { + return a.title === b.title && a.source === b.source && a.updatedAt === b.updatedAt; } function sameActivity(a: ShellActivity, b: ShellActivity): boolean { @@ -444,8 +453,9 @@ export function buildAppTitleResolver( } export function titleCandidatesForDisplay(pane: TerminalPaneState): TerminalTitle[] { - return titleCandidateValues(pane.titleCandidates ?? {}, pane.title) - .sort((a, b) => b.updatedAt - a.updatedAt || titleSourceLabel(a.source).localeCompare(titleSourceLabel(b.source))); + return Object.values(pane.titleCandidates) + .filter((candidate): candidate is TerminalTitle => !!candidate) + .sort((a, b) => b.updatedAt - a.updatedAt || a.source.localeCompare(b.source)); } export function titleSourceLabel(source: TerminalTitleSource): string { @@ -768,10 +778,12 @@ function idleLabel(pane: TerminalPaneState, _options: { shellName?: string } = { return DEFAULT_IDLE_TITLE; } +const HEADER_APP_TITLE_SOURCES: TerminalTitleSource[] = ['osc0', 'osc2', 'osc9', 'notification']; + function activeTerminalTitle(pane: TerminalPaneState): string | null { const command = pane.currentCommand ?? (pane.activity.kind === 'finished' ? pane.lastCommand : null); if (!command) return null; - const title = latestTitleCandidateForSources(pane, ['osc0', 'osc2', 'osc9', 'notification']); + const title = latestTitleCandidateForSources(pane, HEADER_APP_TITLE_SOURCES); if (!title || title.updatedAt < command.startedAt) return null; const text = title.title.trim(); return text || null; @@ -817,52 +829,32 @@ function groupBy( return [...groups.values()]; } -function createTitleCandidates( - initialCandidates: TerminalTitleCandidates | undefined, - initialTitle: TerminalTitle | null | undefined, -): TerminalTitleCandidates { - const candidates: TerminalTitleCandidates = { ...(initialCandidates ?? {}) }; - if (initialTitle) candidates[initialTitle.source] = initialTitle; - return candidates; -} - -function titleCandidateValues(candidates: TerminalTitleCandidates, latestTitle: TerminalTitle | null): TerminalTitle[] { - const bySource = new Map<TerminalTitleSource, TerminalTitle>(); - for (const candidate of Object.values(candidates)) { - if (!candidate) continue; - bySource.set(candidate.source, candidate); - } - if (latestTitle && !bySource.has(latestTitle.source)) bySource.set(latestTitle.source, latestTitle); - return [...bySource.values()]; -} - -function latestTitleCandidate(candidates: TerminalTitleCandidates): TerminalTitle | null { - return titleCandidateValues(candidates, null) - .sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? null; -} - function latestTerminalTitleCandidate(state: TerminalPaneState | null | undefined): TerminalTitle | null { if (!state) return null; - return titleCandidateValues(state.titleCandidates ?? {}, state.title) - .filter((candidate) => candidate.source !== 'user') - .sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? null; + let latest: TerminalTitle | null = null; + for (const candidate of Object.values(state.titleCandidates)) { + if (!candidate || candidate.source === 'user') continue; + if (!latest || candidate.updatedAt > latest.updatedAt) latest = candidate; + } + return latest; } function titleCandidateForSource( pane: TerminalPaneState, source: TerminalTitleSource, ): TerminalTitle | null { - const candidate = pane.titleCandidates?.[source]; - if (candidate) return candidate; - return pane.title?.source === source ? pane.title : null; + return pane.titleCandidates[source] ?? null; } function latestTitleCandidateForSources( pane: TerminalPaneState, sources: TerminalTitleSource[], ): TerminalTitle | null { - const allowed = new Set(sources); - return titleCandidateValues(pane.titleCandidates ?? {}, pane.title) - .filter((candidate) => allowed.has(candidate.source)) - .sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? null; + let latest: TerminalTitle | null = null; + for (const source of sources) { + const candidate = pane.titleCandidates[source]; + if (!candidate) continue; + if (!latest || candidate.updatedAt > latest.updatedAt) latest = candidate; + } + return latest; } From 4398647496bfad2090a41a2788eadecc7b59c9f8 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Fri, 8 May 2026 14:51:49 -0700 Subject: [PATCH 07/50] Decode VS Code OSC 633 command payloads --- docs/specs/terminal-state.md | 2 +- lib/src/lib/terminal-protocol.test.ts | 8 ++++++++ lib/src/lib/terminal-protocol.ts | 10 +++++++++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/specs/terminal-state.md b/docs/specs/terminal-state.md index 0390ccd..39a2f5b 100644 --- a/docs/specs/terminal-state.md +++ b/docs/specs/terminal-state.md @@ -132,7 +132,7 @@ Command lifecycle: | `OSC 133 ; A ST` / `OSC 633 ; A ST` | `promptStart` | | `OSC 133 ; B ST` / `OSC 633 ; B ST` | `promptEnd` | | `OSC 133 ; C ST` | `commandStart(source: "osc133_boundaries")` | -| `OSC 633 ; E ; <commandline> ST` | `commandLine` | +| `OSC 633 ; E ; <commandline> [; <nonce>] ST` | `commandLine`; parses only the command field and decodes VS Code `\xAB` / `\\` escapes before storing it. | | `OSC 633 ; C ST` | `commandStart(source: "osc633_E"` when a pending command line exists, otherwise `"osc633_boundaries")` | | `OSC 133 ; D ; <exitCode?> ST` / `OSC 633 ; D ; <exitCode?> ST` | `commandFinish` | diff --git a/lib/src/lib/terminal-protocol.test.ts b/lib/src/lib/terminal-protocol.test.ts index 2e3ea02..4374770 100644 --- a/lib/src/lib/terminal-protocol.test.ts +++ b/lib/src/lib/terminal-protocol.test.ts @@ -204,6 +204,14 @@ describe('TerminalProtocolParser', () => { ]); }); + it('decodes OSC 633 command lines without including the optional nonce', () => { + const parser = new TerminalProtocolParser(); + + expect(parser.process('\x1b]633;E;echo one\\x3btwo \\\\ path;nonce-123\x07').events).toEqual([ + { kind: 'semantic', event: { type: 'commandLine', commandLine: 'echo one;two \\ path' } }, + ]); + }); + it('parses OSC 633 and 1337 CWD plus title fallbacks', () => { const parser = new TerminalProtocolParser(); const result = parser.process('\x1b]633;P;Cwd=/tmp/with%20space\x07\x1b]1337;CurrentDir=/Users/me/app\x07\x1b]0;zsh\x07\x1b]2;vim\x07'); diff --git a/lib/src/lib/terminal-protocol.ts b/lib/src/lib/terminal-protocol.ts index dc39a72..ac7638d 100644 --- a/lib/src/lib/terminal-protocol.ts +++ b/lib/src/lib/terminal-protocol.ts @@ -318,7 +318,8 @@ function parseOsc633(content: string): TerminalProtocolEvent[] { if (fields[1] === 'E') { const prefix = '633;E;'; if (!content.startsWith(prefix)) return []; - return [{ kind: 'semantic', event: { type: 'commandLine', commandLine: content.slice(prefix.length) } }]; + const rawCommand = content.slice(prefix.length).split(';', 1)[0] ?? ''; + return [{ kind: 'semantic', event: { type: 'commandLine', commandLine: decodeOsc633Value(rawCommand) } }]; } if (fields[1] === 'P') { return parseOsc633Property(content.slice('633;P;'.length)); @@ -381,6 +382,13 @@ function parseExitCode(raw: string | undefined): number | undefined { return Number.isInteger(value) ? value : undefined; } +function decodeOsc633Value(value: string): string { + return value.replace(/\\\\|\\x([0-9a-fA-F]{2})/g, (match, hex: string | undefined) => { + if (match === '\\\\') return '\\'; + return String.fromCharCode(Number.parseInt(hex ?? '00', 16)); + }); +} + // OSC 9;4 state code → progress shape. Codes 1 and 4 require a percent // (drop the update if missing); 2 accepts a missing/invalid percent as null. const OSC94_STATE_TABLE: Record<string, (raw: string | null) => ProtocolProgressUpdate | null> = { From 00568ebd6fd1d3528fbb521fab230f72566d467e Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Fri, 8 May 2026 14:52:50 -0700 Subject: [PATCH 08/50] Keep terminal state after VS Code PTY exit --- docs/specs/vscode.md | 1 + lib/src/lib/platform/vscode-adapter.test.ts | 56 +++++++++++++++++++++ lib/src/lib/platform/vscode-adapter.ts | 1 - 3 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 lib/src/lib/platform/vscode-adapter.test.ts diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index d0329e6..1b505a2 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -122,6 +122,7 @@ This means: - Hiding the MouseTerm panel doesn't kill its PTYs. - VS Code toggling the panel visibility doesn't destroy sessions. - When the view becomes visible again, the webview **resumes** over still-owned PTYs and reapplies the saved visible-pane layout when the saved session covers the live PTY set and the layout's visible panels match. +- A PTY process that exits naturally can remain mounted as an exited pane; frontend semantic state such as CWD, title candidates, and last command is retained until the Session is actually disposed. - Each message router tracks which PTYs it owns; PTYs cannot be stolen by another router. - Explicitly killed PTYs are **tombstoned** in the extension host (`Process: Tombstoned`) so a late child-process `exit` event cannot recreate their buffer and make them resumable. - Multiple VS Code windows each get their own extension host process, and therefore their own pty-host child process. diff --git a/lib/src/lib/platform/vscode-adapter.test.ts b/lib/src/lib/platform/vscode-adapter.test.ts new file mode 100644 index 0000000..456fc5c --- /dev/null +++ b/lib/src/lib/platform/vscode-adapter.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const terminalStateStoreMocks = vi.hoisted(() => ({ + applyTerminalSemanticEventsByPtyId: vi.fn(), + removeTerminalPaneState: vi.fn(), +})); + +vi.mock('../terminal-state-store', () => ({ + applyTerminalSemanticEventsByPtyId: terminalStateStoreMocks.applyTerminalSemanticEventsByPtyId, + removeTerminalPaneState: terminalStateStoreMocks.removeTerminalPaneState, +})); + +import { VSCodeAdapter } from './vscode-adapter'; + +describe('VSCodeAdapter PTY exit handling', () => { + let windowTarget: EventTarget; + let postMessage: ReturnType<typeof vi.fn>; + + beforeEach(() => { + windowTarget = new EventTarget(); + postMessage = vi.fn(); + vi.stubGlobal('window', windowTarget); + vi.stubGlobal('acquireVsCodeApi', () => ({ + postMessage, + getState: vi.fn(), + setState: vi.fn(), + })); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('keeps semantic pane state when a PTY exits naturally', () => { + const adapter = new VSCodeAdapter(); + const exits: Array<{ id: string; exitCode: number }> = []; + adapter.onPtyExit((detail) => exits.push(detail)); + + windowTarget.dispatchEvent(new MessageEvent('message', { + data: { type: 'pty:exit', id: 'pane-1', exitCode: 7 }, + })); + + expect(exits).toEqual([{ id: 'pane-1', exitCode: 7 }]); + expect(terminalStateStoreMocks.removeTerminalPaneState).not.toHaveBeenCalled(); + }); + + it('clears semantic pane state when the adapter explicitly kills a PTY', () => { + const adapter = new VSCodeAdapter(); + + adapter.killPty('pane-1'); + + expect(terminalStateStoreMocks.removeTerminalPaneState).toHaveBeenCalledWith('pane-1'); + expect(postMessage).toHaveBeenCalledWith({ type: 'pty:kill', id: 'pane-1' }); + }); +}); diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index 2c9bedb..26c6236 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -43,7 +43,6 @@ export class VSCodeAdapter implements PlatformAdapter { } } else if (msg.type === 'pty:exit') { this.replayProtocolParsers.delete(msg.id); - removeTerminalPaneState(msg.id); for (const handler of this.exitHandlers) { handler({ id: msg.id, exitCode: msg.exitCode }); } From 6dc8af2f75ff87cee674ec501409f2bc2e0c4a81 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Fri, 8 May 2026 14:54:29 -0700 Subject: [PATCH 09/50] Restore saved titles on live reconnect --- docs/specs/vscode.md | 2 +- lib/src/lib/reconnect.test.ts | 20 ++++++++++++++++++++ lib/src/lib/reconnect.ts | 25 ++++++++++++++++++++++--- lib/src/lib/terminal-lifecycle.ts | 6 +++++- 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 1b505a2..ab9b4b7 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -144,7 +144,7 @@ Both are capped at 1M chars per PTY. When the cap is reached, oldest chunks are 3. Extension responds with: - { type: 'pty:list', ptys: [{ id, alive, exitCode }] } // all owned PTYs - { type: 'pty:replay', id, data } // buffered output per PTY -4. Webview restores terminals from replay data, resumes live stream +4. Webview restores terminals from replay data, seeds any non-unnamed saved visible-pane titles as user titles, and resumes the live stream 5. If the saved session covers those live PTYs, the frontend uses the saved dockview layout when its visible panels match and reattaches saved minimized doors; minimized PTYs are registered but remain doors instead of visible panes ``` diff --git a/lib/src/lib/reconnect.test.ts b/lib/src/lib/reconnect.test.ts index 22de9fa..614c7ec 100644 --- a/lib/src/lib/reconnect.test.ts +++ b/lib/src/lib/reconnect.test.ts @@ -114,6 +114,26 @@ describe('resumeOrRestore', () => { }); }); + it('seeds saved visible pane titles when resuming live PTYs', async () => { + const saved: PersistedSession = { + version: 3, + layout: { panels: { 'pane-a': {} } }, + panes: [ + { id: 'pane-a', title: 'Production API', cwd: null, scrollback: null, resumeCommand: null }, + ], + }; + + await resumeOrRestore(createPlatform([ + { id: 'pane-a', alive: true }, + ], saved)); + + expect(terminalRegistryMocks.resumeTerminal).toHaveBeenCalledWith('pane-a', 'pane-a-replay', { + alive: true, + exitCode: undefined, + title: 'Production API', + }); + }); + it('does not reuse a saved layout when live PTYs do not match saved panes', async () => { const saved: PersistedSession = { version: 3, diff --git a/lib/src/lib/reconnect.ts b/lib/src/lib/reconnect.ts index 584355a..6b2d868 100644 --- a/lib/src/lib/reconnect.ts +++ b/lib/src/lib/reconnect.ts @@ -63,18 +63,23 @@ function resumeLiveSessions(platform: PlatformAdapter): Promise<ReconnectResult return; } + const savedState = platform.getState(); + const savedVisibleTitles = getSavedVisiblePaneTitles(savedState, ptyList.map((pty) => pty.id)); const ids: string[] = []; for (const pty of ptyList) { - resumeTerminal(pty.id, replayBuffer.get(pty.id) ?? null, { + const resumeInfo: { alive: boolean; exitCode?: number; title?: string } = { alive: pty.alive, exitCode: pty.exitCode, - }); + }; + const savedTitle = savedVisibleTitles.get(pty.id); + if (savedTitle !== undefined) resumeInfo.title = savedTitle; + resumeTerminal(pty.id, replayBuffer.get(pty.id) ?? null, resumeInfo); ids.push(pty.id); } // Pull saved visible/doors state so a resume (e.g. after panel // close/reopen) restores splits and doors instead of stacking every live // PTY into one tab group. - const savedPlan = getSavedResumePlan(platform.getState(), ids); + const savedPlan = getSavedResumePlan(savedState, ids); if (savedPlan) { resolve(savedPlan); return; @@ -89,6 +94,20 @@ function resumeLiveSessions(platform: PlatformAdapter): Promise<ReconnectResult }); } +function getSavedVisiblePaneTitles(savedState: unknown, liveIds: string[]): Map<string, string> { + const saved = readPersistedSession(savedState); + if (!saved || !Array.isArray(saved.panes)) return new Map(); + + const liveSet = new Set(liveIds); + const doorIds = new Set((saved.doors ?? []).map((item) => item.id)); + const result = new Map<string, string>(); + for (const pane of saved.panes) { + if (!liveSet.has(pane.id) || doorIds.has(pane.id)) continue; + result.set(pane.id, pane.title); + } + return result; +} + function getSavedResumePlan(savedState: unknown, liveIds: string[]): ReconnectResult | null { const saved = readPersistedSession(savedState); if (!saved || !Array.isArray(saved.panes)) return null; diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index d2cca93..6858e79 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -228,7 +228,7 @@ export function getOrCreateTerminal(id: string): TerminalEntry { export function resumeTerminal( id: string, replayData: string | null, - exitInfo?: { alive: boolean; exitCode?: number }, + exitInfo?: { alive: boolean; exitCode?: number; title?: string | null }, ): TerminalEntry { const existing = registry.get(id); if (existing) return existing; @@ -241,6 +241,10 @@ export function resumeTerminal( if (exitInfo && !exitInfo.alive) { entry.terminal.write(`\r\n[Process exited with code ${exitInfo.exitCode ?? -1}]\r\n`); } + const savedTitle = exitInfo?.title?.trim(); + if (savedTitle && savedTitle !== UNNAMED_PANEL_TITLE) { + setTerminalUserTitle(id, savedTitle); + } return entry; } From 1b5f7d5d5e7560ef5d089b2ddabc4b6cf75dd600 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Fri, 8 May 2026 14:55:45 -0700 Subject: [PATCH 10/50] Resolve pane state before typed fallback --- docs/specs/terminal-state.md | 1 + lib/src/lib/terminal-lifecycle.ts | 8 ++++---- lib/src/lib/terminal-state-store.test.ts | 24 ++++++++++++++++++++++++ lib/src/lib/terminal-state-store.ts | 8 ++++++++ 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/docs/specs/terminal-state.md b/docs/specs/terminal-state.md index 39a2f5b..e52dda7 100644 --- a/docs/specs/terminal-state.md +++ b/docs/specs/terminal-state.md @@ -162,6 +162,7 @@ The parser accepts both BEL and ST terminators and handles split chunks. Unsuppo - `promptEnd` sets `{ kind: "editing" }` and clears stale pending command-line fallback. - `commandLine` stores `pendingCommandLine`. - User-entered prompt input may also store `pendingCommandLine` as an explicit fallback before an OSC 133/633 command-start boundary. This fallback is only used while the shell is idle/editing; foreground-program input is ignored. If the submitted line is non-empty, the input fallback may create a `currentCommand` immediately with `source: "user_input"` so shells without command-start integration still show the active command. +- The typed-command fallback resolves the current Session id from the PTY id before recording input or prompt-looking output, so drag-to-swap moves the fallback state with the visible pane. - `commandStart` creates `currentCommand`, snapshots `cwdAtStart`, clears `pendingCommandLine`, and sets `{ kind: "running" }`. - `commandFinish` moves `currentCommand` to `lastCommand`, stores `finishedAt`/`exitCode`, clears `currentCommand`, and sets `{ kind: "finished", exitCode }`. - `title` updates `title` and the per-source entry in `titleCandidates`. Later OSC title events do not erase earlier user, shell, or notification channel candidates from other sources. diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index 6858e79..2a15117 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -28,8 +28,8 @@ import { getTerminalTheme, paintTerminalHost, startThemeObserver } from './termi import { ensureTerminalPaneState, fillTerminalProcessCwd, - recordTerminalOutput, - recordTerminalUserInput, + recordTerminalOutputByPtyId, + recordTerminalUserInputByPtyId, removeTerminalPaneState, resetTerminalPaneState, seedTerminalManualCwd, @@ -72,7 +72,7 @@ function wirePtyEvents(id: string, terminal: Terminal): () => void { const platform = getPlatform(); const handleData = (detail: { id: string; data: string }) => { if (detail.id === id) { - recordTerminalOutput(id, detail.data); + recordTerminalOutputByPtyId(id, detail.data); terminal.write(detail.data); } }; @@ -107,7 +107,7 @@ function wireXtermHandlers( if (inputIsReplayTerminalReport(input) && registry.get(id)?.isReplaying) return; if (!isSyntheticTerminalReport) { - recordTerminalUserInput(id, input); + recordTerminalUserInputByPtyId(id, input); const entry = registry.get(id); const hadTodo = entry?.todo === true; getPlatform().alertAttend(id); diff --git a/lib/src/lib/terminal-state-store.test.ts b/lib/src/lib/terminal-state-store.test.ts index b2871d1..0f9a2f4 100644 --- a/lib/src/lib/terminal-state-store.test.ts +++ b/lib/src/lib/terminal-state-store.test.ts @@ -3,13 +3,19 @@ import { applyTerminalSemanticEvents, getTerminalPaneState, recordTerminalOutput, + recordTerminalOutputByPtyId, recordTerminalUserInput, + recordTerminalUserInputByPtyId, removeTerminalPaneState, } from './terminal-state-store'; +import { registry, type TerminalEntry } from './terminal-store'; describe('terminal semantic state store command input fallback', () => { afterEach(() => { removeTerminalPaneState('pane'); + removeTerminalPaneState('pane-a'); + removeTerminalPaneState('pane-b'); + registry.delete('pane-b'); }); it('promotes a submitted prompt line into the current command immediately', () => { @@ -47,4 +53,22 @@ describe('terminal semantic state store command input fallback', () => { expect(getTerminalPaneState('pane').currentCommand?.displayCommand).toBe('lazygit'); }); + + it('records PTY fallback state under the current pane after a swap', () => { + registry.set('pane-b', { ptyId: 'pane-a' } as unknown as TerminalEntry); + + recordTerminalUserInputByPtyId('pane-a', 'lazygit\r'); + + expect(getTerminalPaneState('pane-a').currentCommand).toBeNull(); + expect(getTerminalPaneState('pane-b').currentCommand).toMatchObject({ + rawCommandLine: 'lazygit', + displayCommand: 'lazygit', + source: 'user_input', + }); + + recordTerminalOutputByPtyId('pane-a', '\r\nuser@host repo % '); + + expect(getTerminalPaneState('pane-b').currentCommand).toBeNull(); + expect(getTerminalPaneState('pane-b').activity).toEqual({ kind: 'editing' }); + }); }); diff --git a/lib/src/lib/terminal-state-store.ts b/lib/src/lib/terminal-state-store.ts index ace3e90..b3401c3 100644 --- a/lib/src/lib/terminal-state-store.ts +++ b/lib/src/lib/terminal-state-store.ts @@ -99,6 +99,10 @@ export function recordTerminalUserInput(id: string, input: string): void { } } +export function recordTerminalUserInputByPtyId(ptyId: string, input: string): void { + recordTerminalUserInput(resolvePaneStateIdByPtyId(ptyId), input); +} + export function recordTerminalOutput(id: string, output: string): void { if (!output) return; const state = paneStates.get(id); @@ -112,6 +116,10 @@ export function recordTerminalOutput(id: string, output: string): void { applyTerminalSemanticEvents(id, [{ type: 'promptStart' }, { type: 'promptEnd' }]); } +export function recordTerminalOutputByPtyId(ptyId: string, output: string): void { + recordTerminalOutput(resolvePaneStateIdByPtyId(ptyId), output); +} + export function setTerminalUserTitle(id: string, title: string): void { const trimmed = title.trim(); if (!trimmed) return; From 0a8026242e17088182a73580a8bc4534d60cc7a1 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Fri, 8 May 2026 14:56:33 -0700 Subject: [PATCH 11/50] Avoid duplicating UNC roots in labels --- docs/specs/terminal-state.md | 1 + lib/src/lib/terminal-state.test.ts | 10 ++++++++++ lib/src/lib/terminal-state.ts | 2 +- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/specs/terminal-state.md b/docs/specs/terminal-state.md index e52dda7..fc7f4de 100644 --- a/docs/specs/terminal-state.md +++ b/docs/specs/terminal-state.md @@ -224,3 +224,4 @@ unknown | idle | running | finished ``` Directory group keys use `cwdIdentity(cwd)` so remote hosts and Windows/POSIX path kinds remain distinct. +Windows UNC display labels keep `\\server\share\` as the path root and do not repeat the server/share in the trailing path segments. diff --git a/lib/src/lib/terminal-state.test.ts b/lib/src/lib/terminal-state.test.ts index 6b06c2e..769daa4 100644 --- a/lib/src/lib/terminal-state.test.ts +++ b/lib/src/lib/terminal-state.test.ts @@ -67,6 +67,16 @@ describe('terminal CWD normalization', () => { expect(labels.get(cwdIdentity(sibling))).toBe('other/app'); expect(cwdDisplay(remote)).toBe('prod-box:me/app'); }); + + it('does not duplicate UNC roots in full-depth labels', () => { + const share = cwdFromOsc9_9('\\\\server\\share\\repo\\app', 100)!; + const otherShare = cwdFromOsc9_9('\\\\server\\other\\repo\\app', 100)!; + const labels = shortestUniqueCwdLabels([share, otherShare]); + + expect(cwdDisplay(share, { maxSegments: 2 })).toBe('\\\\server\\share\\repo\\app'); + expect(labels.get(cwdIdentity(share))).toBe('\\\\server\\share\\repo\\app'); + expect(labels.get(cwdIdentity(otherShare))).toBe('\\\\server\\other\\repo\\app'); + }); }); describe('terminal command state reducer', () => { diff --git a/lib/src/lib/terminal-state.ts b/lib/src/lib/terminal-state.ts index 1f6b585..43afbab 100644 --- a/lib/src/lib/terminal-state.ts +++ b/lib/src/lib/terminal-state.ts @@ -595,7 +595,7 @@ function pathParts(path: string, kind: PathKind): { root: string; segments: stri const unc = normalized.match(/^\\\\([^\\]+)\\([^\\]+)\\?(.*)$/); if (unc) { const rest = unc[3] ? unc[3].split('\\').filter(Boolean) : []; - return { root: `\\\\${unc[1]}\\${unc[2]}\\`, segments: [unc[1], unc[2], ...rest], separator: '\\' }; + return { root: `\\\\${unc[1]}\\${unc[2]}\\`, segments: rest, separator: '\\' }; } const drive = normalized.match(/^([A-Za-z]:)\\?(.*)$/); if (drive) { From 1cd99d90292860ea5b0249ff70bef443b7345e88 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Fri, 8 May 2026 15:10:10 -0700 Subject: [PATCH 12/50] Tighten header title spacing in stories --- .../components/wall/TerminalPaneHeader.tsx | 8 +- lib/src/stories/Baseboard.stories.tsx | 76 ++++++++++++------- 2 files changed, 53 insertions(+), 31 deletions(-) diff --git a/lib/src/components/wall/TerminalPaneHeader.tsx b/lib/src/components/wall/TerminalPaneHeader.tsx index 4a371a8..39a3c87 100644 --- a/lib/src/components/wall/TerminalPaneHeader.tsx +++ b/lib/src/components/wall/TerminalPaneHeader.tsx @@ -188,7 +188,7 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { className={tabVariant({ state: isActiveHeader ? 'active' : 'inactive' })} onMouseDown={() => actions.onClickPanel(api.id)} > - <div className="flex flex-1 min-w-0 items-center gap-2"> + <div className="flex flex-1 min-w-0 items-center gap-1.5"> {isRenaming ? ( <input className="bg-transparent outline-none border-none text-inherit font-medium font-mono w-full min-w-0 p-0 m-0" @@ -209,7 +209,7 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { ) : ( <span data-title-candidates-for={api.id} - className="flex min-w-0 cursor-text items-baseline font-medium text-inherit decoration-current/50 underline-offset-2 hover:underline" + className="inline-flex max-w-full min-w-0 shrink cursor-text items-baseline overflow-hidden font-medium text-inherit decoration-current/50 underline-offset-2 hover:underline" onMouseDown={(e) => e.stopPropagation()} onClick={(e) => { e.stopPropagation(); actions.onStartRename(api.id); }} onContextMenu={(e) => { @@ -218,9 +218,9 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { setTitleCandidatesRect(e.currentTarget.getBoundingClientRect()); }} > - <span className="min-w-0 truncate">{displayTitle}</span> + <span className="min-w-0 shrink truncate">{displayTitle}</span> {derivedHeader.secondary && ( - <span className="ml-1 max-w-[45%] shrink-0 truncate opacity-70">{derivedHeader.secondary}</span> + <span className="ml-1 min-w-0 shrink truncate opacity-70">{derivedHeader.secondary}</span> )} </span> )} diff --git a/lib/src/stories/Baseboard.stories.tsx b/lib/src/stories/Baseboard.stories.tsx index 4455fdf..07f47cd 100644 --- a/lib/src/stories/Baseboard.stories.tsx +++ b/lib/src/stories/Baseboard.stories.tsx @@ -1,6 +1,10 @@ import type { Meta, StoryObj } from '@storybook/react'; import { Baseboard } from '../components/Baseboard'; import type { DooredItem } from '../components/Wall'; +import { createTerminalPaneState, type TerminalPaneState } from '../lib/terminal-state'; + +const BASE_TIME = 1_700_000_000_000; + const makeItem = (id: string, title: string): DooredItem => ({ id, title, @@ -11,14 +15,27 @@ const makeItem = (id: string, title: string): DooredItem => ({ layoutAtMinimizeSignature: '', }); -function withState(byId: Record<string, Record<string, unknown>>) { +function withState(items: DooredItem[], byId: Record<string, Record<string, unknown>>) { return { primedSessionState: { byId, }, + primedTerminalState: { + byId: Object.fromEntries(items.map((item, index) => [item.id, userTitleState(item.title, index)])), + }, }; } +function userTitleState(title: string, index: number): TerminalPaneState { + return createTerminalPaneState({ + title: { + title, + source: 'user', + updatedAt: BASE_TIME + index, + }, + }); +} + function BaseboardStory({ items }: { items: DooredItem[] }) { return ( <div className="bg-app-bg" style={{ width: '100%' }}> @@ -38,11 +55,34 @@ const meta: Meta<typeof BaseboardStory> = { export default meta; type Story = StoryObj<typeof BaseboardStory>; +const oneRingingDoorItems = [makeItem('p1', 'build-server')]; +const mixedDoorStateItems = [ + makeItem('p1', 'dev-server'), + makeItem('p2', 'test-runner'), + makeItem('p3', 'logs'), + makeItem('p4', 'notarization'), +]; +const overflowWithRingingDoorItems = [ + makeItem('p1', 'frontend-dev'), + makeItem('p2', 'backend-api'), + makeItem('p3', 'database-migrations'), + makeItem('p4', 'test-runner'), + makeItem('p5', 'log-aggregator'), + makeItem('p6', 'build-pipeline'), + makeItem('p7', 'monitoring'), + makeItem('p8', 'linter'), +]; +const extremeTitleWithBothIndicatorsItems = [ + makeItem('p1', 'short'), + makeItem('p2', 'my-extremely-long-running-background-process-with-a-very-descriptive-name'), + makeItem('p3', 'another'), +]; + export const OneRingingDoor: Story = { args: { - items: [makeItem('p1', 'build-server')], + items: oneRingingDoorItems, }, - parameters: withState({ + parameters: withState(oneRingingDoorItems, { p1: { status: 'ALERT_RINGING', @@ -53,14 +93,9 @@ export const OneRingingDoor: Story = { export const MixedDoorStates: Story = { args: { - items: [ - makeItem('p1', 'dev-server'), - makeItem('p2', 'test-runner'), - makeItem('p3', 'logs'), - makeItem('p4', 'notarization'), - ], + items: mixedDoorStateItems, }, - parameters: withState({ + parameters: withState(mixedDoorStateItems, { p1: { status: 'NOTHING_TO_SHOW', @@ -86,18 +121,9 @@ export const MixedDoorStates: Story = { export const OverflowWithRingingDoor: Story = { args: { - items: [ - makeItem('p1', 'frontend-dev'), - makeItem('p2', 'backend-api'), - makeItem('p3', 'database-migrations'), - makeItem('p4', 'test-runner'), - makeItem('p5', 'log-aggregator'), - makeItem('p6', 'build-pipeline'), - makeItem('p7', 'monitoring'), - makeItem('p8', 'linter'), - ], + items: overflowWithRingingDoorItems, }, - parameters: withState({ + parameters: withState(overflowWithRingingDoorItems, { p2: { status: 'NOTHING_TO_SHOW', @@ -125,13 +151,9 @@ export const OverflowWithRingingDoor: Story = { export const ExtremeTitleWithBothIndicators: Story = { args: { - items: [ - makeItem('p1', 'short'), - makeItem('p2', 'my-extremely-long-running-background-process-with-a-very-descriptive-name'), - makeItem('p3', 'another'), - ], + items: extremeTitleWithBothIndicatorsItems, }, - parameters: withState({ + parameters: withState(extremeTitleWithBothIndicatorsItems, { p2: { status: 'ALERT_RINGING', From 4c34b726f87166d7eb367d61dcb9a2c86c2cacb1 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Fri, 8 May 2026 15:25:52 -0700 Subject: [PATCH 13/50] Skip late process CWD updates for disposed panes Why: getCwd() resolves async after spawn; if the session is disposed before then, updateCwdIfAllowed would resurrect a phantom pane state that no listener cleans up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- lib/src/lib/terminal-state-store.test.ts | 7 +++++++ lib/src/lib/terminal-state-store.ts | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/src/lib/terminal-state-store.test.ts b/lib/src/lib/terminal-state-store.test.ts index 0f9a2f4..8113169 100644 --- a/lib/src/lib/terminal-state-store.test.ts +++ b/lib/src/lib/terminal-state-store.test.ts @@ -1,7 +1,9 @@ import { afterEach, describe, expect, it } from 'vitest'; import { applyTerminalSemanticEvents, + fillTerminalProcessCwd, getTerminalPaneState, + getTerminalPaneStateSnapshot, recordTerminalOutput, recordTerminalOutputByPtyId, recordTerminalUserInput, @@ -54,6 +56,11 @@ describe('terminal semantic state store command input fallback', () => { expect(getTerminalPaneState('pane').currentCommand?.displayCommand).toBe('lazygit'); }); + it('does not resurrect a disposed pane when a late process CWD arrives', () => { + fillTerminalProcessCwd('pane', '/Users/me/project'); + expect(getTerminalPaneStateSnapshot().has('pane')).toBe(false); + }); + it('records PTY fallback state under the current pane after a swap', () => { registry.set('pane-b', { ptyId: 'pane-a' } as unknown as TerminalEntry); diff --git a/lib/src/lib/terminal-state-store.ts b/lib/src/lib/terminal-state-store.ts index b3401c3..f919039 100644 --- a/lib/src/lib/terminal-state-store.ts +++ b/lib/src/lib/terminal-state-store.ts @@ -171,7 +171,8 @@ export function swapTerminalPaneStates(idA: string, idB: string): void { } function updateCwdIfAllowed(id: string, cwd: CwdState): void { - const current = paneStates.get(id) ?? createTerminalPaneState(); + const current = paneStates.get(id); + if (!current) return; const currentSource = current.cwd?.source; if (currentSource && currentSource !== 'manual' && currentSource !== 'process') return; paneStates.set(id, { ...current, cwd }); From b9b5996621b3eb9f2affb82f36762957acad7017 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Fri, 8 May 2026 15:26:36 -0700 Subject: [PATCH 14/50] Reject sentinel labels as user-pinned titles Why: the rename input now defaults to the derived header label (e.g. <idle> or <unnamed>), so pressing Enter without editing would pin the sentinel as a permanent user title that overrides everything. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- lib/src/lib/terminal-state-store.test.ts | 13 +++++++++++++ lib/src/lib/terminal-state-store.ts | 6 +++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/lib/src/lib/terminal-state-store.test.ts b/lib/src/lib/terminal-state-store.test.ts index 8113169..dbe5a26 100644 --- a/lib/src/lib/terminal-state-store.test.ts +++ b/lib/src/lib/terminal-state-store.test.ts @@ -9,8 +9,10 @@ import { recordTerminalUserInput, recordTerminalUserInputByPtyId, removeTerminalPaneState, + setTerminalUserTitle, } from './terminal-state-store'; import { registry, type TerminalEntry } from './terminal-store'; +import { DEFAULT_IDLE_TITLE, UNNAMED_PANEL_TITLE } from './terminal-state'; describe('terminal semantic state store command input fallback', () => { afterEach(() => { @@ -61,6 +63,17 @@ describe('terminal semantic state store command input fallback', () => { expect(getTerminalPaneStateSnapshot().has('pane')).toBe(false); }); + it('refuses to pin sentinel labels as a user title', () => { + setTerminalUserTitle('pane', DEFAULT_IDLE_TITLE); + setTerminalUserTitle('pane', UNNAMED_PANEL_TITLE); + setTerminalUserTitle('pane', ' '); + + expect(getTerminalPaneState('pane').titleCandidates.user).toBeUndefined(); + + setTerminalUserTitle('pane', 'Production API'); + expect(getTerminalPaneState('pane').titleCandidates.user?.title).toBe('Production API'); + }); + it('records PTY fallback state under the current pane after a swap', () => { registry.set('pane-b', { ptyId: 'pane-a' } as unknown as TerminalEntry); diff --git a/lib/src/lib/terminal-state-store.ts b/lib/src/lib/terminal-state-store.ts index f919039..80988a4 100644 --- a/lib/src/lib/terminal-state-store.ts +++ b/lib/src/lib/terminal-state-store.ts @@ -2,7 +2,9 @@ import { createTerminalPaneState, cwdFromManualPath, cwdFromProcessPath, + DEFAULT_IDLE_TITLE, reduceTerminalState, + UNNAMED_PANEL_TITLE, type CwdState, type TerminalPaneState, type TerminalSemanticEvent, @@ -120,9 +122,11 @@ export function recordTerminalOutputByPtyId(ptyId: string, output: string): void recordTerminalOutput(resolvePaneStateIdByPtyId(ptyId), output); } +const RESERVED_USER_TITLES = new Set<string>([DEFAULT_IDLE_TITLE, UNNAMED_PANEL_TITLE]); + export function setTerminalUserTitle(id: string, title: string): void { const trimmed = title.trim(); - if (!trimmed) return; + if (!trimmed || RESERVED_USER_TITLES.has(trimmed)) return; const terminalTitle: TerminalTitle = { title: trimmed, source: 'user', From 54d1a8a05eaf6acb4b59bdb4f924387f2950f430 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Fri, 8 May 2026 15:28:50 -0700 Subject: [PATCH 15/50] Tighten the user-input prompt-return heuristic Why: the previous regex matched any line ending in $/#/%/>, which fires on TUI output (lazygit alt-screen render, progress lines like "step 1: 95% complete") and flickers the pane between running and idle. Now we strip alt-screen spans, require the prompt to start on a fresh line, and demand a path/user context signal before treating a trailing $/#/%/> + space as a returned prompt. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- lib/src/lib/terminal-state-store.test.ts | 17 ++++++++ lib/src/lib/terminal-state-store.ts | 50 +++++++++++++++++++++--- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/lib/src/lib/terminal-state-store.test.ts b/lib/src/lib/terminal-state-store.test.ts index dbe5a26..5ef954e 100644 --- a/lib/src/lib/terminal-state-store.test.ts +++ b/lib/src/lib/terminal-state-store.test.ts @@ -58,6 +58,23 @@ describe('terminal semantic state store command input fallback', () => { expect(getTerminalPaneState('pane').currentCommand?.displayCommand).toBe('lazygit'); }); + it('does not match command output that merely ends in a prompt-shaped suffix', () => { + recordTerminalUserInput('pane', 'lazygit\r'); + recordTerminalOutput('pane', '\r\nstep 1: 50% complete\r\nstep 2: 95% \r\n'); + + expect(getTerminalPaneState('pane').currentCommand?.displayCommand).toBe('lazygit'); + }); + + it('ignores prompt-shaped lines emitted inside the alt-screen buffer', () => { + recordTerminalUserInput('pane', 'lazygit\r'); + recordTerminalOutput( + 'pane', + '\x1b[?1049h\r\nuser@host repo $ rendered by tui\r\nmore tui output', + ); + + expect(getTerminalPaneState('pane').currentCommand?.displayCommand).toBe('lazygit'); + }); + it('does not resurrect a disposed pane when a late process CWD arrives', () => { fillTerminalProcessCwd('pane', '/Users/me/project'); expect(getTerminalPaneStateSnapshot().has('pane')).toBe(false); diff --git a/lib/src/lib/terminal-state-store.ts b/lib/src/lib/terminal-state-store.ts index 80988a4..35bc4af 100644 --- a/lib/src/lib/terminal-state-store.ts +++ b/lib/src/lib/terminal-state-store.ts @@ -188,12 +188,50 @@ function resolvePaneStateIdByPtyId(ptyId: string): string { } function looksLikeReturnedShellPrompt(output: string): boolean { - const text = stripTerminalControls(output).replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - const lines = text.split('\n'); - const lastLine = lines[lines.length - 1]?.trimStart() ?? ''; - if (!lastLine || lastLine.length > 200) return false; - if (/^(?:PS\s+.+>|.+[$#%>❯λ])\s?$/.test(lastLine)) return true; - return /^[➜❯λ]\s+.+\s$/.test(lines[lines.length - 1] ?? ''); + const visible = stripAltScreenSpans(output); + const text = stripTerminalControls(visible).replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + // Real prompts come on a fresh line. Requiring a leading newline rejects + // arbitrary command output that happens to end with a prompt-like character. + const newlineIndex = text.lastIndexOf('\n'); + if (newlineIndex === -1) return false; + const lastLine = text.slice(newlineIndex + 1).trimStart(); + if (lastLine.length < 3 || lastLine.length > 200) return false; + // PowerShell `PS C:\path>` (with optional trailing space). + if (/^PS\s+\S.*>\s?$/.test(lastLine)) return true; + // Arrow-style prompts (oh-my-zsh, starship, fish defaults). + if (/^[➜❯λ]\s+\S/.test(lastLine) && lastLine.endsWith(' ')) return true; + // Generic shell prompts: require a path/user context signal AND a trailing + // prompt char + space. The context check rejects lines like "step 1: done" + // or "loading 95% complete" that happen to end in a punctuation mark. + if (!/[\/~@:]/.test(lastLine)) return false; + return /[$#%>]\s$/.test(lastLine); +} + +function stripAltScreenSpans(input: string): string { + // Drop content between alt-screen enter (`\x1b[?1049h`) and exit (`\x1b[?1049l`). + // Fullscreen TUIs (vim, lazygit, less) render into the alt buffer, which is + // not the user's prompt, so anything inside that span must not match. + let result = ''; + let cursor = 0; + let inAlt = false; + while (cursor < input.length) { + if (!inAlt) { + const next = input.indexOf('\x1b[?1049h', cursor); + if (next === -1) { + result += input.slice(cursor); + break; + } + result += input.slice(cursor, next); + cursor = next + 8; + inAlt = true; + } else { + const next = input.indexOf('\x1b[?1049l', cursor); + if (next === -1) return result; + cursor = next + 8; + inAlt = false; + } + } + return result; } function stripTerminalControls(input: string): string { From 5c34e56b8d570d900703ff440a00679dea431f95 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Fri, 8 May 2026 15:29:14 -0700 Subject: [PATCH 16/50] Drop unused options arg from idleLabel Why: idleLabel never reads the {shellName} options it accepts. The _options parameter is dead weight that obscures the function's contract; either implement it or remove it. Removing for now. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- lib/src/lib/terminal-state.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/lib/terminal-state.ts b/lib/src/lib/terminal-state.ts index 43afbab..9750df1 100644 --- a/lib/src/lib/terminal-state.ts +++ b/lib/src/lib/terminal-state.ts @@ -769,10 +769,10 @@ function headerPrimary(pane: TerminalPaneState, options: HeaderOptions): string if (terminalTitle) return terminalTitle; if (pane.currentCommand) return pane.currentCommand.displayCommand; if (pane.activity.kind === 'finished' && pane.lastCommand) return pane.lastCommand.displayCommand; - return idleLabel(pane, options); + return idleLabel(pane); } -function idleLabel(pane: TerminalPaneState, _options: { shellName?: string } = {}): string { +function idleLabel(pane: TerminalPaneState): string { const userTitle = titleCandidateForSource(pane, 'user')?.title.trim(); if (userTitle) return userTitle; return DEFAULT_IDLE_TITLE; From b824cbc3655901bc7214d4c0a306d7180b1f8cc3 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Fri, 8 May 2026 15:29:39 -0700 Subject: [PATCH 17/50] Document semantic stripping on pty:replay in adapters Why: pty:replay used to forward raw bytes to xterm; it now runs the protocol parser to extract CWD/prompt/title state, which means OSCs recognized by the parser are silently stripped. Future readers adding xterm-side OSC handling need to know this happens. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- lib/src/lib/platform/vscode-adapter.ts | 4 ++++ standalone/src/tauri-adapter.ts | 3 +++ 2 files changed, 7 insertions(+) diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index 26c6236..e4e6912 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -51,6 +51,10 @@ export class VSCodeAdapter implements PlatformAdapter { handler({ ptys: msg.ptys }); } } else if (msg.type === 'pty:replay') { + // Replay arrives as raw buffered output. Run it through the protocol + // parser so semantic OSCs (CWD, prompt, title) repopulate pane state + // and are stripped before xterm sees them, mirroring live pty:data. + // See docs/specs/vscode.md for the replay invariants. const parser = this.getReplayProtocolParser(msg.id); const parsed = parser.process(msg.data); applyTerminalSemanticEventsByPtyId(msg.id, collectTerminalSemanticEvents(parsed.events)); diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index 67907eb..c7c9b66 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -93,6 +93,9 @@ export class TauriAdapter implements PlatformAdapter { this.unlistenFns.push( await listen<{ id: string; data: string }>("pty:replay", (event) => { + // Replay arrives as raw buffered output. Run it through the protocol + // parser so semantic OSCs (CWD, prompt, title) repopulate pane state + // and are stripped before xterm sees them, mirroring live pty:data. const { id, data } = event.payload; const parsed = this.getProtocolParser(id).process(data); applyTerminalSemanticEventsByPtyId(id, collectTerminalSemanticEvents(parsed.events)); From cdfe81d81c187d2b1de51760b48a0a98a1a72694 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Fri, 8 May 2026 16:11:27 -0700 Subject: [PATCH 18/50] Avoid raw PTY semantic cleanup in VS Code adapter --- lib/src/lib/platform/vscode-adapter.test.ts | 4 ++-- lib/src/lib/platform/vscode-adapter.ts | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/src/lib/platform/vscode-adapter.test.ts b/lib/src/lib/platform/vscode-adapter.test.ts index 456fc5c..87df8c9 100644 --- a/lib/src/lib/platform/vscode-adapter.test.ts +++ b/lib/src/lib/platform/vscode-adapter.test.ts @@ -45,12 +45,12 @@ describe('VSCodeAdapter PTY exit handling', () => { expect(terminalStateStoreMocks.removeTerminalPaneState).not.toHaveBeenCalled(); }); - it('clears semantic pane state when the adapter explicitly kills a PTY', () => { + it('lets lifecycle cleanup remove semantic pane state after explicitly killing a PTY', () => { const adapter = new VSCodeAdapter(); adapter.killPty('pane-1'); - expect(terminalStateStoreMocks.removeTerminalPaneState).toHaveBeenCalledWith('pane-1'); + expect(terminalStateStoreMocks.removeTerminalPaneState).not.toHaveBeenCalled(); expect(postMessage).toHaveBeenCalledWith({ type: 'pty:kill', id: 'pane-1' }); }); }); diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index e4e6912..0b110f0 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -6,7 +6,6 @@ import { } from '../terminal-protocol'; import { applyTerminalSemanticEventsByPtyId, - removeTerminalPaneState, } from '../terminal-state-store'; export class VSCodeAdapter implements PlatformAdapter { @@ -148,7 +147,6 @@ export class VSCodeAdapter implements PlatformAdapter { killPty(id: string): void { this.replayProtocolParsers.delete(id); - removeTerminalPaneState(id); this.vscode.postMessage({ type: 'pty:kill', id }); } From 4884ab5bc886aa4baf43d50611680b7529eea4f9 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Fri, 8 May 2026 16:12:18 -0700 Subject: [PATCH 19/50] Keep standalone semantic state on PTY exit --- standalone/src/tauri-adapter.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index c7c9b66..03d11be 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -76,7 +76,6 @@ export class TauriAdapter implements PlatformAdapter { await listen<{ id: string; exitCode: number }>("pty:exit", (event) => { this.alertManager.onExit(event.payload.id); this.protocolParsers.delete(event.payload.id); - removeTerminalPaneState(event.payload.id); for (const handler of this.exitHandlers) { handler(event.payload); } From fa78920c0ef5b0feb6d3b6d842459347808a3f5f Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Fri, 8 May 2026 16:14:19 -0700 Subject: [PATCH 20/50] Preserve semantic event timestamp order --- docs/specs/terminal-state.md | 5 ++-- lib/src/lib/terminal-protocol.test.ts | 43 +++++++++++++++++++++++++++ lib/src/lib/terminal-protocol.ts | 36 ++++++++++++++++++++-- lib/src/lib/terminal-state.ts | 4 +-- 4 files changed, 81 insertions(+), 7 deletions(-) diff --git a/docs/specs/terminal-state.md b/docs/specs/terminal-state.md index fc7f4de..7b71c55 100644 --- a/docs/specs/terminal-state.md +++ b/docs/specs/terminal-state.md @@ -107,12 +107,13 @@ type TerminalSemanticEvent = | { type: "promptStart" } | { type: "promptEnd" } | { type: "commandLine"; commandLine: string } - | { type: "commandStart"; source?: CommandRun["source"] } + | { type: "commandStart"; source?: CommandRun["source"]; startedAt?: number } | { type: "commandFinish"; exitCode?: number } | { type: "title"; title: TerminalTitle }; ``` Feature code consumes `TerminalPaneState` or `TerminalSemanticEvent`, never raw OSC sequences. +Protocol-derived semantic events are timestamped in stream order before they reach the reducer, so command-start boundaries and title candidates from the same PTY chunk remain comparable even when they were parsed in the same millisecond. ## Supported OSC Inputs @@ -163,7 +164,7 @@ The parser accepts both BEL and ST terminators and handles split chunks. Unsuppo - `commandLine` stores `pendingCommandLine`. - User-entered prompt input may also store `pendingCommandLine` as an explicit fallback before an OSC 133/633 command-start boundary. This fallback is only used while the shell is idle/editing; foreground-program input is ignored. If the submitted line is non-empty, the input fallback may create a `currentCommand` immediately with `source: "user_input"` so shells without command-start integration still show the active command. - The typed-command fallback resolves the current Session id from the PTY id before recording input or prompt-looking output, so drag-to-swap moves the fallback state with the visible pane. -- `commandStart` creates `currentCommand`, snapshots `cwdAtStart`, clears `pendingCommandLine`, and sets `{ kind: "running" }`. +- `commandStart` creates `currentCommand`, snapshots `cwdAtStart`, uses `event.startedAt` when present, clears `pendingCommandLine`, and sets `{ kind: "running" }`. - `commandFinish` moves `currentCommand` to `lastCommand`, stores `finishedAt`/`exitCode`, clears `currentCommand`, and sets `{ kind: "finished", exitCode }`. - `title` updates `title` and the per-source entry in `titleCandidates`. Later OSC title events do not erase earlier user, shell, or notification channel candidates from other sources. - A later prompt signal moves the pane out of `finished`. If a command was started from `user_input` and no explicit `commandFinish` arrived, the prompt signal also clears `currentCommand` so the header returns to `<idle>`. diff --git a/lib/src/lib/terminal-protocol.test.ts b/lib/src/lib/terminal-protocol.test.ts index 4374770..10dc1a5 100644 --- a/lib/src/lib/terminal-protocol.test.ts +++ b/lib/src/lib/terminal-protocol.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { collectTerminalSemanticEvents, ITERM2_DEVICE_ATTRIBUTES_RESPONSE, TerminalProtocolParser } from './terminal-protocol'; +import { createTerminalPaneState, deriveHeader, reduceTerminalState, type TerminalSemanticEvent } from './terminal-state'; describe('TerminalProtocolParser', () => { it('parses and strips standalone terminal bells', () => { @@ -204,6 +205,40 @@ describe('TerminalProtocolParser', () => { ]); }); + it('preserves stream order when collecting command starts and title candidates', () => { + const staleTitleParser = new TerminalProtocolParser(); + const staleTitleEvents = collectTerminalSemanticEvents( + staleTitleParser.process('\x1b]633;E;npm test\x07\x1b]0;zsh\x07\x1b]633;C\x07').events, + { now: () => 100 }, + ); + const staleTitle = staleTitleEvents.find((event) => event.type === 'title'); + const staleCommandStart = staleTitleEvents.find((event) => event.type === 'commandStart'); + + expect(staleTitle?.type === 'title' ? staleTitle.title.updatedAt : null) + .toBeLessThan(staleCommandStart?.type === 'commandStart' ? staleCommandStart.startedAt ?? 0 : 0); + const staleTitleState = reduceSemanticEvents(staleTitleEvents); + expect(deriveHeader(staleTitleState, [staleTitleState])).toEqual({ + primary: 'npm test', + status: 'running', + }); + + const freshTitleParser = new TerminalProtocolParser(); + const freshTitleEvents = collectTerminalSemanticEvents( + freshTitleParser.process('\x1b]633;E;npm test\x07\x1b]633;C\x07\x1b]0;vitest\x07').events, + { now: () => 100 }, + ); + const freshTitle = freshTitleEvents.find((event) => event.type === 'title'); + const freshCommandStart = freshTitleEvents.find((event) => event.type === 'commandStart'); + + expect(freshTitle?.type === 'title' ? freshTitle.title.updatedAt : 0) + .toBeGreaterThan(freshCommandStart?.type === 'commandStart' ? freshCommandStart.startedAt ?? 0 : 0); + const freshTitleState = reduceSemanticEvents(freshTitleEvents); + expect(deriveHeader(freshTitleState, [freshTitleState])).toEqual({ + primary: 'vitest', + status: 'running', + }); + }); + it('decodes OSC 633 command lines without including the optional nonce', () => { const parser = new TerminalProtocolParser(); @@ -303,3 +338,11 @@ describe('TerminalProtocolParser', () => { expect(parser.process('31mred')).toEqual({ visibleData: '\x1b[31mred', events: [] }); }); }); + +function reduceSemanticEvents(events: TerminalSemanticEvent[]) { + let state = createTerminalPaneState(); + for (const event of events) { + state = reduceTerminalState(state, event, { now: () => 999, createId: () => 'cmd-1' }); + } + return state; +} diff --git a/lib/src/lib/terminal-protocol.ts b/lib/src/lib/terminal-protocol.ts index ac7638d..1952614 100644 --- a/lib/src/lib/terminal-protocol.ts +++ b/lib/src/lib/terminal-protocol.ts @@ -230,15 +230,19 @@ export function collectTerminalProtocolResponses(events: TerminalProtocolEvent[] return events.flatMap((event) => (event.kind === 'response' ? [event.data] : [])); } -export function collectTerminalSemanticEvents(events: TerminalProtocolEvent[]): TerminalSemanticEvent[] { +export function collectTerminalSemanticEvents( + events: TerminalProtocolEvent[], + options: { now?: () => number } = {}, +): TerminalSemanticEvent[] { const semanticEvents: TerminalSemanticEvent[] = []; + const nextTimestamp = createOrderedEventTimestamp(options.now ?? Date.now); for (const event of events) { if (event.kind === 'semantic') { - semanticEvents.push(event.event); + semanticEvents.push(timestampSemanticEvent(event.event, nextTimestamp)); continue; } if (event.kind !== 'notification') continue; - const title = terminalTitleFromNotification(event.notification); + const title = terminalTitleFromNotification(event.notification, nextTimestamp()); if (!title) continue; semanticEvents.push({ type: 'title', @@ -248,6 +252,32 @@ export function collectTerminalSemanticEvents(events: TerminalProtocolEvent[]): return semanticEvents; } +function createOrderedEventTimestamp(now: () => number): () => number { + let lastTimestamp = Number.NEGATIVE_INFINITY; + return () => { + const candidate = now(); + const timestamp = candidate > lastTimestamp ? candidate : lastTimestamp + 0.001; + lastTimestamp = timestamp; + return timestamp; + }; +} + +function timestampSemanticEvent( + event: TerminalSemanticEvent, + nextTimestamp: () => number, +): TerminalSemanticEvent { + switch (event.type) { + case 'cwd': + return { ...event, cwd: { ...event.cwd, updatedAt: nextTimestamp() } }; + case 'commandStart': + return { ...event, startedAt: nextTimestamp() }; + case 'title': + return { ...event, title: { ...event.title, updatedAt: nextTimestamp() } }; + default: + return event; + } +} + function stripStandaloneBells(segment: string, events: TerminalProtocolEvent[]): string { const bellIndex = segment.indexOf('\x07'); if (bellIndex === -1) return segment; diff --git a/lib/src/lib/terminal-state.ts b/lib/src/lib/terminal-state.ts index 9750df1..b5adfb9 100644 --- a/lib/src/lib/terminal-state.ts +++ b/lib/src/lib/terminal-state.ts @@ -76,7 +76,7 @@ export type TerminalSemanticEvent = | { type: 'promptStart' } | { type: 'promptEnd' } | { type: 'commandLine'; commandLine: string } - | { type: 'commandStart'; source?: CommandRunSource } + | { type: 'commandStart'; source?: CommandRunSource; startedAt?: number } | { type: 'commandFinish'; exitCode?: number } | { type: 'title'; title: TerminalTitle }; @@ -193,7 +193,7 @@ export function reduceTerminalState( rawCommandLine: raw, displayCommand: raw ? summarizeCommandLine(raw) : deriveFallbackCommandTitle(state), cwdAtStart: state.cwd, - startedAt: now(), + startedAt: event.startedAt ?? now(), source, }, activity: { kind: 'running' }, From ba657c56381361a5b32e1e707d18bc3276090afc Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Fri, 8 May 2026 16:15:19 -0700 Subject: [PATCH 21/50] Resolve process CWDs through PTY ownership --- docs/specs/terminal-state.md | 1 + lib/src/lib/terminal-lifecycle.ts | 4 ++-- lib/src/lib/terminal-registry.ts | 1 + lib/src/lib/terminal-state-store.test.ts | 14 ++++++++++++++ lib/src/lib/terminal-state-store.ts | 4 ++++ 5 files changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/specs/terminal-state.md b/docs/specs/terminal-state.md index 7b71c55..2023dfb 100644 --- a/docs/specs/terminal-state.md +++ b/docs/specs/terminal-state.md @@ -178,6 +178,7 @@ CWD fallback order is: 4. `null` Process-derived CWD may fill `null` or replace manual/restored CWD, but it must not overwrite explicit OSC CWD. +Asynchronous process CWD query results are applied through PTY-id resolution, so a result that arrives after `swap` updates the Session that currently owns that PTY. ## Header Derivation diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index 2a15117..eee6b2f 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -27,7 +27,7 @@ import { import { getTerminalTheme, paintTerminalHost, startThemeObserver } from './terminal-theme'; import { ensureTerminalPaneState, - fillTerminalProcessCwd, + fillTerminalProcessCwdByPtyId, recordTerminalOutputByPtyId, recordTerminalUserInputByPtyId, removeTerminalPaneState, @@ -39,7 +39,7 @@ import { import { UNNAMED_PANEL_TITLE } from './terminal-state'; function seedProcessCwdAfterSpawn(id: string): void { - void getPlatform().getCwd(id).then((cwd) => fillTerminalProcessCwd(id, cwd)); + void getPlatform().getCwd(id).then((cwd) => fillTerminalProcessCwdByPtyId(id, cwd)); } function createXtermHost(): { terminal: Terminal; fit: FitAddon; element: HTMLDivElement } { diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index 4e8517f..12d9560 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -58,6 +58,7 @@ export { applyTerminalSemanticEventsByPtyId, ensureTerminalPaneState, fillTerminalProcessCwd, + fillTerminalProcessCwdByPtyId, getTerminalPaneState, getTerminalPaneStateSnapshot, removeTerminalPaneState, diff --git a/lib/src/lib/terminal-state-store.test.ts b/lib/src/lib/terminal-state-store.test.ts index 5ef954e..efb3463 100644 --- a/lib/src/lib/terminal-state-store.test.ts +++ b/lib/src/lib/terminal-state-store.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { applyTerminalSemanticEvents, fillTerminalProcessCwd, + fillTerminalProcessCwdByPtyId, getTerminalPaneState, getTerminalPaneStateSnapshot, recordTerminalOutput, @@ -108,4 +109,17 @@ describe('terminal semantic state store command input fallback', () => { expect(getTerminalPaneState('pane-b').currentCommand).toBeNull(); expect(getTerminalPaneState('pane-b').activity).toEqual({ kind: 'editing' }); }); + + it('records process CWD under the current pane after a swap', () => { + registry.set('pane-b', { ptyId: 'pane-a' } as unknown as TerminalEntry); + applyTerminalSemanticEvents('pane-b', [{ type: 'promptStart' }]); + + fillTerminalProcessCwdByPtyId('pane-a', '/Users/me/project'); + + expect(getTerminalPaneState('pane-a').cwd).toBeNull(); + expect(getTerminalPaneState('pane-b').cwd).toMatchObject({ + path: '/Users/me/project', + source: 'process', + }); + }); }); diff --git a/lib/src/lib/terminal-state-store.ts b/lib/src/lib/terminal-state-store.ts index 35bc4af..1831dae 100644 --- a/lib/src/lib/terminal-state-store.ts +++ b/lib/src/lib/terminal-state-store.ts @@ -151,6 +151,10 @@ export function fillTerminalProcessCwd(id: string, path: string | null | undefin updateCwdIfAllowed(id, cwd); } +export function fillTerminalProcessCwdByPtyId(ptyId: string, path: string | null | undefined): void { + fillTerminalProcessCwd(resolvePaneStateIdByPtyId(ptyId), path); +} + export function swapTerminalPaneStates(idA: string, idB: string): void { const stateA = paneStates.get(idA); const stateB = paneStates.get(idB); From 8f8dd80b61dcaf1fadd11a10d750d1033d6ee898 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Fri, 8 May 2026 16:16:25 -0700 Subject: [PATCH 22/50] Seed resumed door titles from saved state --- docs/specs/layout.md | 2 +- docs/specs/vscode.md | 2 +- lib/src/lib/reconnect.test.ts | 30 ++++++++++++++++++++++++++++++ lib/src/lib/reconnect.ts | 9 ++++----- 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index adc5baf..2f1c7de 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -278,7 +278,7 @@ Layout, scrollback, cwd, minimized items, and alert state are saved to persisten Saved snapshots are read through `readPersistedSession()`, which accepts the canonical object shape and defensively parses a JSON-stringified blob before validation and migration. This keeps malformed storage inert while covering hosts that hand back serialized JSON instead of the parsed object. On startup, recovery is priority-based: -1. **Resume** (webview hidden/shown, live PTYs): request PTY list + replay data from platform, `resumeTerminal()` for each (500ms timeout). If the saved session covers every live PTY, restore the saved dockview layout when its visible panel set matches and reattach saved minimized items as doors. This still counts as a live resume when every live session is minimized, so recovery must not fall through to cold restore just because the visible `paneIds` list is empty. +1. **Resume** (webview hidden/shown, live PTYs): request PTY list + replay data from platform, `resumeTerminal()` for each (500ms timeout), and seed any saved pane or door title as the Session's user title. If the saved session covers every live PTY, restore the saved dockview layout when its visible panel set matches and reattach saved minimized items as doors. This still counts as a live resume when every live session is minimized, so recovery must not fall through to cold restore just because the visible `paneIds` list is empty. 2. **Restore** (app restart, cold start): restore layout from serialized dockview state, `restoreTerminal()` for each pane with saved cwd + scrollback, and spawn each PTY with the current default shell selection 3. **Fallback/manual pane creation**: when no saved layout can be safely applied, add multiple panes as splits from the previous pane rather than tabs, and spawn each PTY with the current default shell selection 4. **Empty state**: create a single new pane with the current default shell selection diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index ab9b4b7..9886b51 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -144,7 +144,7 @@ Both are capped at 1M chars per PTY. When the cap is reached, oldest chunks are 3. Extension responds with: - { type: 'pty:list', ptys: [{ id, alive, exitCode }] } // all owned PTYs - { type: 'pty:replay', id, data } // buffered output per PTY -4. Webview restores terminals from replay data, seeds any non-unnamed saved visible-pane titles as user titles, and resumes the live stream +4. Webview restores terminals from replay data, seeds any non-unnamed saved pane or door titles as user titles, and resumes the live stream 5. If the saved session covers those live PTYs, the frontend uses the saved dockview layout when its visible panels match and reattaches saved minimized doors; minimized PTYs are registered but remain doors instead of visible panes ``` diff --git a/lib/src/lib/reconnect.test.ts b/lib/src/lib/reconnect.test.ts index 614c7ec..14cec0e 100644 --- a/lib/src/lib/reconnect.test.ts +++ b/lib/src/lib/reconnect.test.ts @@ -111,6 +111,7 @@ describe('resumeOrRestore', () => { expect(terminalRegistryMocks.resumeTerminal).toHaveBeenCalledWith('pane-c', 'pane-c-replay', { alive: true, exitCode: undefined, + title: 'Pane C', }); }); @@ -134,6 +135,35 @@ describe('resumeOrRestore', () => { }); }); + it('seeds saved minimized door titles when resuming live PTYs', async () => { + const saved: PersistedSession = { + version: 3, + layout: { panels: {} }, + doors: [{ + id: 'pane-a', + title: 'Renamed Door', + neighborId: null, + direction: 'right', + remainingPaneIds: [], + layoutAtMinimize: { panels: {} }, + layoutAtMinimizeSignature: 'sig', + }], + panes: [ + { id: 'pane-a', title: 'Renamed Door', cwd: null, scrollback: null, resumeCommand: null }, + ], + }; + + await resumeOrRestore(createPlatform([ + { id: 'pane-a', alive: true }, + ], saved)); + + expect(terminalRegistryMocks.resumeTerminal).toHaveBeenCalledWith('pane-a', 'pane-a-replay', { + alive: true, + exitCode: undefined, + title: 'Renamed Door', + }); + }); + it('does not reuse a saved layout when live PTYs do not match saved panes', async () => { const saved: PersistedSession = { version: 3, diff --git a/lib/src/lib/reconnect.ts b/lib/src/lib/reconnect.ts index 6b2d868..99d5c2c 100644 --- a/lib/src/lib/reconnect.ts +++ b/lib/src/lib/reconnect.ts @@ -64,14 +64,14 @@ function resumeLiveSessions(platform: PlatformAdapter): Promise<ReconnectResult } const savedState = platform.getState(); - const savedVisibleTitles = getSavedVisiblePaneTitles(savedState, ptyList.map((pty) => pty.id)); + const savedTitles = getSavedPaneTitles(savedState, ptyList.map((pty) => pty.id)); const ids: string[] = []; for (const pty of ptyList) { const resumeInfo: { alive: boolean; exitCode?: number; title?: string } = { alive: pty.alive, exitCode: pty.exitCode, }; - const savedTitle = savedVisibleTitles.get(pty.id); + const savedTitle = savedTitles.get(pty.id); if (savedTitle !== undefined) resumeInfo.title = savedTitle; resumeTerminal(pty.id, replayBuffer.get(pty.id) ?? null, resumeInfo); ids.push(pty.id); @@ -94,15 +94,14 @@ function resumeLiveSessions(platform: PlatformAdapter): Promise<ReconnectResult }); } -function getSavedVisiblePaneTitles(savedState: unknown, liveIds: string[]): Map<string, string> { +function getSavedPaneTitles(savedState: unknown, liveIds: string[]): Map<string, string> { const saved = readPersistedSession(savedState); if (!saved || !Array.isArray(saved.panes)) return new Map(); const liveSet = new Set(liveIds); - const doorIds = new Set((saved.doors ?? []).map((item) => item.id)); const result = new Map<string, string>(); for (const pane of saved.panes) { - if (!liveSet.has(pane.id) || doorIds.has(pane.id)) continue; + if (!liveSet.has(pane.id)) continue; result.set(pane.id, pane.title); } return result; From 6770e0e33a1680fb9d15da137e6b803f369b724a Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Fri, 8 May 2026 16:52:08 -0700 Subject: [PATCH 23/50] Restore persisted cwd into semantic state --- lib/src/lib/terminal-state-store.test.ts | 13 +++++++++++++ lib/src/lib/terminal-state-store.ts | 13 ++++++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/lib/src/lib/terminal-state-store.test.ts b/lib/src/lib/terminal-state-store.test.ts index efb3463..b3b22a4 100644 --- a/lib/src/lib/terminal-state-store.test.ts +++ b/lib/src/lib/terminal-state-store.test.ts @@ -10,6 +10,8 @@ import { recordTerminalUserInput, recordTerminalUserInputByPtyId, removeTerminalPaneState, + resetTerminalPaneState, + seedTerminalManualCwd, setTerminalUserTitle, } from './terminal-state-store'; import { registry, type TerminalEntry } from './terminal-store'; @@ -81,6 +83,17 @@ describe('terminal semantic state store command input fallback', () => { expect(getTerminalPaneStateSnapshot().has('pane')).toBe(false); }); + it('seeds restored manual CWD after reset created a blank pane state', () => { + resetTerminalPaneState('pane'); + + seedTerminalManualCwd('pane', '/Users/me/project'); + + expect(getTerminalPaneState('pane').cwd).toMatchObject({ + path: '/Users/me/project', + source: 'manual', + }); + }); + it('refuses to pin sentinel labels as a user title', () => { setTerminalUserTitle('pane', DEFAULT_IDLE_TITLE); setTerminalUserTitle('pane', UNNAMED_PANEL_TITLE); diff --git a/lib/src/lib/terminal-state-store.ts b/lib/src/lib/terminal-state-store.ts index 1831dae..38a5816 100644 --- a/lib/src/lib/terminal-state-store.ts +++ b/lib/src/lib/terminal-state-store.ts @@ -137,11 +137,18 @@ export function setTerminalUserTitle(id: string, title: string): void { export function seedTerminalManualCwd(id: string, path: string | null | undefined): void { const cwd = path ? cwdFromManualPath(path) : null; - if (cwd && !paneStates.get(id)?.cwd) { - ensureTerminalPaneState(id, { cwd }); - } else { + const current = paneStates.get(id); + if (!cwd) { ensureTerminalPaneState(id); + return; + } + if (!current) { + ensureTerminalPaneState(id, { cwd }); + return; } + if (current.cwd) return; + paneStates.set(id, { ...current, cwd }); + notifyTerminalPaneStateListeners(); } export function fillTerminalProcessCwd(id: string, path: string | null | undefined): void { From eade30f1af6c9d813c4f0c3154b688cede7a6639 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Fri, 8 May 2026 16:53:06 -0700 Subject: [PATCH 24/50] Keep semantic state out of adapter kills --- lib/src/lib/platform/fake-adapter.test.ts | 27 +++++++++++++++++++++++ lib/src/lib/platform/fake-adapter.ts | 2 -- standalone/src/tauri-adapter.ts | 2 -- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/lib/src/lib/platform/fake-adapter.test.ts b/lib/src/lib/platform/fake-adapter.test.ts index bc007b2..c657dd1 100644 --- a/lib/src/lib/platform/fake-adapter.test.ts +++ b/lib/src/lib/platform/fake-adapter.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { FakePtyAdapter, type FakeScenario } from './fake-adapter'; import { ITERM2_DEVICE_ATTRIBUTES_RESPONSE } from '../terminal-protocol'; +import { applyTerminalSemanticEvents, getTerminalPaneState, removeTerminalPaneState } from '../terminal-state-store'; +import { registry, type TerminalEntry } from '../terminal-store'; describe('FakePtyAdapter', () => { beforeEach(() => { @@ -8,6 +10,9 @@ describe('FakePtyAdapter', () => { }); afterEach(() => { + removeTerminalPaneState('pane-a'); + removeTerminalPaneState('pane-b'); + registry.delete('pane-b'); vi.useRealTimers(); }); @@ -110,6 +115,28 @@ describe('FakePtyAdapter', () => { expect(exitEvents).toEqual([{ id: 'nope', exitCode: 0 }]); }); + it('does not remove semantic state for the pane currently owning a killed PTY', () => { + const { adapter } = createAdapter(); + registry.set('pane-b', { ptyId: 'pane-a' } as unknown as TerminalEntry); + applyTerminalSemanticEvents('pane-b', [ + { type: 'cwd', cwd: { + path: '/Users/me/project', + pathKind: 'posix', + isRemote: false, + source: 'manual', + updatedAt: 1, + } }, + ]); + adapter.spawnPty('pane-a'); + + adapter.killPty('pane-a'); + + expect(getTerminalPaneState('pane-b').cwd).toMatchObject({ + path: '/Users/me/project', + source: 'manual', + }); + }); + it('echo stops after kill', () => { const { adapter, dataEvents } = createAdapter(); adapter.spawnPty('t1'); diff --git a/lib/src/lib/platform/fake-adapter.ts b/lib/src/lib/platform/fake-adapter.ts index 4679601..5a2be8a 100644 --- a/lib/src/lib/platform/fake-adapter.ts +++ b/lib/src/lib/platform/fake-adapter.ts @@ -8,7 +8,6 @@ import { } from '../terminal-protocol'; import { applyTerminalSemanticEventsByPtyId, - removeTerminalPaneState, } from '../terminal-state-store'; export interface FakeScenario { @@ -158,7 +157,6 @@ export class FakePtyAdapter implements PlatformAdapter { this.terminalSizes.delete(id); this.inputHandlers.delete(id); this.protocolParsers.delete(id); - removeTerminalPaneState(id); for (const handler of this.exitHandlers) { handler({ id, exitCode: 0 }); } diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index 03d11be..559bee3 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -10,7 +10,6 @@ import { } from "mouseterm-lib/lib/terminal-protocol"; import { applyTerminalSemanticEventsByPtyId, - removeTerminalPaneState, } from "mouseterm-lib/lib/terminal-state-store"; function invoke(cmd: string, args?: Record<string, unknown>): void { @@ -145,7 +144,6 @@ export class TauriAdapter implements PlatformAdapter { killPty(id: string): void { this.protocolParsers.delete(id); - removeTerminalPaneState(id); invoke("pty_kill", { id }); } From df2cfb0cff4071ce28cb01b25b9d180d6f87e39f Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Fri, 8 May 2026 16:56:10 -0700 Subject: [PATCH 25/50] Do not persist derived door titles --- docs/specs/layout.md | 4 +- lib/src/components/Wall.tsx | 19 +++------ lib/src/lib/session-save.test.ts | 71 ++++++++++++++++++++++++++++++++ lib/src/lib/session-save.ts | 23 +++++++++-- 4 files changed, 98 insertions(+), 19 deletions(-) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 2f1c7de..7910def 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -229,7 +229,7 @@ Swaps session **content** between two panes — the layout shape is unchanged. U - `layoutAtMinimize`: full layout snapshot - `layoutAtMinimizeSignature`: structural fingerprint (ignores sizes) 2. Remove pane from dockview (`api.removePanel`) -3. Add to `doors` state → door appears in baseboard +3. Add to `doors` state → door appears in baseboard. The door stores only the stable dockview/user title for persistence; its visible label is derived from live terminal semantic state at render time. 4. Session stays in registry (not disposed) 5. Selection moves to the new door (stays in command mode) @@ -273,7 +273,7 @@ Pane IDs are session IDs. `TerminalPane` calls `getOrCreateTerminal(id)` on Reac ### Session persistence -Layout, scrollback, cwd, minimized items, and alert state are saved to persistent storage via a debounced save (500ms). Saves are triggered by layout changes, panel add/remove, and a 30s periodic interval. Saves are flushed immediately on PTY exit, `pagehide`, and extension shutdown requests. +Layout, scrollback, cwd, minimized items, user-pinned titles, and alert state are saved to persistent storage via a debounced save (500ms). Derived command/app labels shown on minimized doors are display-only and are not persisted as user-pinned titles. Saves are triggered by layout changes, panel add/remove, and a 30s periodic interval. Saves are flushed immediately on PTY exit, `pagehide`, and extension shutdown requests. Saved snapshots are read through `readPersistedSession()`, which accepts the canonical object shape and defensively parses a JSON-stringified blob before validation and migration. This keeps malformed storage inert while covering hosts that hand back serialized JSON instead of the parsed object. diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 2dbe837..3f1c257 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -15,13 +15,7 @@ import { markSessionAttention, toggleSessionTodo, setPendingShellOpts, - buildAppTitleResolver, getDefaultShellOpts, - getActivitySnapshot, - getTerminalPaneState, - getTerminalPaneStateSnapshot, - deriveHeader, - resolveDisplayPrimary, setTerminalUserTitle, UNNAMED_PANEL_TITLE, type SessionStatus, @@ -88,6 +82,11 @@ function idsMatch(a: string[], b: string[]): boolean { return a.length === b.length && a.every((id, i) => id === b[i]); } +function persistedPanelTitle(title: string | null | undefined): string { + const trimmed = title?.trim(); + return trimmed || UNNAMED_PANEL_TITLE; +} + const components = { terminal: TerminalPanel }; const tabComponents = { terminal: TerminalPaneHeader }; @@ -268,13 +267,7 @@ export function Wall({ if (!api) return; const panel = api.getPanel(id); if (!panel) return; - const terminalStateSnapshot = getTerminalPaneStateSnapshot(); - const derivedTitle = deriveHeader( - getTerminalPaneState(id), - [...terminalStateSnapshot.values()], - { appTitleForPane: buildAppTitleResolver(terminalStateSnapshot, getActivitySnapshot()) }, - ).primary; - const title = resolveDisplayPrimary(derivedTitle, panel.title ?? id); + const title = persistedPanelTitle(panel.title); const layoutAtMinimize = cloneLayout(api.toJSON()); // Capture the nearest adjacent pane and our actual relative position diff --git a/lib/src/lib/session-save.test.ts b/lib/src/lib/session-save.test.ts index 4c531c3..0c3e16a 100644 --- a/lib/src/lib/session-save.test.ts +++ b/lib/src/lib/session-save.test.ts @@ -4,15 +4,18 @@ import type { PersistedSession } from './session-types'; const terminalRegistryMocks = vi.hoisted(() => ({ getLivePersistedAlertState: vi.fn(), + getTerminalPaneState: vi.fn(), resolveTerminalSessionId: vi.fn(), })); vi.mock('./terminal-registry', () => ({ getLivePersistedAlertState: terminalRegistryMocks.getLivePersistedAlertState, + getTerminalPaneState: terminalRegistryMocks.getTerminalPaneState, resolveTerminalSessionId: terminalRegistryMocks.resolveTerminalSessionId, })); import { saveSession } from './session-save'; +import { UNNAMED_PANEL_TITLE } from './terminal-state'; function createPlatform(savedState: PersistedSession | null): PlatformAdapter { let persistedState: unknown = savedState; @@ -66,6 +69,7 @@ describe('saveSession', () => { vi.clearAllMocks(); terminalRegistryMocks.resolveTerminalSessionId.mockImplementation((id: string) => id); terminalRegistryMocks.getLivePersistedAlertState.mockReturnValue(null); + terminalRegistryMocks.getTerminalPaneState.mockReturnValue({ titleCandidates: {} }); }); it('persists the live alert state even when the previous snapshot was empty', async () => { @@ -113,4 +117,71 @@ describe('saveSession', () => { ], }); }); + + it('does not persist a derived minimized door label as a user title', async () => { + const platform = createPlatform(null); + + await saveSession(platform, { root: true }, [], [{ + id: 'pane-a', + title: 'npm test', + neighborId: null, + direction: 'right', + remainingPaneIds: [], + layoutAtMinimize: null, + layoutAtMinimizeSignature: 'empty', + }]); + + expect(platform.saveState).toHaveBeenCalledWith({ + version: 3, + layout: { root: true }, + doors: [ + expect.objectContaining({ + id: 'pane-a', + title: UNNAMED_PANEL_TITLE, + }), + ], + panes: [ + expect.objectContaining({ + id: 'pane-a', + title: UNNAMED_PANEL_TITLE, + }), + ], + }); + }); + + it('persists a minimized door title when it is user-pinned semantic state', async () => { + const platform = createPlatform(null); + terminalRegistryMocks.getTerminalPaneState.mockReturnValue({ + titleCandidates: { + user: { title: 'Production API', source: 'user', updatedAt: 1 }, + }, + }); + + await saveSession(platform, { root: true }, [], [{ + id: 'pane-a', + title: 'npm test', + neighborId: null, + direction: 'right', + remainingPaneIds: [], + layoutAtMinimize: null, + layoutAtMinimizeSignature: 'empty', + }]); + + expect(platform.saveState).toHaveBeenCalledWith({ + version: 3, + layout: { root: true }, + doors: [ + expect.objectContaining({ + id: 'pane-a', + title: 'Production API', + }), + ], + panes: [ + expect.objectContaining({ + id: 'pane-a', + title: 'Production API', + }), + ], + }); + }); }); diff --git a/lib/src/lib/session-save.ts b/lib/src/lib/session-save.ts index c1ed2db..63a44fe 100644 --- a/lib/src/lib/session-save.ts +++ b/lib/src/lib/session-save.ts @@ -1,7 +1,8 @@ import type { PlatformAdapter } from './platform/types'; import { readPersistedSession, type PersistedDoor, type PersistedPane, type PersistedSession } from './session-types'; import { detectResumeCommand } from './resume-patterns'; -import { getLivePersistedAlertState, resolveTerminalSessionId } from './terminal-registry'; +import { getLivePersistedAlertState, getTerminalPaneState, resolveTerminalSessionId } from './terminal-registry'; +import { UNNAMED_PANEL_TITLE } from './terminal-state'; function getPreviousPaneMap(platform: PlatformAdapter): Map<string, PersistedPane> { const saved = readPersistedSession(platform.getState()); @@ -20,9 +21,13 @@ export async function saveSession( const previousPanes = getPreviousPaneMap(platform); const allPanes = new Map<string, { id: string; title: string }>(); for (const pane of panes) { - allPanes.set(pane.id, pane); + allPanes.set(pane.id, { id: pane.id, title: persistedVisiblePaneTitle(pane.title) }); } - for (const item of doors) { + const persistedDoors = doors.map((door) => ({ + ...door, + title: persistedDoorTitle(door.id), + })); + for (const item of persistedDoors) { allPanes.set(item.id, { id: item.id, title: item.title }); } @@ -46,6 +51,16 @@ export async function saveSession( }; }), ); - const session: PersistedSession = { version: 3, panes: persisted, doors, layout }; + const session: PersistedSession = { version: 3, panes: persisted, doors: persistedDoors, layout }; platform.saveState(session); } + +function persistedVisiblePaneTitle(title: string): string { + const trimmed = title.trim(); + return trimmed || UNNAMED_PANEL_TITLE; +} + +function persistedDoorTitle(id: string): string { + const userTitle = getTerminalPaneState(id).titleCandidates.user?.title.trim(); + return userTitle || UNNAMED_PANEL_TITLE; +} From 4da4c0f8e8cbfbf9cfa7df256fc48f350e6f1978 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Fri, 8 May 2026 17:27:33 -0700 Subject: [PATCH 26/50] Drop redundant inline timestamp in parseOscTitle collectTerminalSemanticEvents already sets updatedAt in stream order, so the inline Date.now() is dead-write. Use 0 and document the contract. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- lib/src/lib/terminal-protocol.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/src/lib/terminal-protocol.ts b/lib/src/lib/terminal-protocol.ts index 1952614..45c6e6f 100644 --- a/lib/src/lib/terminal-protocol.ts +++ b/lib/src/lib/terminal-protocol.ts @@ -393,11 +393,12 @@ function parseOscTitle(content: string, source: TerminalTitle['source']): Termin if (!content.startsWith(prefix)) return []; const titleText = sanitizeText(content.slice(prefix.length), TITLE_LIMIT); if (!titleText) return []; + // updatedAt is set authoritatively by collectTerminalSemanticEvents in stream order. return [{ kind: 'semantic', event: { type: 'title', - title: { title: titleText, source, updatedAt: Date.now() }, + title: { title: titleText, source, updatedAt: 0 }, }, }]; } From 01ab8b2c0f717225f58be646c9c69b48daa2796e Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Fri, 8 May 2026 17:27:47 -0700 Subject: [PATCH 27/50] Document OSC 633;E semicolon truncation contract The parser intentionally drops everything after the first unescaped `;`, matching VS Code shell integration's encoding (semicolons inside the command must be escaped as \x3b). Emitters that don't follow that encoding will see truncated commands; this is by design. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- lib/src/lib/terminal-protocol.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/src/lib/terminal-protocol.ts b/lib/src/lib/terminal-protocol.ts index 45c6e6f..f78fbdc 100644 --- a/lib/src/lib/terminal-protocol.ts +++ b/lib/src/lib/terminal-protocol.ts @@ -348,6 +348,11 @@ function parseOsc633(content: string): TerminalProtocolEvent[] { if (fields[1] === 'E') { const prefix = '633;E;'; if (!content.startsWith(prefix)) return []; + // VS Code shell integration encodes the command as <command>[;<nonce>], with + // any literal `;` inside <command> escaped as `\x3b`. We split on the first + // unescaped `;` (taking only the command field) and then unescape. Emitters + // that send raw, unescaped semicolons will see their command truncated; this + // matches VS Code's contract rather than guessing a delimiter. const rawCommand = content.slice(prefix.length).split(';', 1)[0] ?? ''; return [{ kind: 'semantic', event: { type: 'commandLine', commandLine: decodeOsc633Value(rawCommand) } }]; } From 423947ebb2f815342f46003287835f0ab6badf61 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Fri, 8 May 2026 17:28:18 -0700 Subject: [PATCH 28/50] Document looksLikeReturnedShellPrompt as best-effort fallback The heuristic is bounded to user_input fallback commands, so false negatives just mean the header doesn't auto-clear; explain that this is preferable to false positives that would flip a real running command back to idle prematurely. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- lib/src/lib/terminal-state-store.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/src/lib/terminal-state-store.ts b/lib/src/lib/terminal-state-store.ts index 38a5816..b2dde96 100644 --- a/lib/src/lib/terminal-state-store.ts +++ b/lib/src/lib/terminal-state-store.ts @@ -198,6 +198,13 @@ function resolvePaneStateIdByPtyId(ptyId: string): string { return getSessionIdByPtyId(ptyId) ?? ptyId; } +// Best-effort heuristic for shells without OSC 133/633 integration. Used only +// when currentCommand.source === 'user_input' to detect "command finished and +// the shell is prompting again." Custom prompts that lack the path/user context +// signal (`/`, `~`, `@`, `:`) or a recognized terminator (`$`, `#`, `%`, `>`) +// will not match — that's intentional, since false positives would prematurely +// flip a running command back to idle. Shells that emit OSC 133/633 take the +// fast path and never reach this code. function looksLikeReturnedShellPrompt(output: string): boolean { const visible = stripAltScreenSpans(output); const text = stripTerminalControls(visible).replace(/\r\n/g, '\n').replace(/\r/g, '\n'); From a8d6d542213bfd4f3597560eecc5486ada761ac8 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Fri, 8 May 2026 17:28:46 -0700 Subject: [PATCH 29/50] Align restoreTerminal title type with resumeTerminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both functions now accept title?: string | null, and restoreTerminal trims its input before checking against the UNNAMED_PANEL_TITLE sentinel — matching resumeTerminal's behaviour and avoiding pinning whitespace-only saved titles. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- lib/src/lib/terminal-lifecycle.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index eee6b2f..b29fc84 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -251,7 +251,7 @@ export function resumeTerminal( export function restoreTerminal( id: string, - opts: { cwd?: string | null; scrollback?: string | null; title?: string; cwdWarning?: string | null; shell?: string; args?: string[] }, + opts: { cwd?: string | null; scrollback?: string | null; title?: string | null; cwdWarning?: string | null; shell?: string; args?: string[] }, ): TerminalEntry { const existing = registry.get(id); if (existing) return existing; @@ -259,8 +259,9 @@ export function restoreTerminal( const entry = setupTerminalEntry(id); resetTerminalPaneState(id); seedTerminalManualCwd(id, opts.cwd); - if (opts.title && opts.title !== UNNAMED_PANEL_TITLE) { - setTerminalUserTitle(id, opts.title); + const trimmedTitle = opts.title?.trim(); + if (trimmedTitle && trimmedTitle !== UNNAMED_PANEL_TITLE) { + setTerminalUserTitle(id, trimmedTitle); } if (opts.scrollback) { From 85a50f1df722842aef29316cdd6645f07ca1d346 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Fri, 8 May 2026 17:29:46 -0700 Subject: [PATCH 30/50] Drop dead-state replay parser map in VSCodeAdapter Live pty:data is pre-parsed by the extension host, so the parser only needs to run on the one-shot pty:replay buffer. Instantiate it transiently inside the message handler instead of keeping a per-id map that's never read after init. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- lib/src/lib/platform/vscode-adapter.ts | 23 +++++------------------ 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index 0b110f0..7bfbc38 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -17,7 +17,6 @@ export class VSCodeAdapter implements PlatformAdapter { private replayHandlers = new Set<(detail: { id: string; data: string }) => void>(); private flushRequestHandlers = new Set<(detail: { requestId: string }) => void>(); private alertStateHandlers = new Set<(detail: AlertStateDetail) => void>(); - private replayProtocolParsers = new Map<string, TerminalProtocolParser>(); constructor() { this.vscode = acquireVsCodeApi(); @@ -41,7 +40,6 @@ export class VSCodeAdapter implements PlatformAdapter { handler({ id: msg.id, data: msg.data }); } } else if (msg.type === 'pty:exit') { - this.replayProtocolParsers.delete(msg.id); for (const handler of this.exitHandlers) { handler({ id: msg.id, exitCode: msg.exitCode }); } @@ -50,11 +48,11 @@ export class VSCodeAdapter implements PlatformAdapter { handler({ ptys: msg.ptys }); } } else if (msg.type === 'pty:replay') { - // Replay arrives as raw buffered output. Run it through the protocol - // parser so semantic OSCs (CWD, prompt, title) repopulate pane state - // and are stripped before xterm sees them, mirroring live pty:data. - // See docs/specs/vscode.md for the replay invariants. - const parser = this.getReplayProtocolParser(msg.id); + // Replay arrives as raw buffered output in a single chunk. Live pty:data + // is pre-parsed by the extension host, so we only need a one-shot parser + // here to reconstruct semantic state from the buffered bytes and strip + // OSCs before xterm sees them. See docs/specs/vscode.md. + const parser = new TerminalProtocolParser(); const parsed = parser.process(msg.data); applyTerminalSemanticEventsByPtyId(msg.id, collectTerminalSemanticEvents(parsed.events)); for (const handler of this.replayHandlers) { @@ -133,7 +131,6 @@ export class VSCodeAdapter implements PlatformAdapter { } spawnPty(id: string, options?: { cols?: number; rows?: number; cwd?: string; shell?: string; args?: string[] }): void { - this.replayProtocolParsers.set(id, new TerminalProtocolParser()); this.vscode.postMessage({ type: 'pty:spawn', id, options }); } @@ -146,7 +143,6 @@ export class VSCodeAdapter implements PlatformAdapter { } killPty(id: string): void { - this.replayProtocolParsers.delete(id); this.vscode.postMessage({ type: 'pty:kill', id }); } @@ -293,13 +289,4 @@ export class VSCodeAdapter implements PlatformAdapter { // first load, before any setState has run. return this.vscode.getState() ?? this.hostState; } - - private getReplayProtocolParser(id: string): TerminalProtocolParser { - let parser = this.replayProtocolParsers.get(id); - if (!parser) { - parser = new TerminalProtocolParser(); - this.replayProtocolParsers.set(id, parser); - } - return parser; - } } From c9170b6b9b29b32c9169a80816330319f43b1965 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Fri, 8 May 2026 17:30:40 -0700 Subject: [PATCH 31/50] Apply command-start freshness gate to OSC 9 app titles The appTitleForPane resolver returns the alert manager's current OSC 9 notification body, which previously bypassed the same staleness check that activeTerminalTitle applies to title candidates. An OSC 9 emitted before the current command would leak through as the header label. Now: if titleCandidates.osc9 exists and predates currentCommand.startedAt, ignore the appTitle and fall back to the command's own display label. When there is no osc9 candidate (notification injected without going through the parser), trust appTitle to preserve legacy behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- lib/src/lib/terminal-state.test.ts | 18 ++++++++++++++++++ lib/src/lib/terminal-state.ts | 17 ++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/lib/src/lib/terminal-state.test.ts b/lib/src/lib/terminal-state.test.ts index 769daa4..f2db162 100644 --- a/lib/src/lib/terminal-state.test.ts +++ b/lib/src/lib/terminal-state.test.ts @@ -312,6 +312,24 @@ describe('header and grouping derivation', () => { }); }); + it('ignores stale OSC 9 notifications emitted before the current command', () => { + const pane = reduceTerminalState( + runningPane('/repo/app', 'npm run build'), + { type: 'title', title: { title: 'Build finished', source: 'osc9', updatedAt: 0 } }, + ); + const terminalStates = new Map([['pane', pane]]); + const activityStates = new Map([ + ['pane', { notification: { source: 'OSC 9', title: null, body: 'Build finished' } }], + ]); + + expect(deriveHeader(pane, [pane], { + appTitleForPane: buildAppTitleResolver(terminalStates, activityStates), + })).toEqual({ + primary: 'npm run build', + status: 'running', + }); + }); + it('does not use rich notification titles as tab title overrides', () => { expect(notificationDisplayTitle({ source: 'OSC 777', title: 'Tests', body: '341 passed' })).toBeNull(); expect(notificationDisplayTitle({ source: 'OSC 99', title: 'Build', body: 'Finished successfully' })).toBeNull(); diff --git a/lib/src/lib/terminal-state.ts b/lib/src/lib/terminal-state.ts index b5adfb9..bd8edba 100644 --- a/lib/src/lib/terminal-state.ts +++ b/lib/src/lib/terminal-state.ts @@ -764,7 +764,7 @@ function headerPrimary(pane: TerminalPaneState, options: HeaderOptions): string const userTitle = titleCandidateForSource(pane, 'user')?.title.trim(); if (userTitle) return userTitle; const appTitle = options.appTitleForPane?.(pane)?.trim(); - if (appTitle) return appTitle; + if (appTitle && isAppTitleFresh(pane)) return appTitle; const terminalTitle = activeTerminalTitle(pane); if (terminalTitle) return terminalTitle; if (pane.currentCommand) return pane.currentCommand.displayCommand; @@ -772,6 +772,21 @@ function headerPrimary(pane: TerminalPaneState, options: HeaderOptions): string return idleLabel(pane); } +// appTitleForPane is sourced from the alert manager's current OSC 9 notification. +// The protocol parser populates titleCandidates.osc9 from the same OSC 9 stream, +// so when both exist they share a timestamp. Use the candidate to apply the same +// staleness rule we apply in activeTerminalTitle: an OSC 9 emitted before the +// current command started must not override the command's own label. If no osc9 +// candidate exists (e.g. notification was injected without going through the +// parser), trust the appTitle to preserve legacy behaviour. +function isAppTitleFresh(pane: TerminalPaneState): boolean { + const command = pane.currentCommand ?? (pane.activity.kind === 'finished' ? pane.lastCommand : null); + if (!command) return true; + const osc9 = pane.titleCandidates.osc9; + if (!osc9) return true; + return osc9.updatedAt >= command.startedAt; +} + function idleLabel(pane: TerminalPaneState): string { const userTitle = titleCandidateForSource(pane, 'user')?.title.trim(); if (userTitle) return userTitle; From 1ad8bb75f5db95822b660e41f8623e08eccccf6a Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Fri, 8 May 2026 17:31:10 -0700 Subject: [PATCH 32/50] Test VSCodeAdapter replay parser and semantic event paths Adds two cases to vscode-adapter.test.ts: - pty:replay runs raw data through TerminalProtocolParser, forwards semantic events (e.g. OSC 7 CWD) to applyTerminalSemanticEventsByPtyId, and emits the OSC-stripped visible data to replay handlers. - terminal:semanticEvents passes the host-parsed events straight through under the same PTY id. Both paths were uncovered. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- lib/src/lib/platform/vscode-adapter.test.ts | 43 +++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/lib/src/lib/platform/vscode-adapter.test.ts b/lib/src/lib/platform/vscode-adapter.test.ts index 87df8c9..cc3f00b 100644 --- a/lib/src/lib/platform/vscode-adapter.test.ts +++ b/lib/src/lib/platform/vscode-adapter.test.ts @@ -53,4 +53,47 @@ describe('VSCodeAdapter PTY exit handling', () => { expect(terminalStateStoreMocks.removeTerminalPaneState).not.toHaveBeenCalled(); expect(postMessage).toHaveBeenCalledWith({ type: 'pty:kill', id: 'pane-1' }); }); + + it('parses replay buffers into semantic events and strips OSCs before forwarding', () => { + const adapter = new VSCodeAdapter(); + const replays: Array<{ id: string; data: string }> = []; + adapter.onPtyReplay((detail) => replays.push(detail)); + + windowTarget.dispatchEvent(new MessageEvent('message', { + data: { + type: 'pty:replay', + id: 'pane-1', + data: 'hello\x1b]7;file://localhost/Users/me/project\x1b\\world', + }, + })); + + // Visible data is stripped of the OSC 7 sequence. + expect(replays).toEqual([{ id: 'pane-1', data: 'helloworld' }]); + + // Semantic CWD event was forwarded under the PTY id. + expect(terminalStateStoreMocks.applyTerminalSemanticEventsByPtyId).toHaveBeenCalledTimes(1); + const [forwardedId, forwardedEvents] = terminalStateStoreMocks.applyTerminalSemanticEventsByPtyId.mock.calls[0]; + expect(forwardedId).toBe('pane-1'); + expect(forwardedEvents).toHaveLength(1); + expect(forwardedEvents[0]).toMatchObject({ + type: 'cwd', + cwd: { path: '/Users/me/project', source: 'osc7' }, + }); + }); + + it('forwards extension-host semantic events to the pane state store', () => { + const adapter = new VSCodeAdapter(); + const events = [ + { type: 'cwd' as const, cwd: { path: '/repo', pathKind: 'posix' as const, isRemote: false, source: 'osc633' as const, updatedAt: 5 } }, + { type: 'promptStart' as const }, + ]; + + windowTarget.dispatchEvent(new MessageEvent('message', { + data: { type: 'terminal:semanticEvents', id: 'pane-1', events }, + })); + void adapter; + + expect(terminalStateStoreMocks.applyTerminalSemanticEventsByPtyId).toHaveBeenCalledTimes(1); + expect(terminalStateStoreMocks.applyTerminalSemanticEventsByPtyId).toHaveBeenCalledWith('pane-1', events); + }); }); From 55626fcac39b8a9bdfd94e0200e0fe5f623b5eca Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Fri, 8 May 2026 17:32:12 -0700 Subject: [PATCH 33/50] Test extension-host to webview semantic event round-trip vscode-ext has no test runner configured, so cover the contract from the lib side: parse a PTY chunk through the same TerminalProtocolParser + collectTerminalSemanticEvents pipeline message-router.ts uses, JSON round-trip the result (proxy for structured-clone), and dispatch it through VSCodeAdapter. Asserts the webview applies exactly what the host emitted, end to end. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- lib/src/lib/platform/vscode-adapter.test.ts | 32 +++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/lib/src/lib/platform/vscode-adapter.test.ts b/lib/src/lib/platform/vscode-adapter.test.ts index cc3f00b..97e8464 100644 --- a/lib/src/lib/platform/vscode-adapter.test.ts +++ b/lib/src/lib/platform/vscode-adapter.test.ts @@ -10,6 +10,10 @@ vi.mock('../terminal-state-store', () => ({ removeTerminalPaneState: terminalStateStoreMocks.removeTerminalPaneState, })); +import { + collectTerminalSemanticEvents, + TerminalProtocolParser, +} from '../terminal-protocol'; import { VSCodeAdapter } from './vscode-adapter'; describe('VSCodeAdapter PTY exit handling', () => { @@ -96,4 +100,32 @@ describe('VSCodeAdapter PTY exit handling', () => { expect(terminalStateStoreMocks.applyTerminalSemanticEventsByPtyId).toHaveBeenCalledTimes(1); expect(terminalStateStoreMocks.applyTerminalSemanticEventsByPtyId).toHaveBeenCalledWith('pane-1', events); }); + + it('round-trips host-parsed semantic events through JSON to the webview adapter', () => { + // Simulate the extension host: run live PTY data through the same parser + // that message-router.ts uses, collect semantic events, then ship them + // over the postMessage wire as terminal:semanticEvents. + const hostParser = new TerminalProtocolParser(); + const parsed = hostParser.process( + 'before\x1b]7;file://prod-box/srv/app\x1b\\\x1b]133;A\x07after', + ); + const hostEvents = collectTerminalSemanticEvents(parsed.events); + expect(hostEvents).toHaveLength(2); + + // postMessage forces structured-clone-equivalent serialization. JSON + // round-trip is a sufficient stand-in: it would drop functions or + // non-cloneable values, so passing this also documents that the wire + // payload contains only plain data. + const wirePayload = JSON.parse(JSON.stringify({ + type: 'terminal:semanticEvents', + id: 'pane-1', + events: hostEvents, + })); + + new VSCodeAdapter(); + windowTarget.dispatchEvent(new MessageEvent('message', { data: wirePayload })); + + expect(terminalStateStoreMocks.applyTerminalSemanticEventsByPtyId).toHaveBeenCalledTimes(1); + expect(terminalStateStoreMocks.applyTerminalSemanticEventsByPtyId).toHaveBeenCalledWith('pane-1', hostEvents); + }); }); From 6b6d0d0f47ce2df75ad87d530331442a82ad39cd Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Fri, 8 May 2026 17:46:47 -0700 Subject: [PATCH 34/50] Warn when a rename hits a reserved label setTerminalUserTitle now returns { accepted, reason? } so the rename flow can distinguish accepted, empty, and reserved-sentinel inputs. Wall.onFinishRename forwards that result; TerminalPaneHeader anchors an auto-dismissing warning popover under the input when it's rejected. Also adds a TerminalPaneHeader Storybook story ("RenameRejectedReserved") that submits "<idle>" and shows the warning, plus a layout.md note documenting the new behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- docs/specs/layout.md | 2 + lib/src/components/Wall.tsx | 9 ++- .../components/wall/IllegalRenameWarning.tsx | 79 +++++++++++++++++++ .../components/wall/TerminalPaneHeader.tsx | 25 +++++- lib/src/components/wall/wall-context.tsx | 6 +- lib/src/lib/terminal-registry.ts | 1 + lib/src/lib/terminal-state-store.test.ts | 10 +-- lib/src/lib/terminal-state-store.ts | 10 ++- lib/src/stories/MouseHeaderIcon.stories.tsx | 2 +- lib/src/stories/ShellCwd.stories.tsx | 2 +- .../stories/TerminalPaneHeader.stories.tsx | 40 +++++++++- 11 files changed, 168 insertions(+), 18 deletions(-) create mode 100644 lib/src/components/wall/IllegalRenameWarning.tsx diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 7910def..3f2c678 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -259,6 +259,8 @@ The name `<span>` is replaced by an `<input>` with: - `stopPropagation` on `mousedown`/`click`/`keydown` to prevent panel click or drag - All command-mode shortcuts are bypassed while renaming +Sentinel labels (`<idle>`, `<unnamed>`) and empty values are rejected as user-pin titles. When the user submits one, the input still closes (so it is not a blocking dialog) and a small auto-dismissing warning popover anchored under the input names the offending value. The popover dismisses on the next pointerdown, scroll, resize, `Escape`, or after 3s. + ## Session lifecycle and terminal registry Pane IDs are session IDs. `TerminalPane` calls `getOrCreateTerminal(id)` on React mount and `unmountElement(id)` on React unmount. The session (xterm.js instance, PTY, DOM element) persists in the registry across mount/unmount cycles — the DOM element is detached from its container but the Registry entry stays `Mounted`. diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 3f1c257..7f236e8 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -532,11 +532,16 @@ export function Wall({ }, onFinishRename: (id: string, value: string) => { const trimmed = value.trim(); - if (trimmed) { + if (!trimmed) { + setRenamingPaneId(null); + return { accepted: false, reason: 'empty' as const }; + } + const result = setTerminalUserTitle(id, trimmed); + if (result.accepted) { apiRef.current?.getPanel(id)?.api.setTitle(trimmed); - setTerminalUserTitle(id, trimmed); } setRenamingPaneId(null); + return result; }, onCancelRename: () => { setRenamingPaneId(null); diff --git a/lib/src/components/wall/IllegalRenameWarning.tsx b/lib/src/components/wall/IllegalRenameWarning.tsx new file mode 100644 index 0000000..b42f6d0 --- /dev/null +++ b/lib/src/components/wall/IllegalRenameWarning.tsx @@ -0,0 +1,79 @@ +import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react'; +import { createPortal } from 'react-dom'; +import type { SetTerminalUserTitleResult } from '../../lib/terminal-registry'; + +export type RenameRejection = Extract<SetTerminalUserTitleResult, { accepted: false }>['reason']; + +const POPOVER_GAP = 6; +const POPOVER_MARGIN = 8; +const AUTO_DISMISS_MS = 3000; + +export interface IllegalRenameWarningProps { + anchorRect: DOMRect; + reason: RenameRejection; + attemptedValue: string; + onClose: () => void; +} + +export function IllegalRenameWarning({ anchorRect, reason, attemptedValue, onClose }: IllegalRenameWarningProps) { + const ref = useRef<HTMLDivElement>(null); + const [style, setStyle] = useState<CSSProperties>({ + position: 'fixed', + left: anchorRect.left, + top: anchorRect.bottom + POPOVER_GAP, + }); + + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + const top = anchorRect.bottom + POPOVER_GAP; + const maxLeft = Math.max(POPOVER_MARGIN, window.innerWidth - rect.width - POPOVER_MARGIN); + setStyle({ + position: 'fixed', + left: Math.min(Math.max(anchorRect.left, POPOVER_MARGIN), maxLeft), + top, + }); + }, [anchorRect]); + + useEffect(() => { + const dismiss = () => onClose(); + const onKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') dismiss(); + }; + const timeout = window.setTimeout(dismiss, AUTO_DISMISS_MS); + window.addEventListener('pointerdown', dismiss); + window.addEventListener('resize', dismiss); + window.addEventListener('scroll', dismiss, true); + window.addEventListener('keydown', onKey); + return () => { + window.clearTimeout(timeout); + window.removeEventListener('pointerdown', dismiss); + window.removeEventListener('resize', dismiss); + window.removeEventListener('scroll', dismiss, true); + window.removeEventListener('keydown', onKey); + }; + }, [onClose]); + + return createPortal( + <div + ref={ref} + role="alert" + data-testid="illegal-rename-warning" + aria-label={`Illegal name: ${describeReason(reason, attemptedValue)}`} + className="z-[1000] max-w-72 rounded border border-border bg-surface-raised px-2.5 py-1.5 font-mono text-xs leading-snug text-foreground shadow-md" + style={style} + onPointerDown={(e) => e.stopPropagation()} + > + <div className="font-medium" style={{ color: 'var(--color-error)' }}>Illegal name</div> + <div className="mt-0.5 text-muted">{describeReason(reason, attemptedValue)}</div> + </div>, + document.body, + ); +} + +function describeReason(reason: RenameRejection, attemptedValue: string): string { + if (reason === 'empty') return 'Pane names cannot be blank.'; + const trimmed = attemptedValue.trim(); + return `"${trimmed}" is reserved for derived labels.`; +} diff --git a/lib/src/components/wall/TerminalPaneHeader.tsx b/lib/src/components/wall/TerminalPaneHeader.tsx index 39a3c87..40edea2 100644 --- a/lib/src/components/wall/TerminalPaneHeader.tsx +++ b/lib/src/components/wall/TerminalPaneHeader.tsx @@ -19,6 +19,7 @@ import { TodoAlertDialog } from '../TodoAlertDialog'; import { TERMINAL_TOP_RADIUS_CLASS, TODO_PILL_TRACKING_CLASS } from '../design'; import { bellIconClass } from '../bell-icon-class'; import { useTodoPillContent } from '../TodoPillBody'; +import { IllegalRenameWarning, type RenameRejection } from './IllegalRenameWarning'; import { DEFAULT_MOUSE_SELECTION_STATE, getMouseSelectionSnapshot, @@ -119,6 +120,7 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { const [dialogTriggerRect, setDialogTriggerRect] = useState<DOMRect | null>(null); const [todoPreviewRect, setTodoPreviewRect] = useState<DOMRect | null>(null); const [titleCandidatesRect, setTitleCandidatesRect] = useState<DOMRect | null>(null); + const [renameWarning, setRenameWarning] = useState<{ rect: DOMRect; reason: RenameRejection; value: string } | null>(null); const todoPill = useTodoPillContent(activity.todo); const titleCandidates = useMemo(() => titleCandidatesForDisplay(paneState), [paneState]); const showTodoPill = todoPill.visible && tier !== 'minimal'; @@ -134,6 +136,16 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { const closeDialog = useCallback(() => setDialogTriggerRect(null), []); const closeTodoPreview = useCallback(() => setTodoPreviewRect(null), []); const closeTitleCandidates = useCallback(() => setTitleCandidatesRect(null), []); + const closeRenameWarning = useCallback(() => setRenameWarning(null), []); + const submitRename = useCallback((value: string, anchor: HTMLElement) => { + const rect = anchor.getBoundingClientRect(); + const result = actions.onFinishRename(api.id, value); + if (!result.accepted) { + setRenameWarning({ rect, reason: result.reason, value }); + } else { + setRenameWarning(null); + } + }, [actions, api.id]); const openTodoPreview = useCallback((button: HTMLButtonElement) => { if (!activity.notification) return; setTodoPreviewRect(button.getBoundingClientRect()); @@ -191,18 +203,19 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { <div className="flex flex-1 min-w-0 items-center gap-1.5"> {isRenaming ? ( <input + data-renaming-input-for={api.id} className="bg-transparent outline-none border-none text-inherit font-medium font-mono w-full min-w-0 p-0 m-0" defaultValue={displayTitle} autoFocus ref={(el) => el?.select()} onKeyDown={(e) => { if (e.key === 'Enter') { - actions.onFinishRename(api.id, (e.target as HTMLInputElement).value); + submitRename((e.target as HTMLInputElement).value, e.currentTarget); } if (e.key === 'Escape') actions.onCancelRename(); e.stopPropagation(); }} - onBlur={(e) => actions.onFinishRename(api.id, e.target.value)} + onBlur={(e) => submitRename(e.target.value, e.currentTarget)} onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} /> @@ -373,6 +386,14 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { onClose={closeTitleCandidates} /> )} + {renameWarning && ( + <IllegalRenameWarning + anchorRect={renameWarning.rect} + reason={renameWarning.reason} + attemptedValue={renameWarning.value} + onClose={closeRenameWarning} + /> + )} </div> ); } diff --git a/lib/src/components/wall/wall-context.tsx b/lib/src/components/wall/wall-context.tsx index c30c588..ad87410 100644 --- a/lib/src/components/wall/wall-context.tsx +++ b/lib/src/components/wall/wall-context.tsx @@ -1,5 +1,5 @@ import { createContext } from 'react'; -import type { AlertButtonActionResult, SessionStatus } from '../../lib/terminal-registry'; +import type { AlertButtonActionResult, SessionStatus, SetTerminalUserTitleResult } from '../../lib/terminal-registry'; import type { WallMode, SpawnDirection } from './wall-types'; export interface PaneElementsState { @@ -33,7 +33,7 @@ export interface WallActions { onZoom: (id: string) => void; onClickPanel: (id: string) => void; onStartRename: (id: string) => void; - onFinishRename: (id: string, value: string) => void; + onFinishRename: (id: string, value: string) => SetTerminalUserTitleResult; onCancelRename: () => void; } @@ -47,7 +47,7 @@ export const WallActionsContext = createContext<WallActions>({ onZoom: () => {}, onClickPanel: () => {}, onStartRename: () => {}, - onFinishRename: () => {}, + onFinishRename: () => ({ accepted: true }), onCancelRename: () => {}, }); diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index 12d9560..6f6ee0e 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -67,6 +67,7 @@ export { setTerminalUserTitle, subscribeToTerminalPaneState, } from './terminal-state-store'; +export type { SetTerminalUserTitleResult } from './terminal-state-store'; export { cwdDisplay, diff --git a/lib/src/lib/terminal-state-store.test.ts b/lib/src/lib/terminal-state-store.test.ts index b3b22a4..a42e19d 100644 --- a/lib/src/lib/terminal-state-store.test.ts +++ b/lib/src/lib/terminal-state-store.test.ts @@ -94,14 +94,14 @@ describe('terminal semantic state store command input fallback', () => { }); }); - it('refuses to pin sentinel labels as a user title', () => { - setTerminalUserTitle('pane', DEFAULT_IDLE_TITLE); - setTerminalUserTitle('pane', UNNAMED_PANEL_TITLE); - setTerminalUserTitle('pane', ' '); + it('refuses to pin sentinel labels as a user title and reports the reason', () => { + expect(setTerminalUserTitle('pane', DEFAULT_IDLE_TITLE)).toEqual({ accepted: false, reason: 'reserved' }); + expect(setTerminalUserTitle('pane', UNNAMED_PANEL_TITLE)).toEqual({ accepted: false, reason: 'reserved' }); + expect(setTerminalUserTitle('pane', ' ')).toEqual({ accepted: false, reason: 'empty' }); expect(getTerminalPaneState('pane').titleCandidates.user).toBeUndefined(); - setTerminalUserTitle('pane', 'Production API'); + expect(setTerminalUserTitle('pane', 'Production API')).toEqual({ accepted: true }); expect(getTerminalPaneState('pane').titleCandidates.user?.title).toBe('Production API'); }); diff --git a/lib/src/lib/terminal-state-store.ts b/lib/src/lib/terminal-state-store.ts index b2dde96..71255cb 100644 --- a/lib/src/lib/terminal-state-store.ts +++ b/lib/src/lib/terminal-state-store.ts @@ -124,15 +124,21 @@ export function recordTerminalOutputByPtyId(ptyId: string, output: string): void const RESERVED_USER_TITLES = new Set<string>([DEFAULT_IDLE_TITLE, UNNAMED_PANEL_TITLE]); -export function setTerminalUserTitle(id: string, title: string): void { +export type SetTerminalUserTitleResult = + | { accepted: true } + | { accepted: false; reason: 'empty' | 'reserved' }; + +export function setTerminalUserTitle(id: string, title: string): SetTerminalUserTitleResult { const trimmed = title.trim(); - if (!trimmed || RESERVED_USER_TITLES.has(trimmed)) return; + if (!trimmed) return { accepted: false, reason: 'empty' }; + if (RESERVED_USER_TITLES.has(trimmed)) return { accepted: false, reason: 'reserved' }; const terminalTitle: TerminalTitle = { title: trimmed, source: 'user', updatedAt: Date.now(), }; applyTerminalSemanticEvents(id, [{ type: 'title', title: terminalTitle }]); + return { accepted: true }; } export function seedTerminalManualCwd(id: string, path: string | null | undefined): void { diff --git a/lib/src/stories/MouseHeaderIcon.stories.tsx b/lib/src/stories/MouseHeaderIcon.stories.tsx index 7336e78..3079bbb 100644 --- a/lib/src/stories/MouseHeaderIcon.stories.tsx +++ b/lib/src/stories/MouseHeaderIcon.stories.tsx @@ -29,7 +29,7 @@ const noopActions: WallActions = { onZoom: () => {}, onClickPanel: () => {}, onStartRename: () => {}, - onFinishRename: () => {}, + onFinishRename: () => ({ accepted: true }), onCancelRename: () => {}, }; diff --git a/lib/src/stories/ShellCwd.stories.tsx b/lib/src/stories/ShellCwd.stories.tsx index a985f1f..01809db 100644 --- a/lib/src/stories/ShellCwd.stories.tsx +++ b/lib/src/stories/ShellCwd.stories.tsx @@ -49,7 +49,7 @@ const noopActions: WallActions = { onZoom: () => {}, onClickPanel: () => {}, onStartRename: () => {}, - onFinishRename: () => {}, + onFinishRename: () => ({ accepted: true }), onCancelRename: () => {}, }; diff --git a/lib/src/stories/TerminalPaneHeader.stories.tsx b/lib/src/stories/TerminalPaneHeader.stories.tsx index 6919090..73ab435 100644 --- a/lib/src/stories/TerminalPaneHeader.stories.tsx +++ b/lib/src/stories/TerminalPaneHeader.stories.tsx @@ -9,6 +9,7 @@ import { type WallActions, } from '../components/Wall'; import type { ActivityNotification } from '../lib/alert-manager'; +import type { SetTerminalUserTitleResult } from '../lib/terminal-registry'; const SESSION_ID = 'tab-story'; @@ -22,10 +23,15 @@ const noopActions: WallActions = { onZoom: () => {}, onClickPanel: () => {}, onStartRename: () => {}, - onFinishRename: () => {}, + onFinishRename: () => ({ accepted: true }), onCancelRename: () => {}, }; +function actionsRejecting(reason: 'empty' | 'reserved'): WallActions { + const rejection: SetTerminalUserTitleResult = { accepted: false, reason }; + return { ...noopActions, onFinishRename: () => rejection }; +} + function primedState(state: Record<string, unknown>) { return { primedSessionState: { @@ -51,6 +57,7 @@ function TabStory({ isRenaming = false, width = 360, reducedMotion = false, + actions = noopActions, }: { title?: string; mode?: WallMode; @@ -58,13 +65,14 @@ function TabStory({ isRenaming?: boolean; width?: number; reducedMotion?: boolean; + actions?: WallActions; }) { const mockApi = { id: SESSION_ID, title } as any; return ( <ModeContext.Provider value={mode}> <SelectedIdContext.Provider value={isSelected ? SESSION_ID : null}> - <WallActionsContext.Provider value={noopActions}> + <WallActionsContext.Provider value={actions}> <RenamingIdContext.Provider value={isRenaming ? SESSION_ID : null}> <div className={reducedMotion ? '[&_button]:!animate-none [&_*]:!transition-none' : undefined} @@ -108,6 +116,21 @@ async function openTodoNotificationPreview() { await wait(100); } +async function submitReservedRename() { + await wait(100); + const input = document.querySelector<HTMLInputElement>( + `[data-renaming-input-for="${SESSION_ID}"]`, + ) ?? document.querySelector<HTMLInputElement>('input'); + if (!input) return; + input.value = '<idle>'; + input.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true, + })); + await wait(50); +} + const NOTIFICATIONS = { osc9BodyOnly: { source: 'OSC 9', @@ -334,3 +357,16 @@ export const ReducedMotionRinging: Story = { todo: false, }), }; + +export const RenameRejectedReserved: Story = { + args: { + title: 'build-server', + isRenaming: true, + actions: actionsRejecting('reserved'), + }, + parameters: primedState({ + status: 'NOTHING_TO_SHOW', + todo: false, + }), + play: submitReservedRename, +}; From 096a3d5f979a2e44cd2d008edf88ed94affb83a4 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Sat, 9 May 2026 09:06:54 -0700 Subject: [PATCH 35/50] Reorganize docs/specs: OSC.md, transport.md, fold notifications into alert iTerm2.md had three unrelated concerns (iTerm2 identity, notification machinery, and a registry of every OSC parsed). vscode.md mixed VS Code-specific behavior with adapter-agnostic transport. - OSC.md (new): every supported OSC plus iTerm2 identity and known- unimplemented sequences. Single source for parsing-location and pty:data strip semantics. - transport.md (new): adapter-agnostic PTY lifecycle, message protocol, and persisted-session types shared across VS Code, standalone, and fake adapters. - alert.md: absorbs notification machinery (OSC 9/9;4/99/777/BEL, ActivityNotification model, text handling, security, scenarios, verification checklist). - vscode.md: trimmed to VS Code-specific layer (manifest, persistence flow, theme, CSP, build, dream-architecture commands). - terminal-state.md: header cross-ref points at alert.md and OSC.md. - iTerm2.md: deleted. Also adds SPEC-CONFLICTS.md capturing a prior audit; this commit closes items #3 (OSC 9 timing) and #4 (pty:data strip semantics) from it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- SPEC-CONFLICTS.md | 91 ++++++++ docs/specs/OSC.md | 99 ++++++++ docs/specs/alert.md | 330 ++++++++++++++++++++++++++- docs/specs/iTerm2.md | 431 ----------------------------------- docs/specs/terminal-state.md | 2 +- docs/specs/transport.md | 159 +++++++++++++ docs/specs/vscode.md | 145 ++---------- 7 files changed, 693 insertions(+), 564 deletions(-) create mode 100644 SPEC-CONFLICTS.md create mode 100644 docs/specs/OSC.md delete mode 100644 docs/specs/iTerm2.md create mode 100644 docs/specs/transport.md diff --git a/SPEC-CONFLICTS.md b/SPEC-CONFLICTS.md new file mode 100644 index 0000000..8f910ec --- /dev/null +++ b/SPEC-CONFLICTS.md @@ -0,0 +1,91 @@ +# Spec conflicts in `docs/specs/` (HEAD vs main) + +Audit of the changed regions in `iTerm2.md`, `layout.md`, `terminal-state.md` (new), and `vscode.md`. + +## Substantive conflicts + +### 1. `ShellActivity` (5 kinds) ≠ `DerivedHeader.status` / status grouping (4 values) + +`terminal-state.md`: + +- `ShellActivity.kind`: `unknown | prompt | editing | running | finished` (line 48–53). +- `DerivedHeader.status`: `"unknown" | "idle" | "running" | "finished"` (line 189). +- Status grouping (line 225): `unknown | idle | running | finished`. + +`prompt` and `editing` collapse to `idle` somewhere, but the spec never states that mapping. Two different vocabularies for the same axis. + +### 2. Header-derivation rules don't form a clean priority chain + +`terminal-state.md` line 194–204 vs `layout.md`. + +`layout.md` lays out a clean order: *user-pinned → app-sent override → current/freshly-finished command → `<idle>`*. + +But the bullets in `terminal-state.md` contradict that: + +- "A freshly finished command uses `lastCommand.displayCommand` until the next prompt signal." — no override carve-out, even though the bullet above grants override precedence. +- "Idle terminals use `<idle>` unless a user-pinned title exists." — omits the app-sent override case entirely. + +Read literally, an app-sent OSC 9 on an idle pane would be ignored (idle rule wins), and an override during `lastCommand` would be ignored (finished rule wins). Both contradict `layout.md`'s priority list. + +### 3. OSC 9 title-override timing is stated three different ways + +- `iTerm2.md` prose: "Legacy `OSC 9` message text also participates in pane header/door title derivation as an app-sent title override" — unconditional. +- `iTerm2.md` table: "may override the pane header/door label" — conditional, condition unstated. +- `layout.md` and `terminal-state.md`: only OSC 9 / OSC 0/2 "emitted **after the current command started**" are overrides; "Older shell titles remain fallback-only." + +These three need to agree, and the timing condition itself is underdefined: what about an OSC 9 emitted while running, after the command has now finished (i.e., `lastCommand` is set)? `layout.md`'s priority list says the override still beats the finished-command title, but `terminal-state.md`'s reducer/rules don't say when an override expires. Does it survive the next `commandStart`? Next prompt? Forever? + +### 4. `pty:data` strip semantics conflict with the "streaming parser" description + +`vscode.md` vs `iTerm2.md`. + +`vscode.md` (changed) now says `pty:data` is "PTY output **after supported OSC sequences have been parsed/stripped**" and adds a separate `terminal:semanticEvents` message for the parsed events. + +But `iTerm2.md` (changed) describes "**The same streaming parser**" recognizing OSC 7/9;9/633/1337/133/0/2 — this parser, in the surrounding spec context, is the webview parser. If the extension host has already stripped those sequences from `pty:data`, the webview's "same streaming parser" never sees them in live data, only in `pty:replay`. + +Either the parser exists in two places (under-specified), or one of these two specs is wrong about who parses what. As written, "the same streaming parser also recognizes" is misleading. + +### 5. Dead/unreferenced enum values + +- `CommandRun.source` (line 67–73) declares `"foreground_process"` and `"title"`, but no rule, table row, or fallback in the document produces them. +- `CwdState.source` (line 40) declares `"manual"`, with no production rule. CWD fallback step 3 ("initial launch or restored directory") is the most likely candidate, but it's never tied to the `manual` value. +- `TerminalTitle.source` (line 84–93) declares `"notification"`, `"profile"`, and `"derived"` — none of these are produced by any documented event, none appear in the `titleCandidates` tables in `iTerm2.md`, and none are listed in `layout.md`'s right-click popup channels. + +### 6. Disambiguator coverage is inconsistent + +- `layout.md`: "running command's `cwdAtStart` or the idle pane's latest `cwd`" — only running and idle. +- `terminal-state.md`: running **and finished** use `cwdAtStart`; idle uses `pane.cwd`. +- Neither covers `unknown`-kind panes, even though `unknown` is a first-class status. + +`layout.md` silently drops the "finished" case. + +### 7. Right-click popup channels (6) ≠ `TerminalTitle.source` enum (9) + +`layout.md` says the diagnostic popup lists "user, OSC 0, OSC 2, OSC 9, OSC 99, and OSC 777 where present." The type allows three more (`notification`, `profile`, `derived`). Either the popup is exhaustive (and the enum has dead values), or the enum is right (and the popup spec is incomplete). + +### 8. Resume seeding: `non-unnamed` filter only on one side + +`vscode.md` vs `layout.md`. + +- `vscode.md`: "seeds any **non-unnamed** saved pane or door titles as user titles." +- `layout.md`: "seed any saved pane or door title as the Session's user title." + +If `<unnamed>` is rejected at write time per the rename rules (`layout.md` line 262), the read-time filter is unnecessary; if legacy data can contain it, both specs should agree on the filter. Pick one. + +## Smaller issues + +### 9. "Stale pending command-line fallback" + +`terminal-state.md` line 162–163: `promptStart` and `promptEnd` "clear stale pending command-line fallback" without defining what makes a pending line "stale." With `user_input` fallback firing in `editing` and OSC 633 ; E firing later, the staleness condition is load-bearing but unstated. + +### 10. CWD fallback list vs. priority statement + +`terminal-state.md` line 173–180: the numbered fallback list looks like a strict priority, but the immediately following sentence describes a different, looser semantics ("process may fill `null` or replace manual/restored, but not OSC"). It would be clearer as a single rule; as written, the two paragraphs leave the manual-vs-process tiebreak ambiguous when both fight for the same slot at runtime. + +### 11. `OSC 9` vs `OSC 9;4` title-candidate split is implicit + +`iTerm2.md`'s title-candidate side-effects table lists `OSC 9` as recording `titleCandidates.osc9 = message`, but `OSC 9;4` (progress) appears only as a notification source. A reader has to infer that "OSC 9" in the candidate row means *only* the message form, not the progress form. Worth saying explicitly given they share the same OSC number. + +--- + +Severity: #1, #2, and #3 are blockers — they change actual behavior depending on which spec a reader trusts. #4 and #5 are spec-hygiene issues that will silently rot. The rest are minor. diff --git a/docs/specs/OSC.md b/docs/specs/OSC.md new file mode 100644 index 0000000..4bed5f4 --- /dev/null +++ b/docs/specs/OSC.md @@ -0,0 +1,99 @@ +# OSC Sequence Registry + +> Single registry of OSC sequences MouseTerm parses. Behavioral details live in `docs/specs/alert.md` (notifications) and `docs/specs/terminal-state.md` (CWD, prompt/command, title fallback). This file also documents iTerm2 self-identification because the same identity is what causes most of these sequences to be emitted at us. + +## Goal + +MouseTerm parses a small set of OSC (Operating System Command) escape sequences from PTY output to drive alerts, terminal state, and titles. This document is the index — every supported OSC has one row in the table below pointing to the spec that defines its full behavior. + +## Parsing location + +OSC sequences are introduced by `ESC ]` and terminated by either `BEL` (`\x07`) or `ST` (`ESC \`). A `BEL` that terminates an OSC is part of that OSC sequence, not a standalone bell notification. Both terminators are accepted across all supported sequences, and the parser handles split chunks across PTY reads. + +Supported OSCs are parsed at the PTY data boundary in the platform adapter: + +- VS Code: in the extension host (`message-router.ts` / `pty-manager.ts`), before `pty:data` is forwarded to the webview. +- Standalone and fake adapters: in the frontend adapter, before xterm.js sees the bytes. + +After parsing, supported sequences are consumed and not re-emitted. The platform sends two streams to the webview: + +- `pty:data` — terminal output with supported OSCs already parsed/stripped. Feeds xterm.js. +- `terminal:semanticEvents` — normalized semantic events parsed in the platform (CWD, prompt/command boundaries, titles). Feeds `TerminalPaneState`. +- Notification-derived state is delivered through `AlertManager` calls / `alert:state` messages, not through `pty:data`. + +For replay (`pty:replay`), the webview re-parses semantic OSCs from the buffered raw stream during reconstruction. Replay must not re-fire alerts, activity-monitor events, or protocol notifications: saved scrollback may contain raw OSC sequences, but replay filtering suppresses all protocol side effects so a resumed Session does not re-ring on every reload. + +The parser also classifies each PTY data chunk for activity-monitor purposes: + +- A chunk that contains only notification/progress OSCs after parsing must not be fed to the activity monitor's `onData()` as generic meaningful output. +- A chunk that contains visible output plus notification/progress OSCs still counts visible output as activity. + +Unknown OSC sequences pass through to xterm.js unchanged (and are then ignored by xterm.js if it does not recognize them — see the iTerm2-identity fail-inertly rule below). + +## Supported OSCs + +| Sequence | Purpose | Spec | +|---|---|---| +| `BEL` (standalone, outside an OSC) | Generic terminal-bell notification | [alert.md](alert.md#standalone-bel) | +| `OSC 0 ; <title> ST` | Window/icon title | [terminal-state.md](terminal-state.md#supported-osc-inputs) | +| `OSC 2 ; <title> ST` | Window title | [terminal-state.md](terminal-state.md#supported-osc-inputs) | +| `OSC 7 ; file://host/path ST` | CWD (xterm-style URI) | [terminal-state.md](terminal-state.md#supported-osc-inputs) | +| `OSC 9 ; <message> ST` | iTerm2 legacy notification | [alert.md](alert.md#osc-9) | +| `OSC 9 ; 4 ; <state> [; <progress>] ST` | iTerm2 progress | [alert.md](alert.md#osc-94-progress) | +| `OSC 9 ; 9 ; <cwd> ST` | CWD (Windows Terminal / ConEmu) | [terminal-state.md](terminal-state.md#supported-osc-inputs) | +| `OSC 99 ; <metadata> ; <payload> ST` | kitty desktop notification | [alert.md](alert.md#osc-99) | +| `OSC 133 ; A/B/C/D [...] ST` | Prompt/command boundaries | [terminal-state.md](terminal-state.md#supported-osc-inputs) | +| `OSC 633 ; A/B/C/D ST` | VS Code prompt/command boundaries | [terminal-state.md](terminal-state.md#supported-osc-inputs) | +| `OSC 633 ; E ; <commandline> [; <nonce>] ST` | VS Code command line | [terminal-state.md](terminal-state.md#supported-osc-inputs) | +| `OSC 633 ; P ; Cwd=<cwd> ST` | CWD (VS Code) | [terminal-state.md](terminal-state.md#supported-osc-inputs) | +| `OSC 777 ; notify ; <title> ; <body> ST` | rxvt/WezTerm notification | [alert.md](alert.md#osc-777) | +| `OSC 1337 ; CurrentDir=<cwd> ST` | CWD (iTerm2 compatibility) | [terminal-state.md](terminal-state.md#supported-osc-inputs) | + +## iTerm2 identity + +MouseTerm reports an iTerm2-compatible identity so that tools (shells, build systems, agent clients) emit the iTerm2-style escape codes that this spec set supports. + +Environment for spawned PTYs: + +| Variable | Value | +|---|---| +| `TERM_PROGRAM` | `iTerm.app` | +| `TERM_PROGRAM_VERSION` | MouseTerm's chosen iTerm2 compatibility version, not the package version | +| `LC_TERMINAL` | `iTerm2` only if needed by real-world shell integrations | +| `LC_TERMINAL_VERSION` | same compatibility version as `TERM_PROGRAM_VERSION` | + +Device/version query: + +- On `CSI > q`, respond with `DCS > | iTerm2 [version] ST`, matching iTerm2's extended device attributes response shape. +- Use a single compatibility version across env and device responses. +- Do not advertise feature-specific support until the relevant behavior exists. + +Because this identity can cause tools to emit more iTerm2 escape codes than MouseTerm implements, **unsupported escape codes must fail inertly**: consume or ignore them without visible terminal garbage, privilege escalation, clipboard access, file access, or focus stealing. + +## Known-unimplemented iTerm2 sequences + +MouseTerm intentionally does not implement the following iTerm2 OSC sequences. They must fail inertly per the rule above. + +| Sequence | Purpose | Reason for non-support | +|---|---|---| +| `OSC 1337 ; SetMark` | Pin a navigable scrollback mark | No mark UI in MouseTerm. | +| `OSC 1337 ; CursorShape=...` | Cursor shape override | Cursor shape comes from MouseTerm settings, not the PTY. | +| `OSC 1337 ; SetBadgeFormat=...` | Display a badge string in the terminal | No badge UI. | +| `OSC 1337 ; ClearScrollback` | Clear scrollback buffer | xterm.js handles native clear-screen sequences. | +| `OSC 1337 ; CopyToClipboard=...` / `EndCopy` | Programmatic clipboard write | Security: untrusted PTY output cannot write the user's clipboard. See `docs/specs/mouse-and-clipboard.md`. | +| `OSC 1337 ; RequestUpload=...` | Begin file upload from terminal | No file-transfer protocol. | +| `OSC 1337 ; File=...` | Inline image protocol | No inline-image rendering. | +| `OSC 1337 ; SetUserVar=...` | Set a per-tab user variable | No user-variable surface. | +| `OSC 50 ; <font> ST` | Set font dynamically | Font is host-controlled. | +| `OSC 52 ; <selection> ; <data> ST` | Programmatic clipboard write | Security: same rationale as `CopyToClipboard`. | + +This list is non-exhaustive. Any iTerm2 OSC not in the [Supported OSCs](#supported-oscs) table is ignored. + +## References + +- iTerm2 proprietary escape codes: https://iterm2.com/documentation-escape-codes.html +- xterm control sequences (OSC 0 / 2 / 7): https://invisible-island.net/xterm/ctlseqs/ctlseqs.html +- VS Code shell integration sequences (OSC 633): https://code.visualstudio.com/docs/terminal/shell-integration +- Windows Terminal CWD OSC 9;9: https://learn.microsoft.com/en-us/windows/terminal/tutorials/new-tab-same-directory +- kitty desktop notifications (OSC 99): https://sw.kovidgoyal.net/kitty/desktop-notifications/ +- WezTerm escape sequences (OSC 777): https://wezterm.org/escape-sequences.html diff --git a/docs/specs/alert.md b/docs/specs/alert.md index c32965d..3ac61f0 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -4,7 +4,7 @@ The alert system is an opt-in reminder for a **Session** that may finish work while the user is looking elsewhere. Alert state lives on the Session itself, not on the Pane or Door that currently displays it. -Explicit terminal notification/progress reports are the exception to the opt-in rule. `OSC 9`, `OSC 9;4`, `OSC 99`, `OSC 777`, and standalone terminal `BEL` handling is specified in `docs/specs/iTerm2.md`; those protocol signals may cock the bell or force `ALERT_RINGING` even when the activity monitor is disabled. +Explicit terminal notification/progress reports are the exception to the opt-in rule. `OSC 9`, `OSC 9;4`, `OSC 99`, `OSC 777`, and standalone terminal `BEL` handling is specified in [Notification protocols](#notification-protocols) below; those protocol signals may cock the bell or force `ALERT_RINGING` even when the activity monitor is disabled. The OSC sequence registry and parsing-location rules live in `docs/specs/OSC.md`. This spec uses semantic state names that describe what the Session currently owes the user: @@ -20,7 +20,10 @@ This document is the source of truth for the naming and behavior of this state m ## Non-goals - No command sniffing or per-tool heuristics. We do not try to guess whether `vim`, `npm dev`, `claude`, or any other command is "appropriate" for alerts. -- No sound, OS notifications, or browser notifications in v1. +- No sound, native OS notifications, or browser notifications in v1. "Alarm" means MouseTerm's existing `ALERT_RINGING` visual state. +- No standalone progress bar widget. `OSC 9;4` progress updates `protocolStatus` while active; completion/error creates TODO detail. It does not add a separate progress widget to the Pane header. +- No full iTerm2/kitty/rxvt/WezTerm feature parity. Unsupported sequences are ignored unless another spec claims them. +- No HTML, Markdown, ANSI styling, shell command parsing, or clickable action buttons inside TODO notification previews. - No Door-specific alert menu that overrides the existing click-to-reattach behavior from `docs/specs/layout.md`. ## When alerts are useful @@ -54,7 +57,7 @@ Each Session owns: - It is driven only by meaningful output, silence timers, and attention. - It may be deleted in a future terminal-report-only implementation without changing the protocol notification model. - `protocolStatus: 'IDLE' | 'OSC_NOTIF_BUSY' | 'ALERT_RINGING'` - - Internal terminal-report status owned by parsed terminal reports from `docs/specs/iTerm2.md`. + - Internal terminal-report status owned by parsed terminal reports (see [Notification protocols](#notification-protocols)). - It is driven only by terminal reports such as `OSC 9`, `OSC 9;4`, `OSC 99`, `OSC 777`, and standalone `BEL`. - It does not use output/silence timers from the visual activity monitor. - It does use the shared attention model. A protocol completion/notification received while the user is actively attending that Session must not ring. @@ -73,10 +76,35 @@ Each Session also owns: - True when the user attended to a ringing Session (clicked into the Pane, typed in passthrough, etc.). Cleared when the bell is next clicked or the alert is toggled/disabled. Used by the bell button to show the context menu on the next click instead of immediately disabling. - `notification: ActivityNotification | null` - Latest explicit protocol notification detail, when a Session received a supported terminal notification sequence. - - Defined in `docs/specs/iTerm2.md`. - This metadata is attached to TODO/alert state; it does not replace the boolean `todo` model or the visible TODO pill text. - `OSC 9;4` progress is tracked through `protocolStatus` while active; completion/error promotes it into this notification field. +`ActivityNotification` shape (intentionally small — these are the only fields rendered): + +```ts +type ActivityNotificationSource = 'OSC 9' | 'OSC 9;4' | 'OSC 99' | 'OSC 777' | 'BEL'; + +interface ActivityNotification { + source: ActivityNotificationSource; + title: string | null; + body: string | null; +} +``` + +Per-source mapping rules (full protocol semantics in [Notification protocols](#notification-protocols)): + +- `OSC 9` stores `{ source: 'OSC 9', title: null, body: message }`. +- `OSC 777` stores `{ source: 'OSC 777', title, body }`. +- `OSC 99` stores `{ source: 'OSC 99', title, body }` after chunk assembly and sanitization. +- `OSC 9;4` stores nothing while progress is active. On completion/error it generates `{ source: 'OSC 9;4', title, body }`, where `title` is a short summary such as `Progress complete`, `Progress error`, or `Progress warning`, and `body` contains the percent when available. +- Standalone `BEL` stores `{ source: 'BEL', title: 'Terminal bell', body: null }`. + +Persistence rules: + +- Persist the latest `ActivityNotification` with the Session's alert state. +- Persist only sanitized text and metadata, not raw escape sequences. +- On restore, persisted notification detail should restore TODO detail, but must not create a fresh ring or re-cock the bell by itself. + The workspace owns: - `attentionSessionId: string | null` @@ -238,6 +266,178 @@ These transition rules apply to the visual track only. `OSC_NOTIF_BUSY` is not e The implementation may later learn additional suppressions, but this spec only requires resize churn suppression today. +## Notification protocols + +Protocol notifications and standalone terminal bells are explicit application requests for attention. They bypass the normal opt-in activity monitor: a Session may ring even when its alert toggle was disabled. They must not ring while the user is actively attending that Session. + +Progress sequences do not ring immediately. They "cock" the alarm bell — MouseTerm treats active progress as an explicit finite-work cycle, exposes `OSC_NOTIF_BUSY`, and rings when the cycle completes or enters an error state. + +The OSC sequence registry, parser placement, and stripping behavior live in `docs/specs/OSC.md`. This section defines per-protocol semantics for the five supported notification sources. + +| Protocol | Shape | Fields | Notes | +|---|---|---|---| +| `BEL` | `BEL` outside an OSC sequence | none | Generic terminal-bell notification. | +| `OSC 9` | `OSC 9 ; [message] ST` | `message` | iTerm2's legacy notification form. No title/body split. | +| `OSC 9;4` | `OSC 9 ; 4 ; [state] ; [progress] ST` or `OSC 9 ; 4 ST` | progress state/progress | Progress only. Cocks the bell and may later ring on completion/error. | +| `OSC 99` | `OSC 99 ; [metadata] ; [payload] ST` | metadata keys plus payload | kitty's rich notification protocol. Chunked and extensible. | +| `OSC 777` | `OSC 777 ; notify ; [title] ; [body] ST` | `title`, `body` | rxvt/WezTerm notification form. Only `notify` is supported. | + +### Standalone BEL + +A `BEL` byte outside an OSC sequence creates one generated notification: + +- `source: 'BEL'` +- `title: 'Terminal bell'` +- `body: null` + +Standalone `BEL` is for compatibility with tools that choose a plain terminal-bell notification channel. It strips the bell byte from visible terminal output and rings through the same protocol path as OSC notifications, subject to the shared user-attention check. + +If a parse batch contains both standalone `BEL` and a richer OSC notification/progress event, MouseTerm keeps the richer OSC event and drops the generic `BEL` notification detail so `iterm2_with_bell`-style tools cannot overwrite useful TODO preview text. + +### OSC 9 + +`OSC 9 ; [message] ST` creates one notification: + +- `source: 'OSC 9'` +- `title: null` +- `body: [message]` + +The message is plain text. There is no formal title, subtitle, urgency, app id, or notification id. + +OSC 9 also feeds the title-candidate channel for header/door label derivation; that side effect is specified in `docs/specs/terminal-state.md` and does not affect alert behavior. + +### OSC 9;4 progress + +If the first OSC 9 parameter is `4`, the sequence belongs to the progress protocol: + +- `OSC 9 ; 4 ST` clears progress +- `OSC 9 ; 4 ; 0 ST` clears progress +- `OSC 9 ; 4 ; 1 ; [0-100] ST` sets normal progress +- `OSC 9 ; 4 ; 2 ; [0-100?] ST` sets error progress +- `OSC 9 ; 4 ; 3 ST` sets indeterminate progress +- `OSC 9 ; 4 ; 4 ; [0-100] ST` sets warning progress + +The official fields are only: + +- `state` +- optional `progress` percent + +There is no title, body, subtitle, notification id, application name, urgency, or message text in `OSC 9;4`. + +MouseTerm behavior: + +- Non-clear states create or update an internal protocol progress cycle. +- Active progress cocks the bell by setting `protocolStatus = OSC_NOTIF_BUSY`. Public `status` projects this as `OSC_NOTIF_BUSY`, which looks the same as `BUSY` but is independent of the timer-based activity monitor. +- `state = 1` with `progress < 100` is normal active progress. Do not ring. +- `state = 1` with `progress = 100` is a completion report. Ring immediately as a completed progress cycle only if the Session lacks attention. +- `state = 2` is an error signal. Ring immediately and attach a generated progress notification to the TODO only if the Session lacks attention. +- `state = 3` is indeterminate active progress. Do not ring until cleared or replaced by an error/completion signal. +- `state = 4` is warning active progress. Do not ring immediately; remember the warning internally, and if the cycle later rings MouseTerm preserves that warning in the generated progress notification. +- `state = 0` or abbreviated `OSC 9 ; 4 ST` clears progress. If it clears an active protocol progress cycle, ring as completion. If there was no active protocol progress cycle, ignore it. +- Invalid states, missing required progress values for states `1` and `4`, and out-of-range progress values are ignored. Clamp only for display if an implementation has already accepted the sequence. + +Progress completion creates a generated notification, but does not invent copy beyond the normalized progress summary. The TODO preview should say things like `Progress complete`, `Progress error`, `Progress warning`, or `Progress 75%` rather than replacing the TODO pill text. + +### OSC 777 + +`OSC 777 ; notify ; [title] ; [body] ST` creates one notification: + +- `source: 'OSC 777'` +- `title: [title]` +- `body: [body]` + +Only the `notify` subcommand is supported. The format has no escaping for semicolons. For compatibility, parse the title as the field after `notify` and treat the rest of the sequence after the next semicolon as the body, preserving additional semicolons in the body. A title containing a semicolon cannot be represented portably. + +### OSC 99 + +`OSC 99 ; [metadata] ; [payload] ST` uses colon-delimited metadata where each key is a single ASCII letter. Unknown keys are ignored. Unknown payload types are ignored unless this spec adds them later. + +Initial supported metadata keys: + +| Key | Meaning | Initial MouseTerm behavior | +|---|---|---| +| `i` | notification identifier | Used to assemble chunks and coalesce updates for the same notification. | +| `d` | done flag, `0` or `1`, default `1` | `d=0` stores a partial notification without ringing. `d=1` completes and rings. | +| `e` | payload encoding, `0` plain or `1` base64 | Decode RFC 4648 base64 when `e=1`; reject invalid base64. | +| `p` | payload type, default `title` | Support `title` and `body`; handle management/query payloads separately. | +| `f` | base64 application name | Decode only if needed for protocol validity; do not store or render in this phase. | +| `t` | base64 notification type | Ignore in this phase. | +| `u` | urgency, `0`, `1`, or `2` | Ignore in this phase; urgency does not change alert mechanics. | +| `o` | occasion, `always`, `unfocused`, `invisible` | Parse but ignore for MouseTerm ringing; explicit OSC notifications always ring. | +| `w` | auto-close milliseconds | Parse but ignore for TODO lifetime. TODO clears only by MouseTerm's normal TODO clearing rules. | + +Payload types: + +| `p` value | Behavior | +|---|---| +| `title` | Append payload to the pending notification title. | +| `body` | Append payload to the pending notification body. | +| `?` | Support query. Does not ring. | +| `close` | Close/update management. Does not ring. | +| `alive` | Liveness query. Does not ring. | +| `icon` | Ignore payload content in this phase. Does not ring by itself. | +| `buttons` | Ignore payload content in this phase. Does not ring by itself. | + +Official kitty OSC 99 does not define a `subtitle` payload. If real-world agent tools emit `p=subtitle`, ignore it unless a later spec chooses to render a third user-facing text field. + +For a completed OSC 99 notification: + +- If title and body are both empty after sanitization, ignore it. +- If there is a body but no title, the body is the primary preview line. +- If there is a title but no body, render title only. +- If the same `i` arrives again after completion, treat it as an update to the same notification detail and ring again. +- If `i` is omitted, each completed notification is unique. + +Support query: + +- `OSC 99 ; i=[id] : p=? ; ST` must be answered with MouseTerm's actual support. +- Initial minimal response advertises only `title` and `body`, for example: `OSC 99 ; i=[id] : p=? ; o=always:p=title,body ST`. +- Preserve a valid query id in the response metadata. If the id is missing or cannot be safely echoed in OSC 99 metadata, omit `i=[id]` and respond with `OSC 99 ; p=? ; o=always:p=title,body ST`. +- Do not advertise click reports, close reports, urgency, sounds, icons, buttons, or auto-expiry unless implemented end-to-end. + +## Notification text handling + +Terminal notifications are untrusted terminal output. Treat all text as plain text. + +Input normalization: + +- Decode UTF-8 strictly enough to avoid replacement-character floods. +- Strip C0/C1 control characters after protocol parsing. +- Collapse CR/LF/TAB and other controls to spaces. +- Trim leading/trailing whitespace. +- Do not interpret ANSI, OSC, HTML, Markdown, URLs, shell paths, or emoji shortcodes as markup. + +Protocol-defined limits: + +- OSC 9;4 progress carries only a numeric state and optional numeric percent. There is no user-facing text payload. +- OSC 99 defines a payload chunk limit of 2048 bytes before base64 or 4096 bytes after base64. It permits chunking title/body multiple times, while allowing terminals to impose sensible denial-of-service limits. +- OSC 9 and OSC 777 do not define formal text length limits in the referenced terminal docs. + +MouseTerm-imposed limits: + +- Store at most 256 Unicode grapheme clusters for `title`. +- Store at most 4096 grapheme clusters for `body`. +- Parser memory for incomplete OSC 99 chunks is capped per Session. Drop the oldest incomplete chunks when the cap is exceeded. +- Expire incomplete OSC 99 chunks after 60 seconds if no `d=1` completion arrives. + +Expected UI copy length: + +- Titles are expected to be one short line, usually under 80 characters. +- Bodies are expected to be a few short lines at most. In MouseTerm chrome, show a compact preview and make the full stored body available in a popover/dialog. + +## Notification security + +Any remote process can emit these sequences over SSH. The feature is useful because it works over SSH, but the UI must be robust against hostile text. + +Requirements: + +- Sanitize all text before storing or rendering. +- Cap stored text and incomplete parser state. +- Never execute commands, open URLs, copy to clipboard, read files, or focus outside MouseTerm from these sequences. +- Do not render custom icons or buttons in this phase. +- Do not let notification text alter accessible labels beyond plain-text names. +- Do not allow repeated notifications to allocate unbounded history. Store only the latest detail, not an infinite list. + ## Alert trigger Visual alert logic is driven by transitions in `visualStatus`. Protocol alert logic is driven by transitions in `protocolStatus`. The public `status` projection reflects whichever track currently has the strongest user-facing claim. @@ -250,7 +450,7 @@ Visual alert logic is driven by transitions in `visualStatus`. Protocol alert lo ### Protocol override -Supported terminal notification reports from `docs/specs/iTerm2.md` may create a protocol ring. Supported `OSC 9;4` progress sequences set `protocolStatus = OSC_NOTIF_BUSY` and may later promote to `protocolStatus = ALERT_RINGING`. Protocol rings: +Supported terminal notification reports (see [Notification protocols](#notification-protocols)) may create a protocol ring. Supported `OSC 9;4` progress sequences set `protocolStatus = OSC_NOTIF_BUSY` and may later promote to `protocolStatus = ALERT_RINGING`. Protocol rings: - force public `status = ALERT_RINGING` even when the Session's activity monitor is disabled - obey attention suppression because the user may already be typing into or reading that Session @@ -258,6 +458,16 @@ Supported terminal notification reports from `docs/specs/iTerm2.md` may create a - do not enable or disable the activity monitor - return to `ALERT_DISABLED` after dismissal if no activity monitor was enabled before the protocol ring +Implementation surface inside `AlertManager`: + +- A protocol-ring flag or source field independent of `ActivityMonitor`. +- `OSC 9;4` progress is tracked internally in `AlertManager`, not in public `ActivityState`. +- `getState(id).status` returns `ALERT_RINGING` while the protocol ring is active. +- `getState(id).status` returns `OSC_NOTIF_BUSY` while internal protocol progress is active and no stronger state is present. +- Dismiss/attend clears the protocol ring; status falls back to the visual track or `ALERT_DISABLED` if no `ActivityMonitor` exists. +- Completing or erroring a protocol progress cycle creates an `ActivityNotification` and promotes it into a protocol ring only if the Session lacks attention. +- Methods such as `notifyFromProtocol(id, notification)` and `updateProtocolProgress(id, state, percent)` are exposed through `PlatformAdapter` / VS Code messages. + ### Ringing does not start when any of these are true - the Session already has attention at the moment it would otherwise enter `ALERT_RINGING` @@ -280,7 +490,7 @@ For activity-monitor rings, the Session leaves `ALERT_RINGING` and returns to `N All attention-based dismissals (the first three above) set `todo = true` if it is not already set. This prevents phantom dismissals where the alert vanishes without a trace. Once the TODO is visible, the user can clear it explicitly from the pill/dialog or by typing `Enter` as passthrough input into that Session's shell (i.e., the keystroke is forwarded to the PTY). The command-mode `Enter` that *switches into* passthrough does not clear the TODO. Synthetic terminal reports (focus events, cursor-position responses) also do not count as user input for clearing. -For protocol rings from `docs/specs/iTerm2.md`, clearing the protocol ring sets `protocolStatus = IDLE` and returns public `status` to the projected visual-track state. If no visual activity monitor was enabled before the protocol ring, the Session returns to `ALERT_DISABLED`. +For protocol rings (see [Notification protocols](#notification-protocols)), clearing the protocol ring sets `protocolStatus = IDLE` and returns public `status` to the projected visual-track state. If no visual activity monitor was enabled before the protocol ring, the Session returns to `ALERT_DISABLED`. The visual track leaves `ALERT_RINGING` and returns to `ALERT_DISABLED` when: @@ -294,7 +504,7 @@ The Session's alert state is cleared entirely when: If more output arrives later and the Session makes a fresh transition back into `ALERT_RINGING`, the alert rings again. -Marking a Session as TODO resets an activity-monitor alert to `NOTHING_TO_SHOW` and sets `todo = true`, but it does **not** disable future alerts. `todo` and the alert toggle are separate concerns. Protocol rings preserve the same TODO behavior, with return-state details defined in `docs/specs/iTerm2.md`. +Marking a Session as TODO resets an activity-monitor alert to `NOTHING_TO_SHOW` and sets `todo = true`, but it does **not** disable future alerts. `todo` and the alert toggle are separate concerns. Protocol rings preserve the same TODO behavior; clearing TODO clears `notification` unless the user explicitly chooses a future "keep details" action. Disabling alerts disposes the visual activity monitor and returns `visualStatus` to `ALERT_DISABLED`. Public `status` returns to `ALERT_DISABLED` only when `protocolStatus === 'IDLE'`. @@ -316,6 +526,7 @@ TODO pill: - clicking the TODO pill clears it - when TODO clears, the pill briefly morphs to a `✓` glyph in the success color (~500 ms) before unmounting — this marks the moment of completion so the pill never vanishes silently - no empty placeholder when off +- the visible pill remains `TODO`. It does not resize to arbitrary notification text, and does not adopt protocol-supplied title/body strings. It may show a small dot treatment when notification detail is present, as long as the pill remains fixed-width enough for narrow headers. Alert button: @@ -348,6 +559,30 @@ Interaction (`dismissOrToggleAlert` state machine): The alert control has higher layout priority than split or zoom controls. Long titles must truncate before the bell disappears. +### Notification preview and detail + +Protocol notification detail appears in a preview surface anchored below the TODO pill or alert bell: + +- Shown on TODO hover/focus. +- Shown when the selected Pane has a TODO with notification detail and there is enough space. +- Shown above a Door on hover/focus without changing Door click behavior. +- Click/`Enter` on a Door remains reattach-and-attend; no Door-only menus. + +Preview content: + +- Primary line: `title` if present, otherwise the first body excerpt. +- Body: clamp to 3 lines in the hover preview. +- For generated `OSC 9;4` notifications, title/body already contain the progress summary; no separate progress widget is rendered. +- Footer metadata: protocol source (`OSC 9`, `OSC 9;4`, `OSC 99`, `OSC 777`, `BEL`). + +A full detail dialog/popover may be opened from the preview or the existing alert context menu: + +- Text wraps and can scroll. +- No raw escape sequence is shown by default. +- Focus traps and `Escape` behavior follow [Accessibility and motion](#accessibility-and-motion). + +Recommended decision: do not replace TODO text with notification text. The header and Door need fixed, scannable indicators across many Sessions. Replacing `TODO` with unbounded remote-controlled text creates overflow, localization, spoofing, and attention-noise problems. A hover/selected expansion gives the notification context without destabilizing the layout. + ### Door A Door is display-only for alert state in v1. It must not replace the existing Door primary actions defined in `docs/specs/layout.md`. @@ -457,8 +692,58 @@ Consequences: - User never presses `Enter` into the terminal → TODO persists. - User later notices the TODO pill and clicks it to clear it. +### OSC 9 rings with alerts disabled + +- Session starts with `status = ALERT_DISABLED`, `todo = false`. +- PTY emits `OSC 9 ; Build finished ST`. +- MouseTerm stores body `Build finished`, sets `todo = true`, and reports `ALERT_RINGING`. +- User clicks into the Pane. +- Ring clears. Because the activity monitor was disabled, status returns to `ALERT_DISABLED`; TODO remains until explicitly cleared or passthrough `Enter` is sent. + +### OSC 777 preserves title and body + +- PTY emits `OSC 777 ; notify ; Tests ; 341 passed ST`. +- Preview primary line is `Tests`. +- Preview body is `341 passed`. +- The TODO pill remains `TODO`. + +### OSC 99 chunked title/body + +- PTY emits `OSC 99 ; i=build-1:d=0 ; Build complete ST`. +- No ring yet. +- PTY emits `OSC 99 ; i=build-1:p=body:d=1 ; All tests passed ST`. +- MouseTerm combines title and body, then rings once. + +### OSC 9 progress cocks the bell + +- PTY emits `OSC 9 ; 4 ; 1 ; 50 ST`. +- MouseTerm stores progress `normal, 50%`. +- Public `status` becomes `OSC_NOTIF_BUSY`; the bell looks like `BUSY` without creating a TODO. +- PTY emits `OSC 9 ; 4 ; 0 ST` while the Session lacks attention. +- MouseTerm rings, sets `todo = true`, and the TODO preview says progress completed. + +### OSC 9 progress error rings immediately + +- PTY emits `OSC 9 ; 4 ; 2 ; 75 ST` while the Session lacks attention. +- MouseTerm stores progress `error, 75%`. +- MouseTerm rings immediately and attaches error progress detail to the TODO. + +### OSC notification while typing does not ring + +- User is typing into a Session in passthrough mode, so the Session has attention. +- PTY emits `OSC 9 ; Build finished ST`. +- MouseTerm does not ring and does not create a TODO because the user is already attending that Session. + +### Restore does not replay old notifications + +- A Session receives an OSC notification and saves state with TODO detail. +- The app reloads and replays buffered output containing the original OSC. +- The TODO detail is restored from persisted state, but no fresh ring is emitted from replay. + ## Verification checklist +Visual track: + - Alert only rings on a fresh transition into `ALERT_RINGING` - Single quick responses stay in `NOTHING_TO_SHOW` - short pauses in a `BUSY` session only reach `MIGHT_NEED_ATTENTION`, not `ALERT_RINGING` @@ -469,3 +754,34 @@ Consequences: - very long titles do not push bell or TODO indicators out of bounds - ringing is still understandable with reduced motion enabled - multiple simultaneous ringing Sessions remain independently dismissible + +Notification protocols: + +- `OSC 9;message` rings and stores `message`. +- `OSC 9;4;1;50` sets `OSC_NOTIF_BUSY` and stores `normal, 50%` internally. +- `OSC 9;4;3` sets `OSC_NOTIF_BUSY` and stores indeterminate progress internally. +- `OSC 9;4;4;25` sets `OSC_NOTIF_BUSY` and stores warning progress internally. +- `OSC 9;4;2` rings immediately with indeterminate error detail. +- `OSC 9;4;0` rings as completion only if there was an active progress cycle. +- `OSC 9;4;1;100` rings immediately as an explicit completion report. +- Standalone `BEL` rings and stores generated terminal-bell detail. +- `OSC 777;notify;title;body` rings and stores title/body. +- Unsupported `OSC 777` subcommands are ignored. +- OSC 99 `d=0` chunks do not ring before completion. +- OSC 99 `d=1` completion rings once with combined title/body. +- OSC 99 `p=?` is answered and does not ring; `p=close`, `p=alive`, `p=icon`, and `p=buttons` do not ring by themselves. +- Extra standalone `BEL` in the same parse batch as a richer OSC event does not replace the richer notification detail. +- Protocol notifications ring with alert disabled. +- Protocol notifications do not ring when the Session has attention. +- Dismissal returns an alert-disabled Session to `ALERT_DISABLED`. +- Dismissal returns an alert-enabled Session to its monitor-backed state. +- TODO pill text remains stable under very long notification text. +- Hover/focus preview wraps long text and does not overflow narrow headers or Doors. +- Replay/restore does not re-fire notification side effects. + +## References + +- iTerm2 proprietary escape codes (OSC 9, OSC 9;4): https://iterm2.com/documentation-escape-codes.html +- kitty desktop notifications (OSC 99): https://sw.kovidgoyal.net/kitty/desktop-notifications/ +- WezTerm escape sequences and notification handling (OSC 9, OSC 777): https://wezterm.org/escape-sequences.html, https://wezterm.org/config/lua/config/notification_handling.html +- foot control sequences (OSC 9, OSC 99, OSC 777): https://manpages.ubuntu.com/manpages/resolute/man7/foot-ctlseqs.7.html diff --git a/docs/specs/iTerm2.md b/docs/specs/iTerm2.md deleted file mode 100644 index 3241e38..0000000 --- a/docs/specs/iTerm2.md +++ /dev/null @@ -1,431 +0,0 @@ -# iTerm2 Compatibility Spec - -> See `docs/specs/ontology.md` for canonical Session vocabulary and `docs/specs/alert.md` for the Activity state machine. This spec defines terminal-emulator identity and explicit terminal notification escape sequences. CWD, shell lifecycle, OSC 133, OSC 633, OSC 1337 `CurrentDir`, and OSC 0/2 title fallback are defined in `docs/specs/terminal-state.md`. - -## Goal - -MouseTerm should be compatible with applications that look for iTerm2-style terminal behavior when they want to notify the user from inside a PTY. The first supported surfaces are terminal notifications and progress-driven alert arming: - -- `OSC 9` iTerm2 notification form -- `OSC 9;4` iTerm2 progress form -- `OSC 99` kitty desktop notification protocol -- `OSC 777` rxvt / WezTerm `notify` form -- standalone terminal `BEL` (`\x07`) for tools that choose a terminal-bell notification channel - -Notification sequences and standalone terminal bells are explicit application requests for attention. They bypass the normal opt-in activity monitor. If a Session receives a complete displayable notification sequence or a standalone `BEL`, the Session may ring even when its alert toggle was disabled. It must not ring while the user is actively attending that Session. - -Progress sequences do not ring immediately. They "cock" the alarm bell: MouseTerm treats active progress as an explicit finite-work cycle, exposes `OSC_NOTIF_BUSY`, and rings when the progress cycle completes or enters an error state. - -Legacy `OSC 9` message text also participates in pane header/door title derivation as an app-sent title override, even when the alert ring itself is suppressed because the Session has attention. Rich notification titles from `OSC 99` and `OSC 777` are stored as title candidates for the header diagnostic popup, but they are not tab/door title overrides; they stay in the TODO notification UI. - -## Non-goals - -- No native OS notifications, browser notifications, or sound in this phase. "Alarm" means MouseTerm's existing `ALERT_RINGING` visual state. -- No standalone progress bar in this phase. `OSC 9;4;...` updates `protocolStatus` and internal progress state while active; completion/error creates TODO detail. It does not add a separate progress widget to the Pane header. -- No full iTerm2 feature parity. Unsupported iTerm2, kitty, rxvt, or WezTerm sequences are ignored unless another spec claims them. -- No HTML, markdown, ANSI styling, shell command parsing, or clickable action buttons inside TODO notification previews. - -## Identity - -MouseTerm should report an iTerm2-compatible identity only to unlock behavior that this spec or later specs intentionally support. - -Environment for spawned PTYs: - -| Variable | Value | -|---|---| -| `TERM_PROGRAM` | `iTerm.app` | -| `TERM_PROGRAM_VERSION` | MouseTerm's chosen iTerm2 compatibility version, not the package version | -| `LC_TERMINAL` | `iTerm2` only if needed by real-world shell integrations | -| `LC_TERMINAL_VERSION` | same compatibility version as `TERM_PROGRAM_VERSION` | - -Device/version query: - -- On `CSI > q`, respond with `DCS > | iTerm2 [version] ST`, matching iTerm2's extended device attributes response shape. -- Use a single compatibility version across env and device responses. -- Do not advertise feature-specific support until the relevant behavior exists. - -Because this identity can cause tools to emit more iTerm2 escape codes, unsupported escape codes must fail inertly: consume or ignore them without visible terminal garbage, privilege escalation, clipboard access, file access, or focus stealing. - -## Supported Protocols - -The OSC notification families use sequences introduced by `ESC ]`. MouseTerm must accept either `BEL` (`\x07`) or `ST` (`ESC \`) terminators for these notification families. A `BEL` that terminates an OSC is part of that OSC sequence, not a standalone bell notification. - -The same streaming parser also recognizes semantic terminal OSCs from `docs/specs/terminal-state.md`: - -- `OSC 7` / `OSC 9;9` / `OSC 633;P;Cwd=` / `OSC 1337;CurrentDir=` for CWD -- `OSC 133` and `OSC 633` prompt/command boundaries -- `OSC 0` and `OSC 2` title fallback - -Those semantic sequences are normalized into `TerminalSemanticEvent` and consumed by terminal state, not by the Activity alert machine. - -| Protocol | Shape | Fields | Notes | -|---|---|---|---| -| `BEL` | `BEL` outside an OSC sequence | none | Generic terminal-bell notification. | -| `OSC 9` | `OSC 9 ; [message] ST` | `message` | iTerm2's legacy notification form. No title/body split. | -| `OSC 9;4` | `OSC 9 ; 4 ; [state] ; [progress] ST` or `OSC 9 ; 4 ST` | progress state/progress | Progress only. Cocks the bell and may later ring on completion/error. | -| `OSC 99` | `OSC 99 ; [metadata] ; [payload] ST` | metadata keys plus payload | kitty's rich notification protocol. Chunked and extensible. | -| `OSC 777` | `OSC 777 ; notify ; [title] ; [body] ST` | `title`, `body` | rxvt/WezTerm notification form. Only `notify` is supported. | - -### Standalone BEL - -A `BEL` byte outside an OSC sequence creates one generated notification: - -- `source: 'BEL'` -- `title: 'Terminal bell'` -- `body: null` - -Standalone `BEL` is for compatibility with tools that choose a plain terminal-bell notification channel. It strips the bell byte from visible terminal output and rings through the same protocol path as OSC notifications, subject to the shared user-attention check. - -If a parse batch contains both standalone `BEL` and a richer OSC notification/progress event, MouseTerm keeps the richer OSC event and drops the generic `BEL` notification detail so `iterm2_with_bell`-style tools cannot overwrite useful TODO preview text. - -### OSC 9 - -`OSC 9 ; [message] ST` creates one notification: - -- `source: 'OSC 9'` -- `title: null` -- `body: [message]` - -The message is plain text. There is no formal title, subtitle, urgency, app id, or notification id. - -If the first OSC 9 parameter is `4`, the sequence belongs to the progress protocol: - -- `OSC 9 ; 4 ST` clears progress -- `OSC 9 ; 4 ; 0 ST` clears progress -- `OSC 9 ; 4 ; 1 ; [0-100] ST` sets normal progress -- `OSC 9 ; 4 ; 2 ; [0-100?] ST` sets error progress -- `OSC 9 ; 4 ; 3 ST` sets indeterminate progress -- `OSC 9 ; 4 ; 4 ; [0-100] ST` sets warning progress - -The official fields are only: - -- `state` -- optional `progress` percent - -There is no title, body, subtitle, notification id, application name, urgency, or message text in `OSC 9;4`. - -MouseTerm behavior: - -- Non-clear states create or update an internal protocol progress cycle. -- Active progress cocks the bell by setting `protocolStatus = OSC_NOTIF_BUSY`. Public `status` projects this as `OSC_NOTIF_BUSY`, which looks the same as `BUSY` but is independent of the timer-based activity monitor. -- `state = 1` with `progress < 100` is normal active progress. Do not ring. -- `state = 1` with `progress = 100` is a completion report. Ring immediately as a completed progress cycle only if the Session lacks attention. -- `state = 2` is an error signal. Ring immediately and attach a generated progress notification to the TODO only if the Session lacks attention. -- `state = 3` is indeterminate active progress. Do not ring until cleared or replaced by an error/completion signal. -- `state = 4` is warning active progress. Do not ring immediately; remember the warning internally, and if the cycle later rings MouseTerm preserves that warning in the generated progress notification. -- `state = 0` or abbreviated `OSC 9 ; 4 ST` clears progress. If it clears an active protocol progress cycle, ring as completion. If there was no active protocol progress cycle, ignore it. -- Invalid states, missing required progress values for states `1` and `4`, and out-of-range progress values are ignored. Clamp only for display if an implementation has already accepted the sequence. - -Progress completion creates a generated notification, but does not invent copy beyond the normalized progress summary. The TODO preview should say things like `Progress complete`, `Progress error`, `Progress warning`, or `Progress 75%` rather than replacing the TODO pill text. - -### OSC 777 - -`OSC 777 ; notify ; [title] ; [body] ST` creates one notification: - -- `source: 'OSC 777'` -- `title: [title]` -- `body: [body]` - -Only the `notify` subcommand is supported. The format has no escaping for semicolons. For compatibility, parse the title as the field after `notify` and treat the rest of the sequence after the next semicolon as the body, preserving additional semicolons in the body. A title containing a semicolon cannot be represented portably. - -### OSC 99 - -`OSC 99 ; [metadata] ; [payload] ST` uses colon-delimited metadata where each key is a single ASCII letter. Unknown keys are ignored. Unknown payload types are ignored unless this spec adds them later. - -Initial supported metadata keys: - -| Key | Meaning | Initial MouseTerm behavior | -|---|---|---| -| `i` | notification identifier | Used to assemble chunks and coalesce updates for the same notification. | -| `d` | done flag, `0` or `1`, default `1` | `d=0` stores a partial notification without ringing. `d=1` completes and rings. | -| `e` | payload encoding, `0` plain or `1` base64 | Decode RFC 4648 base64 when `e=1`; reject invalid base64. | -| `p` | payload type, default `title` | Support `title` and `body`; handle management/query payloads separately. | -| `f` | base64 application name | Decode only if needed for protocol validity; do not store or render in this phase. | -| `t` | base64 notification type | Ignore in this phase. | -| `u` | urgency, `0`, `1`, or `2` | Ignore in this phase; urgency does not change alert mechanics. | -| `o` | occasion, `always`, `unfocused`, `invisible` | Parse but ignore for MouseTerm ringing; explicit OSC notifications always ring. | -| `w` | auto-close milliseconds | Parse but ignore for TODO lifetime. TODO clears only by MouseTerm's normal TODO clearing rules. | - -Payload types: - -| `p` value | Behavior | -|---|---| -| `title` | Append payload to the pending notification title. | -| `body` | Append payload to the pending notification body. | -| `?` | Support query. Does not ring. | -| `close` | Close/update management. Does not ring. | -| `alive` | Liveness query. Does not ring. | -| `icon` | Ignore payload content in this phase. Does not ring by itself. | -| `buttons` | Ignore payload content in this phase. Does not ring by itself. | - -Official kitty OSC 99 does not define a `subtitle` payload. If real-world agent tools emit `p=subtitle`, ignore it unless a later spec chooses to render a third user-facing text field. - -For a completed OSC 99 notification: - -- If title and body are both empty after sanitization, ignore it. -- If there is a body but no title, the body is the primary preview line. -- If there is a title but no body, render title only. -- If the same `i` arrives again after completion, treat it as an update to the same notification detail and ring again. -- If `i` is omitted, each completed notification is unique. - -Support query: - -- `OSC 99 ; i=[id] : p=? ; ST` must be answered with MouseTerm's actual support. -- Initial minimal response advertises only `title` and `body`, for example: `OSC 99 ; i=[id] : p=? ; o=always:p=title,body ST`. -- Preserve a valid query id in the response metadata. If the id is missing or cannot be safely echoed in OSC 99 metadata, omit `i=[id]` and respond with `OSC 99 ; p=? ; o=always:p=title,body ST`. -- Do not advertise click reports, close reports, urgency, sounds, icons, buttons, or auto-expiry unless implemented end-to-end. - -## Normalized Data Model - -Protocol notifications are normalized before they touch UI. Keep this shape intentionally small: these are only fields MouseTerm plans to render. - -```typescript -type ActivityNotificationSource = 'OSC 9' | 'OSC 9;4' | 'OSC 99' | 'OSC 777' | 'BEL'; - -interface ActivityNotification { - source: ActivityNotificationSource; - title: string | null; - body: string | null; -} -``` - -Extend `ActivityState` with: - -```typescript -interface ActivityState { - status: SessionStatus; // projected public status, may be OSC_NOTIF_BUSY - todo: boolean; - notification: ActivityNotification | null; -} -``` - -`todo` remains a boolean. Do not replace the TODO pill text with arbitrary notification or progress text. `notification` is the only user-facing detail attached to TODO/alert state. - -Mapping rules: - -- `OSC 9` stores `{ source: 'OSC 9', title: null, body: message }`. -- `OSC 777` stores `{ source: 'OSC 777', title, body }`. -- `OSC 99` stores `{ source: 'OSC 99', title, body }` after chunk assembly and sanitization. -- `OSC 9;4` stores nothing while progress is active. On completion/error it generates `{ source: 'OSC 9;4', title, body }`, where `title` is a short summary such as `Progress complete`, `Progress error`, or `Progress warning`, and `body` contains the percent when available. -- Standalone `BEL` stores `{ source: 'BEL', title: 'Terminal bell', body: null }`. - -Title candidate side effects: - -- `OSC 9` also records `titleCandidates.osc9 = message` and may override the pane header/door label. -- `OSC 777` also records `titleCandidates.osc777 = title` for diagnostics only. -- `OSC 99` also records `titleCandidates.osc99 = title` for diagnostics only. - -Persistence: - -- Persist the latest `ActivityNotification` with the Session's alert state. -- Persist only sanitized text and metadata, not raw escape sequences. -- On restore, persisted notification detail should restore TODO detail, but must not create a fresh ring or re-cock the bell by itself. - -## Text Handling - -Terminal notifications are untrusted terminal output. Treat all text as plain text. - -Input normalization: - -- Decode UTF-8 strictly enough to avoid replacement-character floods. -- Strip C0/C1 control characters after protocol parsing. -- Collapse CR/LF/TAB and other controls to spaces. -- Trim leading/trailing whitespace. -- Do not interpret ANSI, OSC, HTML, Markdown, URLs, shell paths, or emoji shortcodes as markup. - -Protocol limits: - -- OSC 9;4 progress carries only a numeric state and optional numeric percent. There is no user-facing text payload. -- OSC 99 defines a payload chunk limit of 2048 bytes before base64 or 4096 bytes after base64. It permits chunking title/body multiple times, while allowing terminals to impose sensible denial-of-service limits. -- OSC 9 and OSC 777 do not define formal text length limits in the referenced terminal docs. - -MouseTerm limits: - -- Store at most 256 Unicode grapheme clusters for `title`. -- Store at most 4096 grapheme clusters for `body`. -- Parser memory for incomplete OSC 99 chunks is capped per Session. Drop the oldest incomplete chunks when the cap is exceeded. -- Expire incomplete OSC 99 chunks after 60 seconds if no `d=1` completion arrives. - -Expected UI copy length: - -- Titles are expected to be one short line, usually under 80 characters. -- Bodies are expected to be a few short lines at most. In MouseTerm chrome, show a compact preview and make the full stored body available in a popover/dialog. - -## Alert and TODO Integration - -Protocol notification receipt creates a **protocol ring**. This is independent of the opt-in activity monitor. - -Protocol progress receipt creates an internal **protocol progress cycle** by setting `protocolStatus = OSC_NOTIF_BUSY`. This is also independent of the opt-in activity monitor. - -When a complete displayable protocol notification arrives: - -1. Normalize and sanitize the payload. -2. Store it as the Session's latest `notification`. -3. Set `todo = true`. -4. Force the Session's public `status` to `ALERT_RINGING`. -5. Notify activity subscribers immediately. - -Important rules: - -- Protocol notifications ring even when the Session's activity monitor is disabled. -- Protocol notifications do not ring when the Session has attention. -- Protocol notifications do not enable the activity monitor. After dismissal, a Session whose alert toggle was disabled returns to `ALERT_DISABLED`. -- Protocol notifications do not disable future activity-monitor alerts. -- A protocol ring and an activity-monitor ring can coexist. Dismissing/attending clears the protocol ring first, then public `status` falls back to the monitor's current state. If the monitor is also ringing, public `status` remains `ALERT_RINGING`; if no monitor exists, it returns to `ALERT_DISABLED`. -- `OSC_NOTIF_BUSY` does not participate in visual activity timers. Silence does not promote it to `MIGHT_NEED_ATTENTION` or `ALERT_RINGING`. -- More PTY output does not clear a protocol notification ring without user action. -- User attention, bell dismissal, `t` TODO marking, or TODO clearing follows `docs/specs/alert.md`, with the addition that protocol notification detail remains attached while `todo === true`. -- Clearing TODO clears `notification` unless the user explicitly chooses a future "keep details" action. - -Implementation shape inside `AlertManager`: - -- Add a protocol-ring flag or source field independent of `ActivityMonitor`. -- Track pending `OSC 9;4` progress internally in `AlertManager`, not in public `ActivityState`. -- `getState(id).status` returns `ALERT_RINGING` while protocol ring is active. -- `getState(id).status` returns `OSC_NOTIF_BUSY` while internal protocol progress is active and no stronger state is present. -- Dismiss/attend clears protocol ring. If no `ActivityMonitor` exists, status becomes `ALERT_DISABLED`; otherwise status falls back to the monitor's current status. -- Completing or erroring a protocol progress cycle creates an `ActivityNotification` and promotes it into a protocol ring only if the Session lacks attention. -- Add methods such as `notifyFromProtocol(id, notification)` and `updateProtocolProgress(id, state, percent)` and expose them through `PlatformAdapter` / VS Code messages. - -## UI - -The TODO pill stays compact and stable: - -- The visible pill remains `TODO`. -- It does not resize to arbitrary notification text. -- It may show a small dot treatment when protocol detail is present, as long as the pill remains fixed-width enough for narrow headers. - -Protocol detail appears in a preview surface anchored below the TODO pill or alert bell: - -- Show on TODO hover/focus. -- Show when the selected Pane has a TODO with notification detail and there is enough space. -- Show above a Door on hover/focus without changing Door click behavior. -- Keep click/`Enter` on a Door as reattach-and-attend; do not add Door-only menus. - -Preview content: - -- Primary line: title if present, otherwise the first body excerpt. -- Body: clamp to 3 lines in a hover preview. -- For generated `OSC 9;4` notifications, title/body already contain the progress summary; no separate progress object is rendered. -- Footer metadata: protocol source (`OSC 9`, `OSC 9;4`, `OSC 99`, `OSC 777`, `BEL`). - -A full detail dialog/popover may be opened from the preview or existing alert context menu: - -- Text wraps and can scroll. -- No raw escape sequence is shown by default. -- Focus traps and `Escape` behavior follow `docs/specs/alert.md`. - -Recommended decision: do not replace TODO text with notification text. The header and Door need fixed, scannable indicators across many Sessions. Replacing `TODO` with unbounded remote-controlled text creates overflow, localization, spoofing, and attention-noise problems. A hover/selected expansion gives the notification context without destabilizing the layout. - -## Parsing Location - -Parse notification OSCs at the platform PTY data boundary, not only in an xterm.js parser hook: - -- VS Code owns the authoritative `AlertManager` in the extension host. -- Standalone and fake adapters own `AlertManager` in the frontend adapter. -- Parsing at the platform boundary lets the owner update alert/TODO state before forwarding output to xterm. - -The parser should also classify whether a PTY data chunk has visible output after removing notification/progress OSCs: - -- If the chunk contains only notification/progress OSCs, do not feed it to activity-monitor `onData()` as generic meaningful output. -- If the chunk contains visible output plus notification/progress OSCs, the visible output still counts as activity. -- Replay/restore output must not re-fire protocol notifications or progress completion. Saved scrollback may contain raw OSCs, but replay filtering must suppress protocol side effects. - -## Security and Abuse - -Any remote process can emit these sequences over SSH. The feature is useful because it works over SSH, but the UI must be robust against hostile text. - -Requirements: - -- Sanitize all text before storing or rendering. -- Cap stored text and incomplete parser state. -- Never execute commands, open URLs, copy to clipboard, read files, or focus outside MouseTerm from these sequences. -- Do not render custom icons or buttons in this phase. -- Do not let notification text alter accessible labels beyond plain-text names. -- Do not allow repeated notifications to allocate unbounded history. Store only the latest detail, not an infinite list. - -## Scenarios - -### OSC 9 rings with alerts disabled - -- Session starts with `status = ALERT_DISABLED`, `todo = false`. -- PTY emits `OSC 9 ; Build finished ST`. -- MouseTerm stores body `Build finished`, sets `todo = true`, and reports `ALERT_RINGING`. -- User clicks into the Pane. -- Ring clears. Because the activity monitor was disabled, status returns to `ALERT_DISABLED`; TODO remains until explicitly cleared or passthrough Enter is sent. - -### OSC 777 preserves title and body - -- PTY emits `OSC 777 ; notify ; Tests ; 341 passed ST`. -- Preview primary line is `Tests`. -- Preview body is `341 passed`. -- The TODO pill remains `TODO`. - -### OSC 99 chunked title/body - -- PTY emits `OSC 99 ; i=build-1:d=0 ; Build complete ST`. -- No ring yet. -- PTY emits `OSC 99 ; i=build-1:p=body:d=1 ; All tests passed ST`. -- MouseTerm combines title and body, then rings once. - -### OSC 9 progress cocks the bell - -- PTY emits `OSC 9 ; 4 ; 1 ; 50 ST`. -- MouseTerm stores progress `normal, 50%`. -- Public `status` becomes `OSC_NOTIF_BUSY`; the bell looks like `BUSY` without creating a TODO. -- PTY emits `OSC 9 ; 4 ; 0 ST` while the Session lacks attention. -- MouseTerm rings, sets `todo = true`, and the TODO preview says progress completed. - -### OSC 9 progress error rings immediately - -- PTY emits `OSC 9 ; 4 ; 2 ; 75 ST` while the Session lacks attention. -- MouseTerm stores progress `error, 75%`. -- MouseTerm rings immediately and attaches error progress detail to the TODO. - -### OSC notification while typing does not ring - -- User is typing into a Session in passthrough mode, so the Session has attention. -- PTY emits `OSC 9 ; Build finished ST`. -- MouseTerm does not ring and does not create a TODO because the user is already attending that Session. - -### Restore does not replay old notifications - -- A Session receives an OSC notification and saves state with TODO detail. -- The app reloads and replays buffered output containing the original OSC. -- The TODO detail is restored from persisted state, but no fresh ring is emitted from replay. - -## Verification Checklist - -- `OSC 9;message` rings and stores `message`. -- `OSC 9;4;1;50` sets `OSC_NOTIF_BUSY` and stores `normal, 50%` internally. -- `OSC 9;4;3` sets `OSC_NOTIF_BUSY` and stores indeterminate progress internally. -- `OSC 9;4;4;25` sets `OSC_NOTIF_BUSY` and stores warning progress internally. -- `OSC 9;4;2` rings immediately with indeterminate error detail. -- `OSC 9;4;0` rings as completion only if there was an active progress cycle. -- `OSC 9;4;1;100` rings immediately as an explicit completion report. -- Standalone `BEL` rings and stores generated terminal-bell detail. -- `OSC 777;notify;title;body` rings and stores title/body. -- Unsupported `OSC 777` subcommands are ignored. -- OSC 99 `d=0` chunks do not ring before completion. -- OSC 99 `d=1` completion rings once with combined title/body. -- OSC 99 `p=?` is answered and does not ring; `p=close`, `p=alive`, `p=icon`, and `p=buttons` do not ring by themselves. -- Extra standalone `BEL` in the same parse batch as a richer OSC event does not replace the richer notification detail. -- Protocol notifications ring with alert disabled. -- Protocol notifications do not ring when the Session has attention. -- Dismissal returns an alert-disabled Session to `ALERT_DISABLED`. -- Dismissal returns an alert-enabled Session to its monitor-backed state. -- TODO pill text remains stable under very long notification text. -- Hover/focus preview wraps long text and does not overflow narrow headers or Doors. -- Replay/restore does not re-fire notification side effects. - -## References - -- iTerm2 proprietary escape codes: `OSC 9` notification, `OSC 9;4` progress, and `CSI > q` response shape. - https://iterm2.com/documentation-escape-codes.html -- kitty desktop notifications: `OSC 99` format, fields, chunking, payload limits, and plain-text rules. - https://sw.kovidgoyal.net/kitty/desktop-notifications/ -- WezTerm escape sequence table and notification handling: `OSC 9` and `OSC 777;notify;title;body`. - https://wezterm.org/escape-sequences.html - https://wezterm.org/config/lua/config/notification_handling.html -- foot control sequences: `OSC 9`, `OSC 99`, and `OSC 777` notification forms. - https://manpages.ubuntu.com/manpages/resolute/man7/foot-ctlseqs.7.html diff --git a/docs/specs/terminal-state.md b/docs/specs/terminal-state.md index 2023dfb..c6b6a94 100644 --- a/docs/specs/terminal-state.md +++ b/docs/specs/terminal-state.md @@ -1,6 +1,6 @@ # Terminal CWD and Command State -> See `docs/specs/ontology.md` for Session vocabulary. This spec defines the per-Session terminal semantic state that layout and grouping consume. Alert/TODO behavior remains in `docs/specs/alert.md`; notification OSCs remain in `docs/specs/iTerm2.md`. +> See `docs/specs/ontology.md` for Session vocabulary. This spec defines the per-Session terminal semantic state that layout and grouping consume. Alert/TODO behavior and notification OSCs (OSC 9 / 9;4 / 99 / 777 / BEL) live in `docs/specs/alert.md`. The OSC sequence registry and parsing-location rules live in `docs/specs/OSC.md`. ## Goal diff --git a/docs/specs/transport.md b/docs/specs/transport.md new file mode 100644 index 0000000..960014c --- /dev/null +++ b/docs/specs/transport.md @@ -0,0 +1,159 @@ +# Transport and PTY Protocol Spec + +> Adapter-agnostic protocol shared by all `PlatformAdapter` implementations — the VS Code extension (`docs/specs/vscode.md`), the standalone Tauri sidecar, and the `fake-adapter.ts` used for tests and the website playground. Covers PTY lifecycle, buffering, the webview ↔ platform message protocol, persisted-session types, and the invariants every adapter must honor. See `docs/specs/ontology.md` for the Process / Link state vocabulary, `docs/specs/alert.md` for `AlertManager` semantics, and `docs/specs/terminal-state.md` for the semantic events delivered over this transport. + +## Adapter model + +Each platform adapter wraps a PTY-spawning runtime and a transport channel between webview and host process. The webview is a thin view layer; PTYs and `AlertManager` live on the platform side. The frontend `lib/src/lib/platform/` module exposes a `PlatformAdapter` interface that all adapters implement. + +| Adapter | Host runtime | Transport | +|---|---|---| +| VS Code extension | extension host (Node.js) | `vscode.Webview.postMessage` ↔ `acquireVsCodeApi().postMessage` | +| Standalone (Tauri) | sidecar process | Tauri command/event bridge | +| Fake (tests, playground) | in-process | direct function calls / event emitter | + +## PTY lifecycle + +PTYs are managed by the platform host, not by the webview. The webview is a view layer that **resumes** over live PTYs (host-preserved) or **restores** from a Snapshot (cold start). See `docs/specs/ontology.md` for the Process / Link states. + +``` +Platform host (always running while the adapter is active) +├── pty-manager (forks pty-host child process) +│ ├── pty-1 (Process: Live) +│ ├── pty-2 (Process: Live) +│ └── pty-3 (Process: Exited) +│ +├── Webview (e.g. VS Code WebviewView, standalone window) +│ └── message-router: owns pty-1, pty-2 +│ +└── Optional secondary webview (e.g. VS Code editor-tab WebviewPanel) + └── message-router: owns pty-3 +``` + +This means: + +- Hiding a webview does not kill its PTYs. +- The webview becoming visible again resumes over still-owned PTYs and reapplies the saved visible-pane layout when the saved session covers the live PTY set and the layout's visible panels match. +- A PTY process that exits naturally can remain mounted as an exited pane; frontend semantic state such as CWD, title candidates, and last command is retained until the Session is actually disposed. +- Each message router tracks which PTYs it owns; PTYs cannot be stolen by another router. +- Explicitly killed PTYs are **tombstoned** in the host (`Process: Tombstoned`) so a late child-process `exit` event cannot recreate their buffer and make them resumable. +- Multiple host instances (e.g., multiple VS Code windows) each get their own pty-host child process. + +### PTY buffering + +`pty-manager` maintains two buffer types per PTY: + +- **replayChunks**: cleared on first consume, used for resume (webview hidden then shown). +- **scrollbackChunks**: never cleared, used for repeat resumes and session save. + +Both are capped at 1M chars per PTY. When the cap is reached, oldest chunks are trimmed. + +### Reconnection protocol + +``` +1. Webview becomes visible (or panel deserializes). +2. Webview sends: { type: 'mouseterm:init' }. +3. Host responds with: + - { type: 'pty:list', ptys: [{ id, alive, exitCode }] } // all owned PTYs + - { type: 'pty:replay', id, data } // buffered output per PTY +4. Webview restores terminals from replay data, seeds any non-unnamed saved pane or door titles as user titles, and resumes the live stream. +5. If the saved session covers those live PTYs, the frontend uses the saved dockview layout when its visible panels match and reattaches saved minimized doors; minimized PTYs are registered but remain doors instead of visible panes. +``` + +For cold restore (no live PTYs), the webview falls back to saved session state: spawns new PTYs in saved CWDs using the currently selected MouseTerm shell, injects saved scrollback (with trailing newline to avoid the zsh `%` artifact), and restores dockview layout. The entry module (`reconnect.ts`) uses a 500ms timeout when waiting for the PTY list. + +## Message protocol + +All types live in `vscode-ext/src/message-types.ts` (the canonical schema; other adapters import or mirror it). Webview-side handling lives in adapter modules (e.g., `vscode-adapter.ts`, `fake-adapter.ts`); host-side handling lives in the per-adapter message router. + +**Webview → host:** + +| Message | Purpose | +|---------|---------| +| `pty:spawn` | Create new PTY (id, optional cols/rows/cwd/shell/args) | +| `pty:input` | Write data to PTY | +| `pty:resize` | Resize PTY dimensions | +| `pty:kill` | Kill PTY and release ownership | +| `pty:getCwd` | Query PTY working directory (request-response via requestId) | +| `pty:getScrollback` | Query PTY scrollback buffer (request-response via requestId) | +| `pty:getShells` | Query available shells (request-response via requestId) | +| `mouseterm:init` | Trigger resume: get PTY list + replay data | +| `mouseterm:saveState` | Frontend persisting session state | +| `mouseterm:flushSessionSaveDone` | Ack for host-triggered flush (matched by requestId) | +| `alert:toggle` | Toggle alert enabled/disabled for a PTY | +| `alert:disable` | Disable alert for a PTY | +| `alert:dismiss` | Dismiss ringing alert | +| `alert:dismissOrToggle` | Context-dependent: dismiss if ringing, else toggle | +| `alert:attend` | Mark user as attending to a PTY | +| `alert:remove` | Remove alert state entirely | +| `alert:resize` | Notify alert of terminal resize (debounce noise) | +| `alert:clearAttention` | Clear attention timer | +| `alert:toggleTodo` | Toggle TODO (false ↔ hard) | +| `alert:markTodo` | Set hard TODO | +| `alert:clearTodo` | Remove TODO | + +**Host → webview:** + +| Message | Purpose | +|---------|---------| +| `pty:data` | PTY output after supported OSC sequences have been parsed/stripped (routed only to owning router) | +| `pty:exit` | PTY process exited (with exitCode) | +| `terminal:semanticEvents` | Normalized CWD/title/prompt/command events parsed in the host from live PTY data | +| `pty:list` | List of all resumable PTYs (response to `mouseterm:init`) | +| `pty:replay` | Buffered raw output since spawn (response to `mouseterm:init`); the webview parses semantic OSCs during replay reconstruction without triggering alerts | +| `pty:cwd` | CWD query response (matched by requestId) | +| `pty:scrollback` | Scrollback query response (matched by requestId) | +| `pty:shells` | Available shells list response (matched by requestId) | +| `mouseterm:flushSessionSave` | Request webview to save state now (host shutdown trigger, matched by requestId) | +| `mouseterm:openThemeDebugger` | Command-triggered request to open the shared theme debugger dialog | +| `alert:state` | Alert state change (projected status, todo, notification, attentionDismissedRing) | + +The OSC parsing/stripping rules that produce `pty:data` and `terminal:semanticEvents` are specified in `docs/specs/OSC.md`. + +## Persisted session types + +```typescript +interface PersistedSession { + version: 3; + panes: PersistedPane[]; + doors?: PersistedDoor[]; + layout: unknown; // SerializedDockview +} + +interface PersistedPane { + id: string; + cwd: string | null; + title: string; + scrollback: string | null; + resumeCommand: string | null; + alert?: PersistedAlertState | null; +} + +interface PersistedAlertState { + status: SessionStatus; + todo: boolean; + notification?: ActivityNotification | null; +} + +interface PersistedDoor { + id: string; + title: string; + neighborId: string | null; + direction: DoorDirection; + remainingPaneIds: string[]; + layoutAtMinimize: unknown; + layoutAtMinimizeSignature: string; +} +``` + +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/migrating; this covers host state APIs that may hand back the inner serialized JSON string. + +## Universal invariants + +These rules apply to every adapter. Adapter-specific layering (deactivate ordering, save APIs, panel retention) lives in the adapter spec, e.g. `docs/specs/vscode.md`. + +- **Shell login args are shell-specific.** The shared `pty-core.js` launches POSIX shells with `-l` only for shells that accept it. `csh`/`tcsh` must be spawned without `-l` so users whose login shell is C-shell-derived can open a usable terminal in any adapter. +- **Scrollback trailing newline.** Restored scrollback must end with `\n` to avoid zsh printing a `%` artifact at the top of the terminal. +- **Replay drops terminal replies only.** While saved output is being replayed into xterm.js, terminal-generated OSC/CSI/DCS query and focus reports are dropped so they do not enter the resumed/restored shell's input buffer. The replay filter must preserve user keyboard escape sequences, including arrows, function keys, and bracketed paste. +- **PTY ownership.** Each message router tracks the PTY ids it owns. A PTY routed to one webview must not be stolen by another router; new routers attaching to a host must respect existing ownership. +- **Replay filtering does not re-fire alerts.** `pty:replay` re-injects buffered output into xterm.js but must not re-trigger `AlertManager`, activity-monitor events, or protocol notifications. diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 9886b51..c58178d 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -1,5 +1,7 @@ # MouseTerm VS Code Integration Spec +> See `docs/specs/transport.md` for the PTY lifecycle, message protocol, persisted-session types, and adapter-agnostic invariants that VS Code shares with the standalone and fake adapters. This spec covers the VS Code-specific layer: panel/view registration, persistence APIs, theme integration, CSP, build, and dream-architecture commands. + ## What's built MouseTerm has two hosting modes: a `WebviewView` in the bottom panel (alongside Terminal, Problems, Output) and `WebviewPanel` editor tabs (via `mouseterm.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:mouseterm` activation event ensures the extension activates early enough. Theme integration uses VSCode `--vscode-*` tokens plus MouseTerm semantic `--color-*` tokens, with a small resolver that materializes missing consumed VSCode colors from registry defaults. CSP is strict with nonce-gated scripts. @@ -59,15 +61,14 @@ Frontend Library (lib/src/) └── fake-adapter.ts — mock adapter for testing + website playground ``` -### Invariants +### Invariants (VS Code-specific) + +Universal PTY/transport invariants live in `docs/specs/transport.md`. The rules below are specific to running inside the VS Code extension host. -- **Save before kill.** Deactivate must save session state *before* killing PTYs. CWD and scrollback queries need live processes. See ordering in `extension.ts:deactivate()`. +- **Save before kill.** `deactivate()` must save session state *before* killing PTYs. CWD and scrollback queries need live processes. See ordering in `extension.ts:deactivate()`. - **Alert state is global.** A single `AlertManager` instance in `message-router.ts` is shared across all routers and survives router disposal. PTY data feeds into it at module level, regardless of webview visibility. -- **PTY ownership.** Each router tracks its PTYs in `ownedPtyIds`. A module-level `globalOwnedPtyIds` set prevents a resuming router from stealing PTYs owned by another webview. -- **Shell login args are shell-specific.** The shared `pty-core.js` launches POSIX shells with `-l` only for shells that accept it. `csh`/`tcsh` must be spawned without `-l` so both the standalone app and VS Code extension can open a usable terminal for users whose login shell is C shell-derived. +- **PTY ownership tracking.** Each router tracks its PTYs in `ownedPtyIds`. A module-level `globalOwnedPtyIds` set prevents a resuming router from stealing PTYs owned by another webview. - **mergeAlertStates on every save path.** Both the frontend periodic save (`onSaveState` callback) and the backend deactivate refresh (`refreshSavedSessionStateFromPtys`) must merge current alert states. Missing this causes alert state to revert on restore. -- **Scrollback trailing newline.** Restored scrollback must end with `\n` to avoid zsh printing a `%` artifact at the top of the terminal. -- **Replay drops terminal replies only.** While saved output is being replayed into xterm.js, terminal-generated OSC/CSI/DCS query and focus reports are dropped so they do not enter the resumed/restored shell's input buffer. The replay filter must preserve user keyboard escape sequences, including arrows, function keys, and bracketed paste. - **retainContextWhenHidden.** Set on both `WebviewPanel` (editor tabs) and `WebviewView` (bottom panel) so that xterm.js DOM, scrollback, and PTY subscriptions survive panel hide/show without going through a resume. - **Two save sources.** Session state is saved from two places: the frontend (debounced 500ms + 30s interval via `mouseterm:saveState`) and the backend (deactivate flushes webviews then refreshes from live PTYs). Both paths must produce consistent state. @@ -100,9 +101,9 @@ Frontend Library (lib/src/) } ``` -### PTY lifecycle (decoupled from webview) +### Webview hosting -PTYs are managed by the extension host, not by the webview. The webview is a view layer that **resumes** over live PTYs (host-preserved) or **restores** from a Snapshot (cold start). See `ontology.md` for the Process / Link states. +VS Code-specific layout of the transport model in `docs/specs/transport.md`: ``` Extension Host (always running while extension is active) @@ -118,83 +119,13 @@ Extension Host (always running while extension is active) └── message-router: owns pty-3 ``` -This means: +VS Code-specific consequences: + - Hiding the MouseTerm panel doesn't kill its PTYs. - VS Code toggling the panel visibility doesn't destroy sessions. -- When the view becomes visible again, the webview **resumes** over still-owned PTYs and reapplies the saved visible-pane layout when the saved session covers the live PTY set and the layout's visible panels match. -- A PTY process that exits naturally can remain mounted as an exited pane; frontend semantic state such as CWD, title candidates, and last command is retained until the Session is actually disposed. -- Each message router tracks which PTYs it owns; PTYs cannot be stolen by another router. -- Explicitly killed PTYs are **tombstoned** in the extension host (`Process: Tombstoned`) so a late child-process `exit` event cannot recreate their buffer and make them resumable. - Multiple VS Code windows each get their own extension host process, and therefore their own pty-host child process. -#### PTY buffering - -`pty-manager.ts` maintains two buffer types per PTY: - -- **replayChunks**: cleared on first consume, used for resume (webview hidden then shown) -- **scrollbackChunks**: never cleared, used for repeat resumes and session save - -Both are capped at 1M chars per PTY. When the cap is reached, oldest chunks are trimmed. - -#### Reconnection protocol - -``` -1. Webview becomes visible (or panel deserializes) -2. Webview sends: { type: 'mouseterm:init' } -3. Extension responds with: - - { type: 'pty:list', ptys: [{ id, alive, exitCode }] } // all owned PTYs - - { type: 'pty:replay', id, data } // buffered output per PTY -4. Webview restores terminals from replay data, seeds any non-unnamed saved pane or door titles as user titles, and resumes the live stream -5. If the saved session covers those live PTYs, the frontend uses the saved dockview layout when its visible panels match and reattaches saved minimized doors; minimized PTYs are registered but remain doors instead of visible panes -``` - -For cold restore (no live PTYs), the webview falls back to saved session state: spawns new PTYs in saved CWDs using the currently selected MouseTerm shell, injects saved scrollback (with trailing newline to avoid zsh `%` artifact), and restores dockview layout. The entry module (`reconnect.ts`) uses a 500ms timeout when waiting for the PTY list. - -### Message protocol - -All types defined in `message-types.ts`. Webview-side handling in `vscode-adapter.ts`; host-side handling in `message-router.ts`. - -**Webview -> Extension Host:** - -| Message | Purpose | -|---------|---------| -| `pty:spawn` | Create new PTY (id, optional cols/rows/cwd/shell/args) | -| `pty:input` | Write data to PTY | -| `pty:resize` | Resize PTY dimensions | -| `pty:kill` | Kill PTY and release ownership | -| `pty:getCwd` | Query PTY working directory (request-response via requestId) | -| `pty:getScrollback` | Query PTY scrollback buffer (request-response via requestId) | -| `pty:getShells` | Query available shells (request-response via requestId) | -| `mouseterm:init` | Trigger resume: get PTY list + replay data | -| `mouseterm:saveState` | Frontend persisting session state | -| `mouseterm:flushSessionSaveDone` | Ack for deactivate-triggered flush (matched by requestId) | -| `alert:toggle` | Toggle alert enabled/disabled for a PTY | -| `alert:disable` | Disable alert for a PTY | -| `alert:dismiss` | Dismiss ringing alert | -| `alert:dismissOrToggle` | Context-dependent: dismiss if ringing, else toggle | -| `alert:attend` | Mark user as attending to a PTY | -| `alert:remove` | Remove alert state entirely | -| `alert:resize` | Notify alert of terminal resize (debounce noise) | -| `alert:clearAttention` | Clear attention timer | -| `alert:toggleTodo` | Toggle TODO (false <-> hard) | -| `alert:markTodo` | Set hard TODO | -| `alert:clearTodo` | Remove TODO | - -**Extension Host -> Webview:** - -| Message | Purpose | -|---------|---------| -| `pty:data` | PTY output after supported OSC sequences have been parsed/stripped (routed only to owning router) | -| `pty:exit` | PTY process exited (with exitCode) | -| `terminal:semanticEvents` | Normalized CWD/title/prompt/command events parsed in the extension host from live PTY data | -| `pty:list` | List of all resumable PTYs (response to `mouseterm:init`) | -| `pty:replay` | Buffered raw output since spawn (response to `mouseterm:init`); the webview parses semantic OSCs during replay reconstruction without triggering alerts | -| `pty:cwd` | CWD query response (matched by requestId) | -| `pty:scrollback` | Scrollback query response (matched by requestId) | -| `pty:shells` | Available shells list response (matched by requestId) | -| `mouseterm:flushSessionSave` | Request webview to save state now (deactivate trigger, matched by requestId) | -| `mouseterm:openThemeDebugger` | Command-triggered request to open the shared theme debugger dialog | -| `alert:state` | Alert state change (projected status, todo, notification, attentionDismissedRing) | +PTY lifecycle, buffering, the reconnection sequence, and the full message protocol live in `docs/specs/transport.md`. ### Serialization and restore @@ -204,52 +135,16 @@ All types defined in `message-types.ts`. Webview-side handling in `vscode-adapte activationEvents: ["onWebviewPanel:mouseterm"] ``` -**Session structure** (from `session-types.ts`): - -```typescript -interface PersistedSession { - version: 3; - panes: PersistedPane[]; - doors?: PersistedDoor[]; - layout: unknown; // SerializedDockview -} - -interface PersistedPane { - id: string; - cwd: string | null; - title: string; - scrollback: string | null; - resumeCommand: string | null; - alert?: PersistedAlertState | null; -} - -interface PersistedAlertState { - status: SessionStatus; - todo: boolean; - notification?: ActivityNotification | null; -} - -interface PersistedDoor { - id: string; - title: string; - neighborId: string | null; - direction: DoorDirection; - remainingPaneIds: string[]; - layoutAtMinimize: unknown; - layoutAtMinimizeSignature: string; -} -``` - -**Persistence flow:** +The persisted-session shape (`PersistedSession` / `PersistedPane` / `PersistedAlertState` / `PersistedDoor`) lives in `docs/specs/transport.md`; it is shared with the standalone and fake adapters. -1. Frontend saves state periodically (debounced 500ms + 30s interval) via `mouseterm:saveState` message -2. Router's `onSaveState` callback merges in current alert states via `mergeAlertStates()` -3. WebviewView writes to `workspaceState`; WebviewPanels persist via `vscode.setState()` (per-panel, no clobbering) -4. On deactivate: flush all sessions from webviews (1s timeout), then refresh from live PTYs (queries CWD + scrollback while processes are still alive) -5. Graceful shutdown: save state -> SIGTERM -> 2s wait -> force kill -6. On activate: saved state loaded and passed to routers for cold-start restore +**VS Code persistence flow:** -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/migrating, which covers VS Code state APIs that may hand back the inner serialized JSON string. +1. Frontend saves state periodically (debounced 500ms + 30s interval) via `mouseterm:saveState` message. +2. Router's `onSaveState` callback merges in current alert states via `mergeAlertStates()`. +3. WebviewView writes to `workspaceState`; WebviewPanels persist via `vscode.setState()` (per-panel, no clobbering). +4. On deactivate: flush all sessions from webviews (1s timeout), then refresh from live PTYs (queries CWD + scrollback while processes are still alive). +5. Graceful shutdown: save state → SIGTERM → 2s wait → force kill. +6. On activate: saved state loaded and passed to routers for cold-start restore via `readPersistedSession()` (defined in `docs/specs/transport.md`), which tolerates both parsed objects and JSON-stringified blobs returned by VS Code state APIs. ### Theme integration From 987c00ab5f37ce9821442757f40bcaa95ce05906 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Sat, 9 May 2026 09:09:42 -0700 Subject: [PATCH 36/50] Update AGENTS.md spec list for the docs/specs reorganization Replaces the iTerm2.md entry with OSC.md (registry/identity) and transport.md (adapter-agnostic protocol). Expands alert.md to mention the folded-in notification machinery, trims vscode.md to its VS Code-specific layer with a pointer to transport.md, and drops stale "soft/hard" TODO wording. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- AGENTS.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3390138..6afe2e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,10 +32,11 @@ The primary job of a spec is to be an accurate reference for the current state o - **`docs/specs/ontology.md`** — Canonical vocabulary for Session states, layers (Process / Registry / View / Link / Activity / Snapshot), transition verbs, and the Liskov contract on Registry APIs. Read this first. Other specs defer to it when naming a state or a verb. - **`docs/specs/layout.md`** — Tiling layout, pane/door containers, dockview configuration, modes (passthrough/command), keyboard shortcuts, selection overlay, spatial navigation, minimize/reattach, inline rename, session lifecycle, session persistence, and theming. Read this when touching: `Wall.tsx`, `Baseboard.tsx`, `Door.tsx`, `TerminalPane.tsx`, `spatial-nav.ts`, `layout-snapshot.ts`, `terminal-registry.ts`, `session-save.ts`, `session-restore.ts`, `reconnect.ts`, `index.css`, `theme.css`, or any keyboard/navigation/mode behavior. -- **`docs/specs/alert.md`** — Activity monitoring state machine, alert trigger/clearing rules, attention model, TODO lifecycle (soft/hard), bell button visual states and interaction, door alert indicators, and hardening (a11y, motion, i18n, overflow). Read this when touching: `activity-monitor.ts`, `alert-manager.ts`, the alert bell or TODO pill in `Wall.tsx` (TerminalPaneHeader), alert indicators in `Door.tsx`, or the `a`/`t` keyboard shortcuts. Layout.md defers to this spec for all alert/TODO behavior. -- **`docs/specs/iTerm2.md`** — iTerm2-compatible identity, terminal notification protocols (`OSC 9`, `OSC 99`, `OSC 777`), and `OSC 9;4` progress arming, including how protocol signals force or cock the alert/TODO system. Read this when touching PTY environment identity, terminal device/version reports, OSC parsing, `AlertManager` protocol notification/progress paths, `ActivityState` metadata, or TODO notification preview UI. -- **`docs/specs/terminal-state.md`** — Terminal semantic state for CWD, shell prompt/editing/running/finished lifecycle, command runs, terminal title fallback, normalized OSC events (`OSC 7`, `OSC 9;9`, `OSC 133`, `OSC 633`, `OSC 1337`, `OSC 0/2`), header derivation, and grouping keys. Read this when touching `terminal-state.ts`, `terminal-state-store.ts`, semantic event parsing in `terminal-protocol.ts`, adapter semantic event forwarding, or derived pane/door labels. -- **`docs/specs/vscode.md`** — VS Code extension architecture: hosting modes (WebviewView + WebviewPanel), PTY lifecycle and buffering, message protocol between webview and extension host, session persistence flow, reconnection protocol, theme integration, CSP, build pipeline, and invariants (save-before-kill ordering, PTY ownership, alert state merging). Read this when touching: `extension.ts`, `webview-view-provider.ts`, `message-router.ts`, `message-types.ts`, `pty-manager.ts`, `pty-host.js`, `session-state.ts`, `webview-html.ts`, `vscode-adapter.ts`, or `pty-core.js`. +- **`docs/specs/alert.md`** — Activity monitoring state machine, alert trigger/clearing rules, attention model, TODO lifecycle, bell button visual states and interaction, door alert indicators, hardening (a11y, motion, i18n, overflow), notification protocols (`OSC 9` / `OSC 9;4` / `OSC 99` / `OSC 777` / `BEL`), the `ActivityNotification` model, notification text handling and security, and the notification preview/detail UI. Read this when touching: `activity-monitor.ts`, `alert-manager.ts`, `AlertManager` notification/progress paths, the alert bell or TODO pill in `Wall.tsx` (TerminalPaneHeader), alert indicators in `Door.tsx`, the `a`/`t` keyboard shortcuts, or TODO notification preview UI. Layout.md defers to this spec for all alert/TODO behavior. +- **`docs/specs/terminal-state.md`** — Terminal semantic state for CWD, shell prompt/editing/running/finished lifecycle, command runs, terminal title fallback, normalized semantic OSC events (`OSC 7`, `OSC 9;9`, `OSC 133`, `OSC 633`, `OSC 1337`, `OSC 0/2`), title-candidate diagnostics, header derivation, and grouping keys. Read this when touching `terminal-state.ts`, `terminal-state-store.ts`, semantic event parsing in `terminal-protocol.ts`, adapter semantic event forwarding, or derived pane/door labels. +- **`docs/specs/OSC.md`** — Registry of every supported OSC sequence with pointers to the spec defining its behavior (alert.md or terminal-state.md), the canonical parsing-location and `pty:data` strip semantics, iTerm2 self-identification (env vars, `CSI > q` response, fail-inertly rule), and known-unimplemented iTerm2 sequences. Read this when touching: OSC parsing at the PTY data boundary, the iTerm2 identity env vars (`TERM_PROGRAM`, `LC_TERMINAL`), or adding support for a new OSC sequence. +- **`docs/specs/transport.md`** — Adapter-agnostic protocol shared across VS Code, standalone, and fake adapters: PTY lifecycle (decoupled from webview), `replayChunks`/`scrollbackChunks` buffering, reconnection sequence (`mouseterm:init` → `pty:list` + `pty:replay`), the full webview ↔ host message protocol, persisted-session types, and universal invariants (shell login args, scrollback trailing newline, replay drop-replies-only). Read this when touching: `pty-manager.ts`, `pty-host.js`, `pty-core.js`, `message-router.ts`, `message-types.ts`, `vscode-adapter.ts`, `fake-adapter.ts`, `reconnect.ts`, `session-save.ts`, `session-restore.ts`, `session-types.ts`, or any code crossing the webview/host boundary. +- **`docs/specs/vscode.md`** — VS Code-specific layer: hosting modes (WebviewView + WebviewPanel), extension manifest, VS Code persistence flow (`workspaceState`, `vscode.setState`, `WebviewPanelSerializer`, deactivate ordering, `mergeAlertStates` rule, `retainContextWhenHidden`), theme integration (`--vscode-*` → `--color-*` with the runtime resolver), CSP, build pipeline, and dream-architecture commands. The transport protocol it speaks (PTY lifecycle, message protocol, persisted-session types) lives in `transport.md`. Read this when touching: `extension.ts`, `webview-view-provider.ts`, `session-state.ts`, `webview-html.ts`, the theme resolver/observer in `terminal-theme.ts`, or VS Code commands and context keys. - **`docs/specs/tutorial.md`** — Playground tutorial on the website: 3-pane layout, interactive `tut` TUI runner with three sections (keyboard navigation, alerts/TODOs, copy/paste), per-item detection wired to `WallEvent` / activity store / mouse-selection store, single-key `mouseterm-tut-v3` localStorage scheme, theme picker, and FakePtyAdapter extensions (`sendOutput`, `pumpActivity`, `setInputHandler`). Read this when touching: `website/src/pages/Playground.tsx`, `website/src/lib/tut-runner.ts`, `website/src/lib/tut-detector.ts`, `website/src/lib/tutorial-state.ts`, `website/src/lib/tut-items.ts`, `website/src/lib/tutorial-shell.ts`, `lib/src/components/ThemePicker.tsx`, `lib/src/lib/themes/`, `lib/src/lib/platform/fake-scenarios.ts` (tutorial scenarios), the `WallEvent` union, or the `onApiReady`/`onEvent`/`initialPaneIds` props on Wall. - **`docs/specs/theme.md`** — Theme system: two-layer CSS variable strategy, theme data model, conversion pipeline, bundled themes, localStorage store, shared ThemePicker component, standalone AppBar picker, runtime OpenVSX installer. Read this when touching: `lib/src/lib/themes/`, `lib/src/components/ThemePicker.tsx`, `lib/src/theme.css`, `lib/scripts/bundle-themes.mjs`, `standalone/src/AppBar.tsx` (theme picker), `standalone/src/main.tsx` (theme restore), or `website/src/components/SiteHeader.tsx` (themeAware mode). - **`docs/specs/mouse-and-clipboard.md`** — Terminal-owned text selection, copy (Raw / Rewrapped), bracketed paste, smart URL/path extension, mouse-reporting override UI (icon + banner), and the state matrix for which layer owns mouse events. Read this when touching: `lib/src/lib/mouse-selection.ts`, `lib/src/lib/mouse-mode-observer.ts`, `lib/src/lib/clipboard.ts`, `lib/src/lib/rewrap.ts`, `lib/src/lib/selection-text.ts`, `lib/src/lib/smart-token.ts`, `lib/src/components/SelectionOverlay.tsx`, `lib/src/components/SelectionPopup.tsx`, the mouse icon / override banner / Cmd+C-V handling in `lib/src/components/Wall.tsx`, or the parser hooks + mouse listeners in `lib/src/lib/terminal-registry.ts`. From b5896bb93712fb817b0a98c2abdd4148b77df7e0 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Sat, 9 May 2026 09:14:55 -0700 Subject: [PATCH 37/50] Drop already-resolved #3 and #4 from SPEC-CONFLICTS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs/specs reorganization in 096a3d5 closed: - #3 (OSC 9 title-override timing stated three ways) — iTerm2.md is gone; terminal-state.md is the single canonical source. - #4 (pty:data strip semantics vs "the same streaming parser") — OSC.md is the single source for parsing-location rules. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- SPEC-CONFLICTS.md | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/SPEC-CONFLICTS.md b/SPEC-CONFLICTS.md index 8f910ec..afd3b73 100644 --- a/SPEC-CONFLICTS.md +++ b/SPEC-CONFLICTS.md @@ -27,24 +27,6 @@ But the bullets in `terminal-state.md` contradict that: Read literally, an app-sent OSC 9 on an idle pane would be ignored (idle rule wins), and an override during `lastCommand` would be ignored (finished rule wins). Both contradict `layout.md`'s priority list. -### 3. OSC 9 title-override timing is stated three different ways - -- `iTerm2.md` prose: "Legacy `OSC 9` message text also participates in pane header/door title derivation as an app-sent title override" — unconditional. -- `iTerm2.md` table: "may override the pane header/door label" — conditional, condition unstated. -- `layout.md` and `terminal-state.md`: only OSC 9 / OSC 0/2 "emitted **after the current command started**" are overrides; "Older shell titles remain fallback-only." - -These three need to agree, and the timing condition itself is underdefined: what about an OSC 9 emitted while running, after the command has now finished (i.e., `lastCommand` is set)? `layout.md`'s priority list says the override still beats the finished-command title, but `terminal-state.md`'s reducer/rules don't say when an override expires. Does it survive the next `commandStart`? Next prompt? Forever? - -### 4. `pty:data` strip semantics conflict with the "streaming parser" description - -`vscode.md` vs `iTerm2.md`. - -`vscode.md` (changed) now says `pty:data` is "PTY output **after supported OSC sequences have been parsed/stripped**" and adds a separate `terminal:semanticEvents` message for the parsed events. - -But `iTerm2.md` (changed) describes "**The same streaming parser**" recognizing OSC 7/9;9/633/1337/133/0/2 — this parser, in the surrounding spec context, is the webview parser. If the extension host has already stripped those sequences from `pty:data`, the webview's "same streaming parser" never sees them in live data, only in `pty:replay`. - -Either the parser exists in two places (under-specified), or one of these two specs is wrong about who parses what. As written, "the same streaming parser also recognizes" is misleading. - ### 5. Dead/unreferenced enum values - `CommandRun.source` (line 67–73) declares `"foreground_process"` and `"title"`, but no rule, table row, or fallback in the document produces them. @@ -88,4 +70,4 @@ If `<unnamed>` is rejected at write time per the rename rules (`layout.md` line --- -Severity: #1, #2, and #3 are blockers — they change actual behavior depending on which spec a reader trusts. #4 and #5 are spec-hygiene issues that will silently rot. The rest are minor. +Severity: #1 and #2 are blockers — they change actual behavior depending on which spec a reader trusts. #5 is a spec-hygiene issue that will silently rot. The rest are minor. (#3 and #4 were resolved by the docs/specs reorganization in `096a3d5`.) From da25a4cb7bc3e8737848b61d073af949b6ece87b Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Sat, 9 May 2026 09:26:33 -0700 Subject: [PATCH 38/50] Show <idle> for finished panes and drop DerivedHeader.status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves SPEC-CONFLICTS #1 (ShellActivity 5 kinds vs. DerivedHeader.status 4 values). DerivedHeader.status and DerivedHeader.exitCode were dead in production: only consumers were a Storybook debug badge and the headerStatus() helper that produced them. The real header (TerminalPaneHeader.tsx, Baseboard.tsx) reads only .primary and .secondary. Drop both fields. While reshaping the header rules, also collapse "freshly finished" panes onto <idle>: - A finished command no longer keeps lastCommand.displayCommand in the header until the next prompt. The header returns to <idle> immediately. Exit code, just-finished context, and TODO/notification detail still flow through the alert/TODO machinery, which is the surface designed for that. - This simplifies headerPrimary, isAppTitleFresh, activeTerminalTitle, and cwdForHeader: they only branch on currentCommand, not on finished + lastCommand. - Disambiguator rules collapse from "running and finished use cwdAtStart, idle uses pane.cwd" to "running uses cwdAtStart, everything else uses pane.cwd." Status grouping keeps its 4 buckets (unknown | idle | running | finished) via an inline statusBucket() helper; the prompt/editing → idle projection is now documented explicitly in terminal-state.md. Storybook debug badge updated to read pane.activity.kind / pane.activity.exitCode directly. All 402 lib tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- SPEC-CONFLICTS.md | 12 +------ docs/specs/terminal-state.md | 46 +++++++++++++++++---------- lib/src/lib/terminal-protocol.test.ts | 2 -- lib/src/lib/terminal-state.test.ts | 17 ++-------- lib/src/lib/terminal-state.ts | 46 +++++++++++---------------- lib/src/stories/ShellCwd.stories.tsx | 14 ++++++-- 6 files changed, 62 insertions(+), 75 deletions(-) diff --git a/SPEC-CONFLICTS.md b/SPEC-CONFLICTS.md index afd3b73..16f3a94 100644 --- a/SPEC-CONFLICTS.md +++ b/SPEC-CONFLICTS.md @@ -4,16 +4,6 @@ Audit of the changed regions in `iTerm2.md`, `layout.md`, `terminal-state.md` (n ## Substantive conflicts -### 1. `ShellActivity` (5 kinds) ≠ `DerivedHeader.status` / status grouping (4 values) - -`terminal-state.md`: - -- `ShellActivity.kind`: `unknown | prompt | editing | running | finished` (line 48–53). -- `DerivedHeader.status`: `"unknown" | "idle" | "running" | "finished"` (line 189). -- Status grouping (line 225): `unknown | idle | running | finished`. - -`prompt` and `editing` collapse to `idle` somewhere, but the spec never states that mapping. Two different vocabularies for the same axis. - ### 2. Header-derivation rules don't form a clean priority chain `terminal-state.md` line 194–204 vs `layout.md`. @@ -70,4 +60,4 @@ If `<unnamed>` is rejected at write time per the rename rules (`layout.md` line --- -Severity: #1 and #2 are blockers — they change actual behavior depending on which spec a reader trusts. #5 is a spec-hygiene issue that will silently rot. The rest are minor. (#3 and #4 were resolved by the docs/specs reorganization in `096a3d5`.) +Severity: #2 is a blocker — it changes actual behavior depending on which spec a reader trusts. #5 is a spec-hygiene issue that will silently rot. The rest are minor. (#1, #3, #4 are resolved.) diff --git a/docs/specs/terminal-state.md b/docs/specs/terminal-state.md index c6b6a94..9cb175a 100644 --- a/docs/specs/terminal-state.md +++ b/docs/specs/terminal-state.md @@ -13,7 +13,7 @@ MouseTerm models terminal panes by: - command directory at start time - app-sent terminal or notification title as an override label -Session CWD and command execution state are separate. `cwd` means "the shell/session reported this directory"; it is not necessarily the internal CWD of a foreground program. A command snapshots `cwdAtStart` when it starts, and that snapshot is used for grouping and header disambiguation while the command is running or freshly finished. +Session CWD and command execution state are separate. `cwd` means "the shell/session reported this directory"; it is not necessarily the internal CWD of a foreground program. A command snapshots `cwdAtStart` when it starts, and that snapshot is used for grouping and header disambiguation while the command is running. ## Core Model @@ -186,22 +186,28 @@ Asynchronous process CWD query results are applied through PTY-id resolution, so type DerivedHeader = { primary: string; secondary?: string; - status: "unknown" | "idle" | "running" | "finished"; - exitCode?: number; }; ``` -Rules: +The header carries only the primary label and an optional secondary disambiguator. Activity state lives on `pane.activity` directly; consumers that need it (status grouping, exit-code badges) read it from there. + +Header priority — first match wins: + +1. User-pinned title. +2. While a command is running: + - App-sent title override emitted after the current command started — legacy `OSC 9` message text or `OSC 0`/`OSC 2` terminal title. + - `currentCommand.displayCommand`. +3. Otherwise (no running command — `prompt`, `editing`, `finished`, `unknown`): `<idle>`. + +Rich notification titles from `OSC 99` and `OSC 777` are stored in `titleCandidates` for the diagnostic popup but never become header/door labels. Older shell titles (terminal titles emitted before the current command started, or with no command running) remain fallback-only and do not replace `<idle>`. + +A freshly finished command shows `<idle>`. The just-finished command's exit code, output, and TODO notification are surfaced via the alert/TODO machinery (`docs/specs/alert.md`); the header itself returns to a peaceful idle state. `lastCommand` is still tracked for grouping and for callers that want exit-code metadata, but it does not appear in the header. -- A user-pinned title is primary. -- An app-sent title override is primary after user-pinned titles. This includes legacy `OSC 9` message text and OSC 0/2 terminal titles sent after the current command started. Rich notification titles from `OSC 99` and `OSC 777` are stored in `titleCandidates` for diagnostics and stay in TODO notification UI; they do not become header/door labels. -- A running command uses `currentCommand.displayCommand` when there is no app-sent title override. -- A freshly finished command uses `lastCommand.displayCommand` until the next prompt signal. -- Idle terminals use `<idle>` unless a user-pinned title exists. -- Unknown active commands may use terminal title or shell fallback. -- Duplicate primary labels get a shortest unique directory label. -- Running and finished commands disambiguate with `cwdAtStart`. -- Idle terminals disambiguate with `pane.cwd`. +Disambiguation: + +- Duplicate primary labels get a shortest unique directory secondary label. +- Running commands disambiguate with `currentCommand.cwdAtStart`. +- Panes without a running command disambiguate with `pane.cwd`. ## Grouping @@ -219,11 +225,17 @@ Command grouping uses: pane.currentCommand?.displayCommand ?? "<idle>" ``` -Status grouping uses: +Status grouping projects `ShellActivity.kind` (5 values) onto 4 buckets: -```ts -unknown | idle | running | finished -``` +| `pane.activity.kind` | Status bucket | +|---|---| +| `unknown` | `unknown` | +| `prompt` | `idle` | +| `editing` | `idle` | +| `running` | `running` | +| `finished` | `finished` | + +`prompt` and `editing` collapse into a single `idle` bucket because the user-visible distinction between "at the prompt" and "typing a command" is not load-bearing for grouping. `finished` stays distinct so a recently-completed pane can be filtered separately even though its header label is `<idle>`. Directory group keys use `cwdIdentity(cwd)` so remote hosts and Windows/POSIX path kinds remain distinct. Windows UNC display labels keep `\\server\share\` as the path root and do not repeat the server/share in the trailing path segments. diff --git a/lib/src/lib/terminal-protocol.test.ts b/lib/src/lib/terminal-protocol.test.ts index 10dc1a5..3795e48 100644 --- a/lib/src/lib/terminal-protocol.test.ts +++ b/lib/src/lib/terminal-protocol.test.ts @@ -219,7 +219,6 @@ describe('TerminalProtocolParser', () => { const staleTitleState = reduceSemanticEvents(staleTitleEvents); expect(deriveHeader(staleTitleState, [staleTitleState])).toEqual({ primary: 'npm test', - status: 'running', }); const freshTitleParser = new TerminalProtocolParser(); @@ -235,7 +234,6 @@ describe('TerminalProtocolParser', () => { const freshTitleState = reduceSemanticEvents(freshTitleEvents); expect(deriveHeader(freshTitleState, [freshTitleState])).toEqual({ primary: 'vitest', - status: 'running', }); }); diff --git a/lib/src/lib/terminal-state.test.ts b/lib/src/lib/terminal-state.test.ts index f2db162..5be0713 100644 --- a/lib/src/lib/terminal-state.test.ts +++ b/lib/src/lib/terminal-state.test.ts @@ -170,16 +170,14 @@ describe('terminal command state reducer', () => { }); state = reduceTerminalState(state, { type: 'commandFinish', exitCode: 0 }, { now: () => 3 }); + expect(state.activity).toEqual({ kind: 'finished', exitCode: 0 }); expect(deriveHeader(state, [state])).toEqual({ - primary: 'lazygit', - status: 'finished', - exitCode: 0, + primary: DEFAULT_IDLE_TITLE, }); state = reduceTerminalState(state, { type: 'promptStart' }); expect(deriveHeader(state, [state])).toEqual({ primary: DEFAULT_IDLE_TITLE, - status: 'idle', }); }); @@ -204,7 +202,6 @@ describe('terminal command state reducer', () => { expect(deriveHeader(state, [state])).toEqual({ primary: 'lazygit', - status: 'running', }); state = reduceTerminalState(state, { type: 'promptStart' }); @@ -212,7 +209,6 @@ describe('terminal command state reducer', () => { expect(state.currentCommand).toBeNull(); expect(deriveHeader(state, [state])).toEqual({ primary: DEFAULT_IDLE_TITLE, - status: 'idle', }); }); }); @@ -240,7 +236,6 @@ describe('header and grouping derivation', () => { expect(deriveHeader(pane, [pane])).toEqual({ primary: DEFAULT_IDLE_TITLE, - status: 'idle', }); }); @@ -251,12 +246,10 @@ describe('header and grouping derivation', () => { expect(deriveHeader(app, [app, api])).toEqual({ primary: 'pnpm test --watch', secondary: 'app', - status: 'running', }); expect(deriveHeader(api, [app, api])).toEqual({ primary: 'pnpm test --watch', secondary: 'api', - status: 'running', }); }); @@ -268,7 +261,6 @@ describe('header and grouping derivation', () => { expect(deriveHeader(pane, [pane])).toEqual({ primary: 'lazygit: mouseterm', - status: 'running', }); }); @@ -280,7 +272,6 @@ describe('header and grouping derivation', () => { expect(deriveHeader(pane, [pane])).toEqual({ primary: 'lazygit', - status: 'running', }); }); @@ -291,7 +282,6 @@ describe('header and grouping derivation', () => { expect(deriveHeader(pane, [pane])).toEqual({ primary: 'dev server', - status: 'running', }); expect(titleCandidatesForDisplay(pane).map((candidate) => candidate.source)).toEqual(['osc0', 'user']); }); @@ -308,7 +298,6 @@ describe('header and grouping derivation', () => { appTitleForPane: buildAppTitleResolver(terminalStates, activityStates), })).toEqual({ primary: 'Build finished', - status: 'running', }); }); @@ -326,7 +315,6 @@ describe('header and grouping derivation', () => { appTitleForPane: buildAppTitleResolver(terminalStates, activityStates), })).toEqual({ primary: 'npm run build', - status: 'running', }); }); @@ -350,7 +338,6 @@ describe('header and grouping derivation', () => { ); expect(deriveHeader(pane, [pane])).toEqual({ primary: 'npm test', - status: 'running', }); }); diff --git a/lib/src/lib/terminal-state.ts b/lib/src/lib/terminal-state.ts index bd8edba..4c19cac 100644 --- a/lib/src/lib/terminal-state.ts +++ b/lib/src/lib/terminal-state.ts @@ -95,8 +95,6 @@ export interface HeaderOptions extends DirectoryDisplayOptions { export interface DerivedHeader { primary: string; secondary?: string; - status: 'unknown' | 'idle' | 'running' | 'finished'; - exitCode?: number; } export type TerminalGroupingMode = 'none' | 'directory' | 'command' | 'status'; @@ -390,7 +388,6 @@ export function deriveHeader( options: HeaderOptions = {}, ): DerivedHeader { const primary = headerPrimary(pane, options); - const status = headerStatus(pane); const samePrimary = visiblePanes.filter((candidate) => headerPrimary(candidate, options) === primary); const cwd = cwdForHeader(pane); let secondary: string | undefined; @@ -404,10 +401,7 @@ export function deriveHeader( } } - const exitCode = pane.activity.kind === 'finished' ? pane.activity.exitCode : undefined; - return exitCode === undefined - ? { primary, secondary, status } - : { primary, secondary, status, exitCode }; + return { primary, secondary }; } export function notificationDisplayTitle( @@ -509,11 +503,24 @@ export function groupTerminalPanes( } return groupBy(panes, (pane) => { - const status = headerStatus(pane); + const status = statusBucket(pane.activity.kind); return { key: status, label: status }; }); } +function statusBucket(kind: ShellActivity['kind']): 'unknown' | 'idle' | 'running' | 'finished' { + switch (kind) { + case 'running': + return 'running'; + case 'finished': + return 'finished'; + case 'unknown': + return 'unknown'; + default: + return 'idle'; + } +} + function cwdFromDecodedPath(rawPath: string, source: CwdSource, now: number): CwdState | null { const path = safeDecodeURIComponent(rawPath.trim()); if (!path) return null; @@ -763,13 +770,12 @@ function truncateCommandTitle(title: string): string { function headerPrimary(pane: TerminalPaneState, options: HeaderOptions): string { const userTitle = titleCandidateForSource(pane, 'user')?.title.trim(); if (userTitle) return userTitle; + if (!pane.currentCommand) return DEFAULT_IDLE_TITLE; const appTitle = options.appTitleForPane?.(pane)?.trim(); if (appTitle && isAppTitleFresh(pane)) return appTitle; const terminalTitle = activeTerminalTitle(pane); if (terminalTitle) return terminalTitle; - if (pane.currentCommand) return pane.currentCommand.displayCommand; - if (pane.activity.kind === 'finished' && pane.lastCommand) return pane.lastCommand.displayCommand; - return idleLabel(pane); + return pane.currentCommand.displayCommand; } // appTitleForPane is sourced from the alert manager's current OSC 9 notification. @@ -780,7 +786,7 @@ function headerPrimary(pane: TerminalPaneState, options: HeaderOptions): string // candidate exists (e.g. notification was injected without going through the // parser), trust the appTitle to preserve legacy behaviour. function isAppTitleFresh(pane: TerminalPaneState): boolean { - const command = pane.currentCommand ?? (pane.activity.kind === 'finished' ? pane.lastCommand : null); + const command = pane.currentCommand; if (!command) return true; const osc9 = pane.titleCandidates.osc9; if (!osc9) return true; @@ -796,7 +802,7 @@ function idleLabel(pane: TerminalPaneState): string { const HEADER_APP_TITLE_SOURCES: TerminalTitleSource[] = ['osc0', 'osc2', 'osc9', 'notification']; function activeTerminalTitle(pane: TerminalPaneState): string | null { - const command = pane.currentCommand ?? (pane.activity.kind === 'finished' ? pane.lastCommand : null); + const command = pane.currentCommand; if (!command) return null; const title = latestTitleCandidateForSources(pane, HEADER_APP_TITLE_SOURCES); if (!title || title.updatedAt < command.startedAt) return null; @@ -804,22 +810,8 @@ function activeTerminalTitle(pane: TerminalPaneState): string | null { return text || null; } -function headerStatus(pane: TerminalPaneState): DerivedHeader['status'] { - switch (pane.activity.kind) { - case 'running': - return 'running'; - case 'finished': - return 'finished'; - case 'unknown': - return 'unknown'; - default: - return 'idle'; - } -} - function cwdForHeader(pane: TerminalPaneState): CwdState | null { if (pane.currentCommand?.cwdAtStart) return pane.currentCommand.cwdAtStart; - if (pane.activity.kind === 'finished' && pane.lastCommand?.cwdAtStart) return pane.lastCommand.cwdAtStart; return pane.cwd; } diff --git a/lib/src/stories/ShellCwd.stories.tsx b/lib/src/stories/ShellCwd.stories.tsx index 01809db..3208e7e 100644 --- a/lib/src/stories/ShellCwd.stories.tsx +++ b/lib/src/stories/ShellCwd.stories.tsx @@ -164,7 +164,7 @@ function ShellCwdMatrix({ <div className="truncate text-sm font-semibold">{item.label}</div> {item.note && <div className="truncate text-xs text-muted">{item.note}</div>} </div> - <DerivedBadge header={header} /> + <DerivedBadge state={item.state} header={header} /> <div style={{ width: HEADER_WIDTH }}> <HeaderPreview id={item.id} title={item.fallbackTitle ?? UNNAMED_PANEL_TITLE} /> </div> @@ -206,10 +206,18 @@ function BaseboardPreview({ id, title }: { id: string; title: string }) { ); } -function DerivedBadge({ header }: { header: ReturnType<typeof deriveHeader> }) { +function DerivedBadge({ + state, + header, +}: { + state: TerminalPaneState; + header: ReturnType<typeof deriveHeader>; +}) { + const exit = state.activity.kind === 'finished' ? state.activity.exitCode : undefined; + const status = state.activity.kind; return ( <div className="min-w-0 text-xs text-muted"> - <div className="truncate">{header.status}{header.exitCode !== undefined ? ` ${header.exitCode}` : ''}</div> + <div className="truncate">{status}{exit !== undefined ? ` ${exit}` : ''}</div> <div className="truncate">{header.secondary ?? 'no secondary'}</div> </div> ); From 2aaa16c943883c24aa4c9717f2c8a9fe450f107d Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Sat, 9 May 2026 09:28:58 -0700 Subject: [PATCH 39/50] Delegate header-label rules from layout.md to terminal-state.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves SPEC-CONFLICTS #2. layout.md was restating the full title priority chain, the disambiguator formula, and the OSC-channel list — all of which already live in terminal-state.md. Replace those paragraphs with a one-way delegation pointer. Layout keeps the rendering concerns it owns (truncation, click/right-click, secondary label visual treatment) and defers semantics to terminal-state.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- SPEC-CONFLICTS.md | 15 +-------------- docs/specs/layout.md | 4 ++-- 2 files changed, 3 insertions(+), 16 deletions(-) diff --git a/SPEC-CONFLICTS.md b/SPEC-CONFLICTS.md index 16f3a94..353660d 100644 --- a/SPEC-CONFLICTS.md +++ b/SPEC-CONFLICTS.md @@ -4,19 +4,6 @@ Audit of the changed regions in `iTerm2.md`, `layout.md`, `terminal-state.md` (n ## Substantive conflicts -### 2. Header-derivation rules don't form a clean priority chain - -`terminal-state.md` line 194–204 vs `layout.md`. - -`layout.md` lays out a clean order: *user-pinned → app-sent override → current/freshly-finished command → `<idle>`*. - -But the bullets in `terminal-state.md` contradict that: - -- "A freshly finished command uses `lastCommand.displayCommand` until the next prompt signal." — no override carve-out, even though the bullet above grants override precedence. -- "Idle terminals use `<idle>` unless a user-pinned title exists." — omits the app-sent override case entirely. - -Read literally, an app-sent OSC 9 on an idle pane would be ignored (idle rule wins), and an override during `lastCommand` would be ignored (finished rule wins). Both contradict `layout.md`'s priority list. - ### 5. Dead/unreferenced enum values - `CommandRun.source` (line 67–73) declares `"foreground_process"` and `"title"`, but no rule, table row, or fallback in the document produces them. @@ -60,4 +47,4 @@ If `<unnamed>` is rejected at write time per the rename rules (`layout.md` line --- -Severity: #2 is a blocker — it changes actual behavior depending on which spec a reader trusts. #5 is a spec-hygiene issue that will silently rot. The rest are minor. (#1, #3, #4 are resolved.) +Severity: #5 is a spec-hygiene issue that will silently rot. The rest are minor. (#1, #2, #3, #4 are resolved.) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 3f2c678..2e3a79f 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -72,9 +72,9 @@ The content area is a tiling layout of panes, powered by dockview. Each pane occ Each pane has a 30px header that doubles as a drag handle. The header uses `cursor-grab` / `active:cursor-grabbing`, `select-none`, and the shared terminal top radius from `lib/src/components/design.tsx`. Background and foreground use the `--color-header-active-*` / `--color-header-inactive-*` token pairs, which map to VSCode file-tree list colors. Dockview's default close button and right-actions container are hidden via CSS. -The header label is derived from `TerminalPaneState` plus scoped protocol notification state: user-pinned title first, then app-sent title overrides, then current/freshly-finished command title, then `<idle>` for idle panes. App-sent overrides include legacy OSC 9 message text and OSC 0/2 terminal titles emitted after the current command started. Rich notification titles from OSC 99/777 stay in TODO notification UI rather than replacing the tab/door label. Older shell titles remain fallback-only and do not replace the default idle label. When visible panes would have duplicate primary labels, the header adds a compact directory disambiguator using the running command's `cwdAtStart` or the idle pane's latest `cwd`. +The header label is the `DerivedHeader` returned by `deriveHeader(paneState, visiblePanes)` in `docs/specs/terminal-state.md` — that spec is the single source of truth for the priority chain (user-pinned title, app-sent overrides, command title, `<idle>`), the disambiguator rule, and which OSC sources contribute. Layout's job is to render the result: the primary label truncates with ellipsis, the secondary label (when present) is shown muted next to it, click renames/pins, right-click opens the diagnostic popup. -Right-clicking the derived session label opens a diagnostic popup listing the latest title candidate per channel, including user, OSC 0, OSC 2, OSC 9, OSC 99, and OSC 777 where present. Each row shows the channel, latest candidate text, and timestamp. The popup is diagnostic only; it does not change the title priority rules. +The diagnostic popup lists the latest entry per `titleCandidates` channel as defined in `docs/specs/terminal-state.md`. Each row shows the channel, latest candidate text, and timestamp. The popup is diagnostic only; it does not change the title priority rules. Elements from left to right: From fe09ab47620f468a7cf056dd0723035d22e3268a Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Sat, 9 May 2026 09:32:10 -0700 Subject: [PATCH 40/50] Drop dead enum values and document live ones in terminal-state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves SPEC-CONFLICTS #5. Dead in production (no producer outside the type definition): - CommandRunSource: 'foreground_process', 'title' - TerminalTitleSource: 'notification', 'profile', 'derived' Removed from both the TypeScript types and the spec, plus the exhaustive titleSourceLabel switch and the HEADER_APP_TITLE_SOURCES constant. One Storybook story was using 'derived' as a placeholder title source — switched to 'osc0'. Live but undocumented in the spec: - CwdSource 'manual': cwdFromManualPath(), used by session restore. - TerminalTitleSource 'user': setTerminalUserTitle(), inline rename UI. Both now have explicit production rules in terminal-state.md so readers can reason about where each value comes from. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- SPEC-CONFLICTS.md | 8 +------- docs/specs/terminal-state.md | 18 +++++++++++------- lib/src/lib/terminal-state.ts | 17 +++-------------- lib/src/stories/ShellCwd.stories.tsx | 2 +- 4 files changed, 16 insertions(+), 29 deletions(-) diff --git a/SPEC-CONFLICTS.md b/SPEC-CONFLICTS.md index 353660d..a7c2468 100644 --- a/SPEC-CONFLICTS.md +++ b/SPEC-CONFLICTS.md @@ -4,12 +4,6 @@ Audit of the changed regions in `iTerm2.md`, `layout.md`, `terminal-state.md` (n ## Substantive conflicts -### 5. Dead/unreferenced enum values - -- `CommandRun.source` (line 67–73) declares `"foreground_process"` and `"title"`, but no rule, table row, or fallback in the document produces them. -- `CwdState.source` (line 40) declares `"manual"`, with no production rule. CWD fallback step 3 ("initial launch or restored directory") is the most likely candidate, but it's never tied to the `manual` value. -- `TerminalTitle.source` (line 84–93) declares `"notification"`, `"profile"`, and `"derived"` — none of these are produced by any documented event, none appear in the `titleCandidates` tables in `iTerm2.md`, and none are listed in `layout.md`'s right-click popup channels. - ### 6. Disambiguator coverage is inconsistent - `layout.md`: "running command's `cwdAtStart` or the idle pane's latest `cwd`" — only running and idle. @@ -47,4 +41,4 @@ If `<unnamed>` is rejected at write time per the rename rules (`layout.md` line --- -Severity: #5 is a spec-hygiene issue that will silently rot. The rest are minor. (#1, #2, #3, #4 are resolved.) +Severity: all remaining items are minor. (#1, #2, #3, #4, #5 are resolved.) diff --git a/docs/specs/terminal-state.md b/docs/specs/terminal-state.md index 9cb175a..430fa72 100644 --- a/docs/specs/terminal-state.md +++ b/docs/specs/terminal-state.md @@ -68,9 +68,7 @@ type CommandRun = { | "osc633_E" | "osc633_boundaries" | "osc133_boundaries" - | "foreground_process" - | "user_input" - | "title"; + | "user_input"; outputRange?: { startMarkId?: string; endMarkId?: string; @@ -87,10 +85,7 @@ type TerminalTitle = { | "osc9" | "osc99" | "osc777" - | "notification" - | "user" - | "profile" - | "derived"; + | "user"; updatedAt: number; }; ``` @@ -126,6 +121,11 @@ CWD: | `OSC 633 ; P ; Cwd=<cwd> ST` | `osc633` | VS Code-style CWD. | | `OSC 1337 ; CurrentDir=<cwd> ST` | `osc1337` | iTerm2-style CWD compatibility. | +Non-OSC CWD sources: + +- `process` — adapter polled the PTY's process for its working directory. Used when no OSC source has reported recently. +- `manual` — caller seeded the CWD directly (e.g., session restore from saved state, or a known spawn directory). Produced by `cwdFromManualPath()`. + Command lifecycle: | Sequence | Event | @@ -152,6 +152,10 @@ Title candidate diagnostics: | `OSC 99 ; ... title/body ... ST` | `osc99` | No | | `OSC 777 ; notify ; <title> ; <body> ST` | `osc777` | No | +Non-OSC title source: + +- `user` — user-pinned title set via the inline rename UI (`setTerminalUserTitle`). Always wins over every other candidate. + The parser accepts both BEL and ST terminators and handles split chunks. Unsupported OSCs pass through to xterm unchanged; supported-but-malformed semantic OSCs are consumed without changing state. ## Reducer diff --git a/lib/src/lib/terminal-state.ts b/lib/src/lib/terminal-state.ts index 4c19cac..9a1ccda 100644 --- a/lib/src/lib/terminal-state.ts +++ b/lib/src/lib/terminal-state.ts @@ -23,9 +23,7 @@ export type CommandRunSource = | 'osc633_E' | 'osc633_boundaries' | 'osc133_boundaries' - | 'foreground_process' - | 'user_input' - | 'title'; + | 'user_input'; export interface CommandRun { id: string; @@ -48,10 +46,7 @@ export type TerminalTitleSource = | 'osc9' | 'osc99' | 'osc777' - | 'notification' - | 'user' - | 'profile' - | 'derived'; + | 'user'; export interface TerminalTitle { title: string; @@ -464,14 +459,8 @@ export function titleSourceLabel(source: TerminalTitleSource): string { return 'OSC 99'; case 'osc777': return 'OSC 777'; - case 'notification': - return 'notification'; case 'user': return 'user'; - case 'profile': - return 'profile'; - case 'derived': - return 'derived'; } } @@ -799,7 +788,7 @@ function idleLabel(pane: TerminalPaneState): string { return DEFAULT_IDLE_TITLE; } -const HEADER_APP_TITLE_SOURCES: TerminalTitleSource[] = ['osc0', 'osc2', 'osc9', 'notification']; +const HEADER_APP_TITLE_SOURCES: TerminalTitleSource[] = ['osc0', 'osc2', 'osc9']; function activeTerminalTitle(pane: TerminalPaneState): string | null { const command = pane.currentCommand; diff --git a/lib/src/stories/ShellCwd.stories.tsx b/lib/src/stories/ShellCwd.stories.tsx index 3208e7e..1201fde 100644 --- a/lib/src/stories/ShellCwd.stories.tsx +++ b/lib/src/stories/ShellCwd.stories.tsx @@ -91,7 +91,7 @@ export const HostAndDirectoryDisambiguation: Story = storyFor([ ]); export const ShellActivityLifecycle: Story = storyFor([ - caseState('activity-unknown', 'Unknown', idle({ activity: { kind: 'unknown' }, title: terminalTitle('shell', 'derived') }), 'No shell integration signal'), + caseState('activity-unknown', 'Unknown', idle({ activity: { kind: 'unknown' }, title: terminalTitle('shell', 'osc0') }), 'No shell integration signal'), caseState('activity-prompt', 'Prompt drawing', idle({ activity: { kind: 'prompt' }, title: terminalTitle('zsh', 'osc0') }), 'Prompt start'), caseState('activity-editing', 'Editing', idle({ activity: { kind: 'editing' }, title: terminalTitle('zsh', 'osc0') }), 'At prompt'), caseState('activity-running', 'Running', running('/repo/app', 'npm run dev'), 'Foreground command active'), From 8a79062671e9428f8f96b405fbaec7f8fb546e5c Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Sat, 9 May 2026 09:32:39 -0700 Subject: [PATCH 41/50] Drop SPEC-CONFLICTS #6 (disambiguator coverage) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-resolved by the prior commits: cwdForHeader in terminal-state.ts collapsed to "running uses cwdAtStart, everything else uses pane.cwd" (da25a4c), and layout.md now delegates the disambiguator rule to terminal-state.md (2aaa16c). No remaining duplication or mismatch — every kind, including unknown, is covered by the "everything else" branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- SPEC-CONFLICTS.md | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/SPEC-CONFLICTS.md b/SPEC-CONFLICTS.md index a7c2468..f8a4753 100644 --- a/SPEC-CONFLICTS.md +++ b/SPEC-CONFLICTS.md @@ -4,14 +4,6 @@ Audit of the changed regions in `iTerm2.md`, `layout.md`, `terminal-state.md` (n ## Substantive conflicts -### 6. Disambiguator coverage is inconsistent - -- `layout.md`: "running command's `cwdAtStart` or the idle pane's latest `cwd`" — only running and idle. -- `terminal-state.md`: running **and finished** use `cwdAtStart`; idle uses `pane.cwd`. -- Neither covers `unknown`-kind panes, even though `unknown` is a first-class status. - -`layout.md` silently drops the "finished" case. - ### 7. Right-click popup channels (6) ≠ `TerminalTitle.source` enum (9) `layout.md` says the diagnostic popup lists "user, OSC 0, OSC 2, OSC 9, OSC 99, and OSC 777 where present." The type allows three more (`notification`, `profile`, `derived`). Either the popup is exhaustive (and the enum has dead values), or the enum is right (and the popup spec is incomplete). @@ -41,4 +33,4 @@ If `<unnamed>` is rejected at write time per the rename rules (`layout.md` line --- -Severity: all remaining items are minor. (#1, #2, #3, #4, #5 are resolved.) +Severity: all remaining items are minor. (#1, #2, #3, #4, #5, #6 are resolved.) From 0a437c51f647d5e14f00e6dd4a5a45c68338a653 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Sat, 9 May 2026 09:33:03 -0700 Subject: [PATCH 42/50] Drop SPEC-CONFLICTS #7 (popup channels mismatch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-resolved: dead enum values (notification/profile/derived) were removed in fe09ab4, and layout.md was rewritten in 2aaa16c to delegate the channel list to terminal-state.md instead of enumerating it. The type now has exactly the six sources the popup shows, and layout.md no longer hardcodes a list — it shows whatever the canonical type defines. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- SPEC-CONFLICTS.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/SPEC-CONFLICTS.md b/SPEC-CONFLICTS.md index f8a4753..8539b58 100644 --- a/SPEC-CONFLICTS.md +++ b/SPEC-CONFLICTS.md @@ -4,10 +4,6 @@ Audit of the changed regions in `iTerm2.md`, `layout.md`, `terminal-state.md` (n ## Substantive conflicts -### 7. Right-click popup channels (6) ≠ `TerminalTitle.source` enum (9) - -`layout.md` says the diagnostic popup lists "user, OSC 0, OSC 2, OSC 9, OSC 99, and OSC 777 where present." The type allows three more (`notification`, `profile`, `derived`). Either the popup is exhaustive (and the enum has dead values), or the enum is right (and the popup spec is incomplete). - ### 8. Resume seeding: `non-unnamed` filter only on one side `vscode.md` vs `layout.md`. @@ -33,4 +29,4 @@ If `<unnamed>` is rejected at write time per the rename rules (`layout.md` line --- -Severity: all remaining items are minor. (#1, #2, #3, #4, #5, #6 are resolved.) +Severity: all remaining items are minor. (#1, #2, #3, #4, #5, #6, #7 are resolved.) From c4fc723a7c781338a451b367050d2bd31d463804 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Sat, 9 May 2026 09:34:48 -0700 Subject: [PATCH 43/50] Align resume title-seeding wording across specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves SPEC-CONFLICTS #8. Both layout.md and transport.md now describe the resume seeding flow in terms of setTerminalUserTitle() — the canonical helper that rejects reserved sentinels (<idle>, <unnamed>). This is what the code actually does (resumeTerminal/restoreTerminal in terminal-lifecycle.ts), and it removes the prior wording divergence where transport.md said "non-unnamed" and layout.md said nothing about a filter at all. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- SPEC-CONFLICTS.md | 11 +---------- docs/specs/layout.md | 2 +- docs/specs/transport.md | 2 +- 3 files changed, 3 insertions(+), 12 deletions(-) diff --git a/SPEC-CONFLICTS.md b/SPEC-CONFLICTS.md index 8539b58..ddf311a 100644 --- a/SPEC-CONFLICTS.md +++ b/SPEC-CONFLICTS.md @@ -4,15 +4,6 @@ Audit of the changed regions in `iTerm2.md`, `layout.md`, `terminal-state.md` (n ## Substantive conflicts -### 8. Resume seeding: `non-unnamed` filter only on one side - -`vscode.md` vs `layout.md`. - -- `vscode.md`: "seeds any **non-unnamed** saved pane or door titles as user titles." -- `layout.md`: "seed any saved pane or door title as the Session's user title." - -If `<unnamed>` is rejected at write time per the rename rules (`layout.md` line 262), the read-time filter is unnecessary; if legacy data can contain it, both specs should agree on the filter. Pick one. - ## Smaller issues ### 9. "Stale pending command-line fallback" @@ -29,4 +20,4 @@ If `<unnamed>` is rejected at write time per the rename rules (`layout.md` line --- -Severity: all remaining items are minor. (#1, #2, #3, #4, #5, #6, #7 are resolved.) +Severity: all remaining items are minor. (#1, #2, #3, #4, #5, #6, #7, #8 are resolved.) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 2e3a79f..35064ec 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -280,7 +280,7 @@ Layout, scrollback, cwd, minimized items, user-pinned titles, and alert state ar Saved snapshots are read through `readPersistedSession()`, which accepts the canonical object shape and defensively parses a JSON-stringified blob before validation and migration. This keeps malformed storage inert while covering hosts that hand back serialized JSON instead of the parsed object. On startup, recovery is priority-based: -1. **Resume** (webview hidden/shown, live PTYs): request PTY list + replay data from platform, `resumeTerminal()` for each (500ms timeout), and seed any saved pane or door title as the Session's user title. If the saved session covers every live PTY, restore the saved dockview layout when its visible panel set matches and reattach saved minimized items as doors. This still counts as a live resume when every live session is minimized, so recovery must not fall through to cold restore just because the visible `paneIds` list is empty. +1. **Resume** (webview hidden/shown, live PTYs): request PTY list + replay data from platform, `resumeTerminal()` for each (500ms timeout). Saved pane and door titles are seeded back via `setTerminalUserTitle()` (see `docs/specs/transport.md`) so persisted placeholder labels never replay as user pins. If the saved session covers every live PTY, restore the saved dockview layout when its visible panel set matches and reattach saved minimized items as doors. This still counts as a live resume when every live session is minimized, so recovery must not fall through to cold restore just because the visible `paneIds` list is empty. 2. **Restore** (app restart, cold start): restore layout from serialized dockview state, `restoreTerminal()` for each pane with saved cwd + scrollback, and spawn each PTY with the current default shell selection 3. **Fallback/manual pane creation**: when no saved layout can be safely applied, add multiple panes as splits from the previous pane rather than tabs, and spawn each PTY with the current default shell selection 4. **Empty state**: create a single new pane with the current default shell selection diff --git a/docs/specs/transport.md b/docs/specs/transport.md index 960014c..d5e56ce 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -56,7 +56,7 @@ Both are capped at 1M chars per PTY. When the cap is reached, oldest chunks are 3. Host responds with: - { type: 'pty:list', ptys: [{ id, alive, exitCode }] } // all owned PTYs - { type: 'pty:replay', id, data } // buffered output per PTY -4. Webview restores terminals from replay data, seeds any non-unnamed saved pane or door titles as user titles, and resumes the live stream. +4. Webview restores terminals from replay data, seeds saved pane and door titles back via `setTerminalUserTitle()` (which silently drops reserved sentinels like `<idle>` and `<unnamed>`), and resumes the live stream. 5. If the saved session covers those live PTYs, the frontend uses the saved dockview layout when its visible panels match and reattaches saved minimized doors; minimized PTYs are registered but remain doors instead of visible panes. ``` From 9cbb0a4573081cea2adb3b757e76ff3f9c394dc5 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Sat, 9 May 2026 09:35:24 -0700 Subject: [PATCH 44/50] Define what 'stale' meant for pending command-line clearing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves SPEC-CONFLICTS #9. The reducer in terminal-state.ts unconditionally clears pendingCommandLine on promptStart and promptEnd — there's no actual "staleness" condition, the spec wording was just imprecise. Replace "clears stale pending command-line fallback" with the concrete rule: a fresh prompt boundary drops any pending input that was not yet consumed by a commandStart, because a fresh prompt is the unambiguous signal that no command is in flight. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- SPEC-CONFLICTS.md | 6 +----- docs/specs/terminal-state.md | 4 ++-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/SPEC-CONFLICTS.md b/SPEC-CONFLICTS.md index ddf311a..4613d28 100644 --- a/SPEC-CONFLICTS.md +++ b/SPEC-CONFLICTS.md @@ -6,10 +6,6 @@ Audit of the changed regions in `iTerm2.md`, `layout.md`, `terminal-state.md` (n ## Smaller issues -### 9. "Stale pending command-line fallback" - -`terminal-state.md` line 162–163: `promptStart` and `promptEnd` "clear stale pending command-line fallback" without defining what makes a pending line "stale." With `user_input` fallback firing in `editing` and OSC 633 ; E firing later, the staleness condition is load-bearing but unstated. - ### 10. CWD fallback list vs. priority statement `terminal-state.md` line 173–180: the numbered fallback list looks like a strict priority, but the immediately following sentence describes a different, looser semantics ("process may fill `null` or replace manual/restored, but not OSC"). It would be clearer as a single rule; as written, the two paragraphs leave the manual-vs-process tiebreak ambiguous when both fight for the same slot at runtime. @@ -20,4 +16,4 @@ Audit of the changed regions in `iTerm2.md`, `layout.md`, `terminal-state.md` (n --- -Severity: all remaining items are minor. (#1, #2, #3, #4, #5, #6, #7, #8 are resolved.) +Severity: all remaining items are minor. (#1, #2, #3, #4, #5, #6, #7, #8, #9 are resolved.) diff --git a/docs/specs/terminal-state.md b/docs/specs/terminal-state.md index 430fa72..e4befa5 100644 --- a/docs/specs/terminal-state.md +++ b/docs/specs/terminal-state.md @@ -163,8 +163,8 @@ The parser accepts both BEL and ST terminators and handles split chunks. Unsuppo `reduceTerminalState(state, event)` is the only state transition surface. - `cwd` replaces the latest session CWD. -- `promptStart` sets `{ kind: "prompt" }` and clears stale pending command-line fallback. -- `promptEnd` sets `{ kind: "editing" }` and clears stale pending command-line fallback. +- `promptStart` sets `{ kind: "prompt" }`, clears `currentCommand`, and clears `pendingCommandLine`. Any pending input that was not yet consumed by a `commandStart` is dropped — a fresh prompt is the unambiguous signal that no command is in flight. +- `promptEnd` sets `{ kind: "editing" }`, clears `currentCommand`, and clears `pendingCommandLine` for the same reason. - `commandLine` stores `pendingCommandLine`. - User-entered prompt input may also store `pendingCommandLine` as an explicit fallback before an OSC 133/633 command-start boundary. This fallback is only used while the shell is idle/editing; foreground-program input is ignored. If the submitted line is non-empty, the input fallback may create a `currentCommand` immediately with `source: "user_input"` so shells without command-start integration still show the active command. - The typed-command fallback resolves the current Session id from the PTY id before recording input or prompt-looking output, so drag-to-swap moves the fallback state with the visible pane. From 1cf31b672b55438c370f33cd5d30e7e7b64d3410 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Sat, 9 May 2026 09:36:25 -0700 Subject: [PATCH 45/50] Replace CWD fallback list with a single precedence statement Resolves SPEC-CONFLICTS #10. The prior wording mixed a numbered fallback list ("OSC > process > manual > null") with a separate caveat ("process may fill null or replace manual/restored, but not OSC"). Read together they were ambiguous about manual-vs-process tiebreaks. Replace with one rule per source, matching what updateCwdIfAllowed() in terminal-state-store.ts actually enforces: - OSC always wins (only a later OSC can replace it). - process updates only when current source is null/manual/process. - manual is the initial seed and is replaceable by any later source. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- SPEC-CONFLICTS.md | 6 +----- docs/specs/terminal-state.md | 11 +++++------ 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/SPEC-CONFLICTS.md b/SPEC-CONFLICTS.md index 4613d28..55ce5c5 100644 --- a/SPEC-CONFLICTS.md +++ b/SPEC-CONFLICTS.md @@ -6,14 +6,10 @@ Audit of the changed regions in `iTerm2.md`, `layout.md`, `terminal-state.md` (n ## Smaller issues -### 10. CWD fallback list vs. priority statement - -`terminal-state.md` line 173–180: the numbered fallback list looks like a strict priority, but the immediately following sentence describes a different, looser semantics ("process may fill `null` or replace manual/restored, but not OSC"). It would be clearer as a single rule; as written, the two paragraphs leave the manual-vs-process tiebreak ambiguous when both fight for the same slot at runtime. - ### 11. `OSC 9` vs `OSC 9;4` title-candidate split is implicit `iTerm2.md`'s title-candidate side-effects table lists `OSC 9` as recording `titleCandidates.osc9 = message`, but `OSC 9;4` (progress) appears only as a notification source. A reader has to infer that "OSC 9" in the candidate row means *only* the message form, not the progress form. Worth saying explicitly given they share the same OSC number. --- -Severity: all remaining items are minor. (#1, #2, #3, #4, #5, #6, #7, #8, #9 are resolved.) +Severity: all remaining items are minor. (#1–#10 are resolved.) diff --git a/docs/specs/terminal-state.md b/docs/specs/terminal-state.md index e4befa5..ed29d70 100644 --- a/docs/specs/terminal-state.md +++ b/docs/specs/terminal-state.md @@ -174,14 +174,13 @@ The parser accepts both BEL and ST terminators and handles split chunks. Unsuppo - A later prompt signal moves the pane out of `finished`. If a command was started from `user_input` and no explicit `commandFinish` arrived, the prompt signal also clears `currentCommand` so the header returns to `<idle>`. - For `user_input` fallback commands only, visible output that looks like a returned shell prompt may synthesize the same prompt transition. This is a scoped fallback for shells that do not emit command finish/start OSCs. -CWD fallback order is: +CWD precedence: -1. OSC-reported CWD -2. process CWD, if available -3. initial launch or restored directory -4. `null` +- OSC-sourced CWD (`osc7`, `osc9_9`, `osc633`, `osc1337`) wins over everything. Once an OSC has reported a directory, only a later OSC can replace it. +- Process-polled CWD (`process`) updates only when the current source is `null`, `manual`, or another `process` reading. It fills the gap when the shell does not emit OSC 7 / 633;P / 1337 / 9;9. +- Manually seeded CWD (`manual`) is the initial seed during session restore or known-spawn-directory launches. It is replaceable by any later source. +- Default is `null`. -Process-derived CWD may fill `null` or replace manual/restored CWD, but it must not overwrite explicit OSC CWD. Asynchronous process CWD query results are applied through PTY-id resolution, so a result that arrives after `swap` updates the Session that currently owns that PTY. ## Header Derivation From cad20e40f8b366d1d8428693f0f2f1fc2470afc3 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Sat, 9 May 2026 09:37:13 -0700 Subject: [PATCH 46/50] Disambiguate OSC 9 message form from OSC 9;4 progress form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves SPEC-CONFLICTS #11 — the final item. Add an explicit note to terminal-state.md's title-candidate table so a reader does not have to infer that "OSC 9" in the candidate row means only the message form, not the progress form. The progress form (OSC 9;4) carries no text and is fully specified in alert.md. With every audit item now closed (#1 through #11), SPEC-CONFLICTS.md is removed; the audit lived as a working document and has served its purpose. The resolution trail is preserved in commits 096a3d5, b5896bb, da25a4c, 2aaa16c, fe09ab4, 8a79062, 0a437c5, c4fc723, 9cbb0a4, and 1cf31b6. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- SPEC-CONFLICTS.md | 15 --------------- docs/specs/terminal-state.md | 2 ++ 2 files changed, 2 insertions(+), 15 deletions(-) delete mode 100644 SPEC-CONFLICTS.md diff --git a/SPEC-CONFLICTS.md b/SPEC-CONFLICTS.md deleted file mode 100644 index 55ce5c5..0000000 --- a/SPEC-CONFLICTS.md +++ /dev/null @@ -1,15 +0,0 @@ -# Spec conflicts in `docs/specs/` (HEAD vs main) - -Audit of the changed regions in `iTerm2.md`, `layout.md`, `terminal-state.md` (new), and `vscode.md`. - -## Substantive conflicts - -## Smaller issues - -### 11. `OSC 9` vs `OSC 9;4` title-candidate split is implicit - -`iTerm2.md`'s title-candidate side-effects table lists `OSC 9` as recording `titleCandidates.osc9 = message`, but `OSC 9;4` (progress) appears only as a notification source. A reader has to infer that "OSC 9" in the candidate row means *only* the message form, not the progress form. Worth saying explicitly given they share the same OSC number. - ---- - -Severity: all remaining items are minor. (#1–#10 are resolved.) diff --git a/docs/specs/terminal-state.md b/docs/specs/terminal-state.md index ed29d70..9eadc21 100644 --- a/docs/specs/terminal-state.md +++ b/docs/specs/terminal-state.md @@ -152,6 +152,8 @@ Title candidate diagnostics: | `OSC 99 ; ... title/body ... ST` | `osc99` | No | | `OSC 777 ; notify ; <title> ; <body> ST` | `osc777` | No | +Only the OSC 9 *message* form (`OSC 9 ; <message>`) feeds the title channel. The OSC 9 *progress* form (`OSC 9 ; 4 ; <state> ; <progress>`) carries no text payload and does not contribute a title candidate; its semantics are documented in `docs/specs/alert.md`. + Non-OSC title source: - `user` — user-pinned title set via the inline rename UI (`setTerminalUserTitle`). Always wins over every other candidate. From 875a41baa824d2f3217cfe581a123726fc19bb37 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Sat, 9 May 2026 16:31:26 -0700 Subject: [PATCH 47/50] Strip OSC 50/52 at parser; preserve progress on attended notification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Parser now consumes known unsupported iTerm2/clipboard OSCs (`OSC 50`, `OSC 52`) instead of forwarding them to xterm.js, matching the fail-inertly rule. - AlertManager no longer clears active OSC 9;4 progress when an attended Session receives a separate direct notification — the notification is suppressed but the progress cycle keeps running. - Spec updates: alert.md adds explicit IDLE→ALERT_RINGING progress completion/error transitions, switches the attended-direct-notification cell to "unchanged", and clarifies that "the visual alert track is disabled" only blocks visual rings. layout.md aligns ActivityState `todo` to `boolean`. terminal-state.md hands unsupported-OSC pass-through rules to OSC.md. transport.md updates `alert:toggleTodo`/`markTodo` wording from soft/hard to boolean. OSC.md notes that listed iTerm2 sequences are consumed (not forwarded) and that unknown non-iTerm2 OSCs still pass through. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- docs/specs/OSC.md | 8 ++++---- docs/specs/alert.md | 10 +++++++--- docs/specs/layout.md | 2 +- docs/specs/terminal-state.md | 2 +- docs/specs/transport.md | 4 ++-- lib/src/lib/alert-manager.test.ts | 16 ++++++++++++++++ lib/src/lib/alert-manager.ts | 5 +---- lib/src/lib/terminal-protocol.test.ts | 8 ++++++++ lib/src/lib/terminal-protocol.ts | 12 ++++++++++++ 9 files changed, 52 insertions(+), 15 deletions(-) diff --git a/docs/specs/OSC.md b/docs/specs/OSC.md index 4bed5f4..fc80831 100644 --- a/docs/specs/OSC.md +++ b/docs/specs/OSC.md @@ -15,7 +15,7 @@ Supported OSCs are parsed at the PTY data boundary in the platform adapter: - VS Code: in the extension host (`message-router.ts` / `pty-manager.ts`), before `pty:data` is forwarded to the webview. - Standalone and fake adapters: in the frontend adapter, before xterm.js sees the bytes. -After parsing, supported sequences are consumed and not re-emitted. The platform sends two streams to the webview: +After parsing, supported sequences are consumed and not re-emitted. Known unsupported iTerm2/clipboard-capable OSCs listed in [Known-unimplemented iTerm2 sequences](#known-unimplemented-iterm2-sequences) are also consumed and ignored. The platform sends two streams to the webview: - `pty:data` — terminal output with supported OSCs already parsed/stripped. Feeds xterm.js. - `terminal:semanticEvents` — normalized semantic events parsed in the platform (CWD, prompt/command boundaries, titles). Feeds `TerminalPaneState`. @@ -28,7 +28,7 @@ The parser also classifies each PTY data chunk for activity-monitor purposes: - A chunk that contains only notification/progress OSCs after parsing must not be fed to the activity monitor's `onData()` as generic meaningful output. - A chunk that contains visible output plus notification/progress OSCs still counts visible output as activity. -Unknown OSC sequences pass through to xterm.js unchanged (and are then ignored by xterm.js if it does not recognize them — see the iTerm2-identity fail-inertly rule below). +Unknown non-iTerm2 OSC families pass through to xterm.js unchanged so xterm.js can handle standard terminal behavior MouseTerm does not model. Security-sensitive or iTerm2-identity-triggered OSCs must not rely on xterm.js defaults: if they are not in [Supported OSCs](#supported-oscs), MouseTerm consumes and ignores them without visible terminal garbage, clipboard access, file access, focus changes, or other side effects. ## Supported OSCs @@ -72,7 +72,7 @@ Because this identity can cause tools to emit more iTerm2 escape codes than Mous ## Known-unimplemented iTerm2 sequences -MouseTerm intentionally does not implement the following iTerm2 OSC sequences. They must fail inertly per the rule above. +MouseTerm intentionally does not implement the following iTerm2 OSC sequences. They must fail inertly per the rule above, which means they are consumed/ignored rather than forwarded to xterm.js. | Sequence | Purpose | Reason for non-support | |---|---|---| @@ -87,7 +87,7 @@ MouseTerm intentionally does not implement the following iTerm2 OSC sequences. T | `OSC 50 ; <font> ST` | Set font dynamically | Font is host-controlled. | | `OSC 52 ; <selection> ; <data> ST` | Programmatic clipboard write | Security: same rationale as `CopyToClipboard`. | -This list is non-exhaustive. Any iTerm2 OSC not in the [Supported OSCs](#supported-oscs) table is ignored. +This list is non-exhaustive. Any iTerm2-compatibility OSC family that MouseTerm can identify and that is not in the [Supported OSCs](#supported-oscs) table is ignored. ## References diff --git a/docs/specs/alert.md b/docs/specs/alert.md index 3ac61f0..a81b8da 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -250,9 +250,13 @@ These transition rules apply to the visual track only. `OSC_NOTIF_BUSY` is not e | `OSC_NOTIF_BUSY` | terminal report errors progress and Session lacks attention | `ALERT_RINGING` | Create error `notification`, set `todo = true`, and ring. | | `OSC_NOTIF_BUSY` | terminal report errors progress and Session has attention | `IDLE` | User already sees it; do not ring or create TODO. | | `OSC_NOTIF_BUSY` | Session destroyed | `IDLE` | Session teardown clears protocol state. | +| `IDLE` | explicit progress completion report (`OSC 9;4;1;100`) and Session lacks attention | `ALERT_RINGING` | Create generated completion `notification`, set `todo = true`, and ring. | +| `IDLE` | explicit progress completion report (`OSC 9;4;1;100`) and Session has attention | `IDLE` | User already sees it; do not ring or create TODO. | +| `IDLE` | explicit progress error report (`OSC 9;4;2`) and Session lacks attention | `ALERT_RINGING` | Create generated error `notification`, set `todo = true`, and ring. | +| `IDLE` | explicit progress error report (`OSC 9;4;2`) and Session has attention | `IDLE` | User already sees it; do not ring or create TODO. | | `ALERT_RINGING` | explicit attention boundary / dismiss / TODO clear | `IDLE` | Public status falls back to visual projection after protocol ring clears. | | any | direct notification (`OSC 9`, completed `OSC 99`, `OSC 777`, standalone `BEL`) and Session lacks attention | `ALERT_RINGING` | Create `notification`, set `todo = true`, and ring immediately. | -| any | direct notification (`OSC 9`, completed `OSC 99`, `OSC 777`, standalone `BEL`) and Session has attention | `IDLE` | User already sees it; do not ring or create TODO. | +| any | direct notification (`OSC 9`, completed `OSC 99`, `OSC 777`, standalone `BEL`) and Session has attention | unchanged | User already sees it; suppress that notification only. Do not create TODO, and do not clear unrelated active progress. | `OSC_NOTIF_BUSY` never auto-rings because of silence. If a program starts progress and never sends completion/error, MouseTerm remains cocked until another terminal report completes/errors the progress cycle or the Session is destroyed. @@ -270,7 +274,7 @@ The implementation may later learn additional suppressions, but this spec only r Protocol notifications and standalone terminal bells are explicit application requests for attention. They bypass the normal opt-in activity monitor: a Session may ring even when its alert toggle was disabled. They must not ring while the user is actively attending that Session. -Progress sequences do not ring immediately. They "cock" the alarm bell — MouseTerm treats active progress as an explicit finite-work cycle, exposes `OSC_NOTIF_BUSY`, and rings when the cycle completes or enters an error state. +Active/in-progress progress sequences do not ring immediately. They "cock" the alarm bell — MouseTerm treats active progress as an explicit finite-work cycle and exposes `OSC_NOTIF_BUSY`. Explicit completion/error progress reports may ring immediately when the Session lacks attention. The OSC sequence registry, parser placement, and stripping behavior live in `docs/specs/OSC.md`. This section defines per-protocol semantics for the five supported notification sources. @@ -473,7 +477,7 @@ Implementation surface inside `AlertManager`: - the Session already has attention at the moment it would otherwise enter `ALERT_RINGING` - the Session is merely re-rendered or reattached while already `ALERT_RINGING` - the only recent output was resize noise already ignored by the completion detector -- the visual alert track is disabled (`visualStatus === 'ALERT_DISABLED'`) +- for visual/activity-monitor rings only: the visual alert track is disabled (`visualStatus === 'ALERT_DISABLED'`) This "fresh transition into `ALERT_RINGING` only" rule is critical. It prevents duplicate alerts on remount, theme change, or Pane <-> Door movement. diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 35064ec..c6ca772 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -287,7 +287,7 @@ On startup, recovery is priority-based: ### Activity state -Each session carries `ActivityState` with `status: SessionStatus`, `todo: TodoState`, and `notification: ActivityNotification | null`. `status` is the projected public status from the timer-based visual track plus the terminal-report protocol track described in `docs/specs/alert.md`; it may be `OSC_NOTIF_BUSY` when OSC progress has cocked the bell. These are synced to React via `useSyncExternalStore`. State that arrives from the platform before a registry entry exists (resume scenario) is held as "primed state" and applied when the registry entry is created. +Each session carries `ActivityState` with `status: SessionStatus`, `todo: boolean`, and `notification: ActivityNotification | null`. `status` is the projected public status from the timer-based visual track plus the terminal-report protocol track described in `docs/specs/alert.md`; it may be `OSC_NOTIF_BUSY` when OSC progress has cocked the bell. These are synced to React via `useSyncExternalStore`. State that arrives from the platform before a registry entry exists (resume scenario) is held as "primed state" and applied when the registry entry is created. Each session also carries `TerminalPaneState` from `docs/specs/terminal-state.md`. The frontend store is keyed by the current pane/session id, and PTY-originated semantic events are resolved through `ptyId` so swapped sessions keep their CWD and command state with the terminal content. diff --git a/docs/specs/terminal-state.md b/docs/specs/terminal-state.md index 9eadc21..8ac9bb0 100644 --- a/docs/specs/terminal-state.md +++ b/docs/specs/terminal-state.md @@ -158,7 +158,7 @@ Non-OSC title source: - `user` — user-pinned title set via the inline rename UI (`setTerminalUserTitle`). Always wins over every other candidate. -The parser accepts both BEL and ST terminators and handles split chunks. Unsupported OSCs pass through to xterm unchanged; supported-but-malformed semantic OSCs are consumed without changing state. +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/OSC.md`. ## Reducer diff --git a/docs/specs/transport.md b/docs/specs/transport.md index d5e56ce..525fa10 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -88,8 +88,8 @@ All types live in `vscode-ext/src/message-types.ts` (the canonical schema; other | `alert:remove` | Remove alert state entirely | | `alert:resize` | Notify alert of terminal resize (debounce noise) | | `alert:clearAttention` | Clear attention timer | -| `alert:toggleTodo` | Toggle TODO (false ↔ hard) | -| `alert:markTodo` | Set hard TODO | +| `alert:toggleTodo` | Toggle TODO (`false` ↔ `true`) | +| `alert:markTodo` | Set TODO to `true` | | `alert:clearTodo` | Remove TODO | **Host → webview:** diff --git a/lib/src/lib/alert-manager.test.ts b/lib/src/lib/alert-manager.test.ts index 5ab20fc..ebc98d0 100644 --- a/lib/src/lib/alert-manager.test.ts +++ b/lib/src/lib/alert-manager.test.ts @@ -271,6 +271,22 @@ describe('AlertManager in isolation', () => { }); }); + it('attended direct notifications do not clear active protocol progress', () => { + const id = 'osc-progress-with-attended-notification'; + + manager.updateProtocolProgress(id, { state: 'normal', percent: 25 }); + expect(manager.getState(id).status).toBe('OSC_NOTIF_BUSY'); + + manager.attend(id); + manager.notifyFromProtocol(id, { source: 'OSC 777', title: 'done', body: 'Build finished' }); + + expect(manager.getState(id)).toMatchObject({ + status: 'OSC_NOTIF_BUSY', + todo: false, + notification: null, + }); + }); + it('terminal bell notifications are suppressed while the user has attention', () => { const id = 'terminal-bell-attention'; diff --git a/lib/src/lib/alert-manager.ts b/lib/src/lib/alert-manager.ts index ae69ebc..a05300d 100644 --- a/lib/src/lib/alert-manager.ts +++ b/lib/src/lib/alert-manager.ts @@ -133,10 +133,7 @@ export class AlertManager { const normalized = normalizeActivityNotification(notification); if (!normalized) return; - if (this.hasAttention(id)) { - if (this.clearProtocolProgress(entry)) this.notify(id); - return; - } + if (this.hasAttention(id)) return; this.setProtocolRinging(id, entry, normalized); } diff --git a/lib/src/lib/terminal-protocol.test.ts b/lib/src/lib/terminal-protocol.test.ts index 3795e48..3d74113 100644 --- a/lib/src/lib/terminal-protocol.test.ts +++ b/lib/src/lib/terminal-protocol.test.ts @@ -150,6 +150,14 @@ describe('TerminalProtocolParser', () => { expect(result.events).toEqual([]); }); + it('strips known unsupported iTerm2 and clipboard OSC sequences', () => { + const parser = new TerminalProtocolParser(); + const result = parser.process('a\x1b]52;c;SGVsbG8=\x07b\x1b]50;Monaco\x07c'); + + expect(result.visibleData).toBe('abc'); + expect(result.events).toEqual([]); + }); + it('parses and strips CWD OSC sequences into semantic events', () => { const parser = new TerminalProtocolParser(); const result = parser.process('a\x1b]7;file://prod-box/home/me/project\x1b\\b\x1b]9;9;C:\\repo\x07c'); diff --git a/lib/src/lib/terminal-protocol.ts b/lib/src/lib/terminal-protocol.ts index f78fbdc..a620a86 100644 --- a/lib/src/lib/terminal-protocol.ts +++ b/lib/src/lib/terminal-protocol.ts @@ -108,6 +108,7 @@ export class TerminalProtocolParser { if (content === '2' || content.startsWith('2;')) return parseOscTitle(content, 'osc2'); if (content === '99' || content.startsWith('99;')) return this.parseOsc99(content); if (content === '777' || content.startsWith('777;')) return this.parseOsc777(content); + if (isKnownUnsupportedIterm2Osc(content)) return []; return null; } @@ -408,6 +409,17 @@ function parseOscTitle(content: string, source: TerminalTitle['source']): Termin }]; } +function isKnownUnsupportedIterm2Osc(content: string): boolean { + // Security-sensitive iTerm2 compatibility OSCs are consumed rather than + // forwarded to xterm.js. In particular, OSC 52 is a clipboard-write channel. + return ( + content === '50' || + content.startsWith('50;') || + content === '52' || + content.startsWith('52;') + ); +} + function commandStartEvent(source: CommandRunSource): TerminalProtocolEvent { return { kind: 'semantic', event: { type: 'commandStart', source } }; } From fc5f5cb312307b29bffeb5a535333809ea4673c9 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Sat, 9 May 2026 16:32:12 -0700 Subject: [PATCH 48/50] Note dual-purpose OSCs and tighten file-location wording - OSC.md: add a note after the supported-OSCs table that `OSC 9 ; <message>`, `OSC 99`, and `OSC 777` also feed the title-candidate channel in terminal-state.md, and clarify that only the OSC 9 message form can actually become a header label. - OSC.md: rename "Known-unimplemented iTerm2 sequences" to "Known-unimplemented iTerm2 and clipboard-capable sequences" so the section title matches the inclusion of `OSC 50` and `OSC 52`, which are xterm extensions rather than iTerm2-proprietary. - transport.md: scope the "All types live in `vscode-ext/src/message-types.ts`" claim to message types only, and call out that `PersistedSession` and friends live in `lib/src/lib/session-types.ts`. - AGENTS.md: update the OSC.md description to match the renamed section. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- AGENTS.md | 2 +- docs/specs/OSC.md | 8 +++++--- docs/specs/transport.md | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6afe2e7..1b510d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ The primary job of a spec is to be an accurate reference for the current state o - **`docs/specs/layout.md`** — Tiling layout, pane/door containers, dockview configuration, modes (passthrough/command), keyboard shortcuts, selection overlay, spatial navigation, minimize/reattach, inline rename, session lifecycle, session persistence, and theming. Read this when touching: `Wall.tsx`, `Baseboard.tsx`, `Door.tsx`, `TerminalPane.tsx`, `spatial-nav.ts`, `layout-snapshot.ts`, `terminal-registry.ts`, `session-save.ts`, `session-restore.ts`, `reconnect.ts`, `index.css`, `theme.css`, or any keyboard/navigation/mode behavior. - **`docs/specs/alert.md`** — Activity monitoring state machine, alert trigger/clearing rules, attention model, TODO lifecycle, bell button visual states and interaction, door alert indicators, hardening (a11y, motion, i18n, overflow), notification protocols (`OSC 9` / `OSC 9;4` / `OSC 99` / `OSC 777` / `BEL`), the `ActivityNotification` model, notification text handling and security, and the notification preview/detail UI. Read this when touching: `activity-monitor.ts`, `alert-manager.ts`, `AlertManager` notification/progress paths, the alert bell or TODO pill in `Wall.tsx` (TerminalPaneHeader), alert indicators in `Door.tsx`, the `a`/`t` keyboard shortcuts, or TODO notification preview UI. Layout.md defers to this spec for all alert/TODO behavior. - **`docs/specs/terminal-state.md`** — Terminal semantic state for CWD, shell prompt/editing/running/finished lifecycle, command runs, terminal title fallback, normalized semantic OSC events (`OSC 7`, `OSC 9;9`, `OSC 133`, `OSC 633`, `OSC 1337`, `OSC 0/2`), title-candidate diagnostics, header derivation, and grouping keys. Read this when touching `terminal-state.ts`, `terminal-state-store.ts`, semantic event parsing in `terminal-protocol.ts`, adapter semantic event forwarding, or derived pane/door labels. -- **`docs/specs/OSC.md`** — Registry of every supported OSC sequence with pointers to the spec defining its behavior (alert.md or terminal-state.md), the canonical parsing-location and `pty:data` strip semantics, iTerm2 self-identification (env vars, `CSI > q` response, fail-inertly rule), and known-unimplemented iTerm2 sequences. Read this when touching: OSC parsing at the PTY data boundary, the iTerm2 identity env vars (`TERM_PROGRAM`, `LC_TERMINAL`), or adding support for a new OSC sequence. +- **`docs/specs/OSC.md`** — Registry of every supported OSC sequence with pointers to the spec defining its behavior (alert.md or terminal-state.md), the canonical parsing-location and `pty:data` strip semantics, iTerm2 self-identification (env vars, `CSI > q` response, fail-inertly rule), and known-unimplemented iTerm2 and clipboard-capable sequences. Read this when touching: OSC parsing at the PTY data boundary, the iTerm2 identity env vars (`TERM_PROGRAM`, `LC_TERMINAL`), or adding support for a new OSC sequence. - **`docs/specs/transport.md`** — Adapter-agnostic protocol shared across VS Code, standalone, and fake adapters: PTY lifecycle (decoupled from webview), `replayChunks`/`scrollbackChunks` buffering, reconnection sequence (`mouseterm:init` → `pty:list` + `pty:replay`), the full webview ↔ host message protocol, persisted-session types, and universal invariants (shell login args, scrollback trailing newline, replay drop-replies-only). Read this when touching: `pty-manager.ts`, `pty-host.js`, `pty-core.js`, `message-router.ts`, `message-types.ts`, `vscode-adapter.ts`, `fake-adapter.ts`, `reconnect.ts`, `session-save.ts`, `session-restore.ts`, `session-types.ts`, or any code crossing the webview/host boundary. - **`docs/specs/vscode.md`** — VS Code-specific layer: hosting modes (WebviewView + WebviewPanel), extension manifest, VS Code persistence flow (`workspaceState`, `vscode.setState`, `WebviewPanelSerializer`, deactivate ordering, `mergeAlertStates` rule, `retainContextWhenHidden`), theme integration (`--vscode-*` → `--color-*` with the runtime resolver), CSP, build pipeline, and dream-architecture commands. The transport protocol it speaks (PTY lifecycle, message protocol, persisted-session types) lives in `transport.md`. Read this when touching: `extension.ts`, `webview-view-provider.ts`, `session-state.ts`, `webview-html.ts`, the theme resolver/observer in `terminal-theme.ts`, or VS Code commands and context keys. - **`docs/specs/tutorial.md`** — Playground tutorial on the website: 3-pane layout, interactive `tut` TUI runner with three sections (keyboard navigation, alerts/TODOs, copy/paste), per-item detection wired to `WallEvent` / activity store / mouse-selection store, single-key `mouseterm-tut-v3` localStorage scheme, theme picker, and FakePtyAdapter extensions (`sendOutput`, `pumpActivity`, `setInputHandler`). Read this when touching: `website/src/pages/Playground.tsx`, `website/src/lib/tut-runner.ts`, `website/src/lib/tut-detector.ts`, `website/src/lib/tutorial-state.ts`, `website/src/lib/tut-items.ts`, `website/src/lib/tutorial-shell.ts`, `lib/src/components/ThemePicker.tsx`, `lib/src/lib/themes/`, `lib/src/lib/platform/fake-scenarios.ts` (tutorial scenarios), the `WallEvent` union, or the `onApiReady`/`onEvent`/`initialPaneIds` props on Wall. diff --git a/docs/specs/OSC.md b/docs/specs/OSC.md index fc80831..7673ed9 100644 --- a/docs/specs/OSC.md +++ b/docs/specs/OSC.md @@ -15,7 +15,7 @@ Supported OSCs are parsed at the PTY data boundary in the platform adapter: - VS Code: in the extension host (`message-router.ts` / `pty-manager.ts`), before `pty:data` is forwarded to the webview. - Standalone and fake adapters: in the frontend adapter, before xterm.js sees the bytes. -After parsing, supported sequences are consumed and not re-emitted. Known unsupported iTerm2/clipboard-capable OSCs listed in [Known-unimplemented iTerm2 sequences](#known-unimplemented-iterm2-sequences) are also consumed and ignored. The platform sends two streams to the webview: +After parsing, supported sequences are consumed and not re-emitted. Known unsupported iTerm2/clipboard-capable OSCs listed in [Known-unimplemented iTerm2 and clipboard-capable sequences](#known-unimplemented-iterm2-and-clipboard-capable-sequences) are also consumed and ignored. The platform sends two streams to the webview: - `pty:data` — terminal output with supported OSCs already parsed/stripped. Feeds xterm.js. - `terminal:semanticEvents` — normalized semantic events parsed in the platform (CWD, prompt/command boundaries, titles). Feeds `TerminalPaneState`. @@ -49,6 +49,8 @@ Unknown non-iTerm2 OSC families pass through to xterm.js unchanged so xterm.js c | `OSC 777 ; notify ; <title> ; <body> ST` | rxvt/WezTerm notification | [alert.md](alert.md#osc-777) | | `OSC 1337 ; CurrentDir=<cwd> ST` | CWD (iTerm2 compatibility) | [terminal-state.md](terminal-state.md#supported-osc-inputs) | +Some sequences are dual-purpose. The notification rows for `OSC 9 ; <message> ST`, `OSC 99` (`p=title`/`p=body`), and `OSC 777 ; notify` also feed the title-candidate channel in `terminal-state.md` — see its [Title candidate diagnostics](terminal-state.md#supported-osc-inputs) table. Only the OSC 9 *message* form can become a header/door label; OSC 99 and OSC 777 candidates are stored for the diagnostic popup only. The OSC 9 *progress* form (`OSC 9 ; 4`) carries no text and never contributes a title candidate. + ## iTerm2 identity MouseTerm reports an iTerm2-compatible identity so that tools (shells, build systems, agent clients) emit the iTerm2-style escape codes that this spec set supports. @@ -70,9 +72,9 @@ Device/version query: Because this identity can cause tools to emit more iTerm2 escape codes than MouseTerm implements, **unsupported escape codes must fail inertly**: consume or ignore them without visible terminal garbage, privilege escalation, clipboard access, file access, or focus stealing. -## Known-unimplemented iTerm2 sequences +## Known-unimplemented iTerm2 and clipboard-capable sequences -MouseTerm intentionally does not implement the following iTerm2 OSC sequences. They must fail inertly per the rule above, which means they are consumed/ignored rather than forwarded to xterm.js. +MouseTerm intentionally does not implement the following sequences. They are mostly iTerm2-proprietary; `OSC 50` (font) and `OSC 52` (clipboard) are standard xterm extensions included here because the iTerm2 identity prompts tools to emit them and they have security implications. All of them must fail inertly per the rule above, which means they are consumed/ignored rather than forwarded to xterm.js. | Sequence | Purpose | Reason for non-support | |---|---|---| diff --git a/docs/specs/transport.md b/docs/specs/transport.md index 525fa10..75bd49b 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -64,7 +64,7 @@ For cold restore (no live PTYs), the webview falls back to saved session state: ## Message protocol -All types live in `vscode-ext/src/message-types.ts` (the canonical schema; other adapters import or mirror it). Webview-side handling lives in adapter modules (e.g., `vscode-adapter.ts`, `fake-adapter.ts`); host-side handling lives in the per-adapter message router. +Message types live in `vscode-ext/src/message-types.ts` (the canonical schema; other adapters import or mirror it). The persisted-session types in the next section live in `lib/src/lib/session-types.ts` because they cross the webview/host boundary and are also consumed by frontend persistence helpers. Webview-side handling lives in adapter modules (e.g., `vscode-adapter.ts`, `fake-adapter.ts`); host-side handling lives in the per-adapter message router. **Webview → host:** From 75da7a271d825532fea4fa7dc215d6094ee89989 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Sat, 9 May 2026 16:40:38 -0700 Subject: [PATCH 49/50] Show `<idle> ${LAST_TITLE}` in headers after a command finishes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plain `<idle>` after completion threw away useful context — the user couldn't see at a glance which program just exited. Now finished panes show `<idle> ${LAST_TITLE}`, where LAST_TITLE follows the same priority as the running header (app-sent OSC 0/2/9 title that was active during the run, falling back to displayCommand) but resolved against `lastCommand` instead of `currentCommand`. Implementation: - Snapshot the in-run OSC 0/2/9 title on `commandFinish` into `CommandRun.finalTerminalTitle`. Without this, a post-finish title event (e.g. the shell resetting OSC 0 to `zsh`) would overwrite the in-run title in `titleCandidates` and we'd lose it. - `headerPrimary` now branches on `lastCommand` after `currentCommand`: it computes the same label via the unified `commandHeaderLabel` helper, then prefixes `<idle> `. - `<idle> ${LAST_TITLE}` persists across subsequent prompt/editing transitions until a new `commandStart` replaces it; only a fresh pane (no `lastCommand` at all) shows plain `<idle>`. Spec: terminal-state.md updates the header priority chain, documents `finalTerminalTitle`, and explains the persistence rule. layout.md refreshes its summary of the priority chain to mention `<idle> ${LAST_TITLE}`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- docs/specs/layout.md | 2 +- docs/specs/terminal-state.md | 22 +++++-- lib/src/lib/terminal-state.test.ts | 51 ++++++++++++++- lib/src/lib/terminal-state.ts | 99 ++++++++++++++++++++---------- 4 files changed, 133 insertions(+), 41 deletions(-) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index c6ca772..706b2e8 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -72,7 +72,7 @@ The content area is a tiling layout of panes, powered by dockview. Each pane occ Each pane has a 30px header that doubles as a drag handle. The header uses `cursor-grab` / `active:cursor-grabbing`, `select-none`, and the shared terminal top radius from `lib/src/components/design.tsx`. Background and foreground use the `--color-header-active-*` / `--color-header-inactive-*` token pairs, which map to VSCode file-tree list colors. Dockview's default close button and right-actions container are hidden via CSS. -The header label is the `DerivedHeader` returned by `deriveHeader(paneState, visiblePanes)` in `docs/specs/terminal-state.md` — that spec is the single source of truth for the priority chain (user-pinned title, app-sent overrides, command title, `<idle>`), the disambiguator rule, and which OSC sources contribute. Layout's job is to render the result: the primary label truncates with ellipsis, the secondary label (when present) is shown muted next to it, click renames/pins, right-click opens the diagnostic popup. +The header label is the `DerivedHeader` returned by `deriveHeader(paneState, visiblePanes)` in `docs/specs/terminal-state.md` — that spec is the single source of truth for the priority chain (user-pinned title, app-sent overrides, current command title, `<idle> ${LAST_TITLE}` for finished panes, plain `<idle>` for fresh panes), the disambiguator rule, and which OSC sources contribute. Layout's job is to render the result: the primary label truncates with ellipsis, the secondary label (when present) is shown muted next to it, click renames/pins, right-click opens the diagnostic popup. The diagnostic popup lists the latest entry per `titleCandidates` channel as defined in `docs/specs/terminal-state.md`. Each row shows the channel, latest candidate text, and timestamp. The popup is diagnostic only; it does not change the title priority rules. diff --git a/docs/specs/terminal-state.md b/docs/specs/terminal-state.md index 8ac9bb0..faa855a 100644 --- a/docs/specs/terminal-state.md +++ b/docs/specs/terminal-state.md @@ -69,6 +69,13 @@ type CommandRun = { | "osc633_boundaries" | "osc133_boundaries" | "user_input"; + /** + * App-sent OSC 0/2/9 title that was active when this command finished. Snapshotted by + * `commandFinish` so post-finish title events (e.g. the shell resetting OSC 0 to `zsh`) do + * not overwrite the in-run title we want to show as `<idle> ${LAST_TITLE}`. + * Only set on finished commands. + */ + finalTerminalTitle?: TerminalTitle; outputRange?: { startMarkId?: string; endMarkId?: string; @@ -171,7 +178,7 @@ The parser accepts both BEL and ST terminators and handles split chunks. Support - User-entered prompt input may also store `pendingCommandLine` as an explicit fallback before an OSC 133/633 command-start boundary. This fallback is only used while the shell is idle/editing; foreground-program input is ignored. If the submitted line is non-empty, the input fallback may create a `currentCommand` immediately with `source: "user_input"` so shells without command-start integration still show the active command. - The typed-command fallback resolves the current Session id from the PTY id before recording input or prompt-looking output, so drag-to-swap moves the fallback state with the visible pane. - `commandStart` creates `currentCommand`, snapshots `cwdAtStart`, uses `event.startedAt` when present, clears `pendingCommandLine`, and sets `{ kind: "running" }`. -- `commandFinish` moves `currentCommand` to `lastCommand`, stores `finishedAt`/`exitCode`, clears `currentCommand`, and sets `{ kind: "finished", exitCode }`. +- `commandFinish` moves `currentCommand` to `lastCommand`, stores `finishedAt`/`exitCode`, snapshots the latest in-run OSC 0/2/9 title into `lastCommand.finalTerminalTitle` (titles older than `startedAt` or younger than `finishedAt` are excluded), clears `currentCommand`, and sets `{ kind: "finished", exitCode }`. - `title` updates `title` and the per-source entry in `titleCandidates`. Later OSC title events do not erase earlier user, shell, or notification channel candidates from other sources. - A later prompt signal moves the pane out of `finished`. If a command was started from `user_input` and no explicit `commandFinish` arrived, the prompt signal also clears `currentCommand` so the header returns to `<idle>`. - For `user_input` fallback commands only, visible output that looks like a returned shell prompt may synthesize the same prompt transition. This is a scoped fallback for shells that do not emit command finish/start OSCs. @@ -199,14 +206,17 @@ The header carries only the primary label and an optional secondary disambiguato Header priority — first match wins: 1. User-pinned title. -2. While a command is running: +2. While a command is running (`currentCommand` is set): - App-sent title override emitted after the current command started — legacy `OSC 9` message text or `OSC 0`/`OSC 2` terminal title. - `currentCommand.displayCommand`. -3. Otherwise (no running command — `prompt`, `editing`, `finished`, `unknown`): `<idle>`. +3. After a command has finished (`currentCommand` is null and `lastCommand` is set): `<idle> ${LAST_TITLE}`, where `LAST_TITLE` follows the same priority as the running case applied to `lastCommand`: + - App-sent title override that was emitted between `lastCommand.startedAt` and `lastCommand.finishedAt`. The candidate is taken from `lastCommand.finalTerminalTitle` (snapshotted at finish) so a post-finish title event cannot overwrite it. + - `lastCommand.displayCommand`. +4. Otherwise (no running command and no last command): `<idle>`. -Rich notification titles from `OSC 99` and `OSC 777` are stored in `titleCandidates` for the diagnostic popup but never become header/door labels. Older shell titles (terminal titles emitted before the current command started, or with no command running) remain fallback-only and do not replace `<idle>`. +Rich notification titles from `OSC 99` and `OSC 777` are stored in `titleCandidates` for the diagnostic popup but never become header/door labels. Older shell titles (terminal titles emitted before the current command started, or after the last command finished) remain fallback-only and do not replace `<idle>` or pollute `LAST_TITLE`. -A freshly finished command shows `<idle>`. The just-finished command's exit code, output, and TODO notification are surfaced via the alert/TODO machinery (`docs/specs/alert.md`); the header itself returns to a peaceful idle state. `lastCommand` is still tracked for grouping and for callers that want exit-code metadata, but it does not appear in the header. +`<idle> ${LAST_TITLE}` keeps the just-finished context visible so the user can see at a glance which program just exited. Exit code, output, and TODO notification are still surfaced via the alert/TODO machinery (`docs/specs/alert.md`); the header itself stays peaceful but informative. `<idle> ${LAST_TITLE}` persists across subsequent prompt/editing transitions until a new `commandStart` replaces it; only a fresh pane (no `lastCommand` at all) shows plain `<idle>`. Disambiguation: @@ -240,7 +250,7 @@ Status grouping projects `ShellActivity.kind` (5 values) onto 4 buckets: | `running` | `running` | | `finished` | `finished` | -`prompt` and `editing` collapse into a single `idle` bucket because the user-visible distinction between "at the prompt" and "typing a command" is not load-bearing for grouping. `finished` stays distinct so a recently-completed pane can be filtered separately even though its header label is `<idle>`. +`prompt` and `editing` collapse into a single `idle` bucket because the user-visible distinction between "at the prompt" and "typing a command" is not load-bearing for grouping. `finished` stays distinct so a recently-completed pane can be filtered separately even though its header label has the same `<idle>` prefix as plain idle panes. Directory group keys use `cwdIdentity(cwd)` so remote hosts and Windows/POSIX path kinds remain distinct. Windows UNC display labels keep `\\server\share\` as the path root and do not repeat the server/share in the trailing path segments. diff --git a/lib/src/lib/terminal-state.test.ts b/lib/src/lib/terminal-state.test.ts index 5be0713..6dd6027 100644 --- a/lib/src/lib/terminal-state.test.ts +++ b/lib/src/lib/terminal-state.test.ts @@ -172,12 +172,12 @@ describe('terminal command state reducer', () => { state = reduceTerminalState(state, { type: 'commandFinish', exitCode: 0 }, { now: () => 3 }); expect(state.activity).toEqual({ kind: 'finished', exitCode: 0 }); expect(deriveHeader(state, [state])).toEqual({ - primary: DEFAULT_IDLE_TITLE, + primary: `${DEFAULT_IDLE_TITLE} lazygit`, }); state = reduceTerminalState(state, { type: 'promptStart' }); expect(deriveHeader(state, [state])).toEqual({ - primary: DEFAULT_IDLE_TITLE, + primary: `${DEFAULT_IDLE_TITLE} lazygit`, }); }); @@ -341,6 +341,53 @@ describe('header and grouping derivation', () => { }); }); + it('shows `<idle> ${displayCommand}` after a command finishes', () => { + const running = runningPane('/repo/app', 'npm run build'); + const finished = reduceTerminalState(running, { type: 'commandFinish', exitCode: 0 }, { now: () => 2 }); + + expect(finished.lastCommand?.displayCommand).toBe('npm run build'); + expect(deriveHeader(finished, [finished])).toEqual({ + primary: `${DEFAULT_IDLE_TITLE} npm run build`, + }); + + const afterPrompt = reduceTerminalState(finished, { type: 'promptStart' }); + expect(deriveHeader(afterPrompt, [afterPrompt])).toEqual({ + primary: `${DEFAULT_IDLE_TITLE} npm run build`, + }); + }); + + it('uses the in-run app-sent title as `<idle> ${LAST_TITLE}`', () => { + let pane = runningPane('/repo/app', 'lazygit'); + pane = reduceTerminalState(pane, { type: 'title', title: { title: 'lazygit: mouseterm', source: 'osc0', updatedAt: 2 } }); + pane = reduceTerminalState(pane, { type: 'commandFinish', exitCode: 0 }, { now: () => 3 }); + + expect(deriveHeader(pane, [pane])).toEqual({ + primary: `${DEFAULT_IDLE_TITLE} lazygit: mouseterm`, + }); + }); + + it('ignores titles emitted after the last command finished when deriving LAST_TITLE', () => { + let pane = runningPane('/repo/app', 'lazygit'); + pane = reduceTerminalState(pane, { type: 'title', title: { title: 'lazygit: mouseterm', source: 'osc0', updatedAt: 2 } }); + pane = reduceTerminalState(pane, { type: 'commandFinish', exitCode: 0 }, { now: () => 3 }); + // Shell sets the title back to its default after the command exits. + pane = reduceTerminalState(pane, { type: 'title', title: { title: 'zsh', source: 'osc0', updatedAt: 4 } }); + + expect(deriveHeader(pane, [pane])).toEqual({ + primary: `${DEFAULT_IDLE_TITLE} lazygit: mouseterm`, + }); + }); + + it('keeps a user-pinned title primary even after a command finishes', () => { + let pane = runningPane('/repo/app', 'npm run build'); + pane = reduceTerminalState(pane, { type: 'title', title: { title: 'build pane', source: 'user', updatedAt: 2 } }); + pane = reduceTerminalState(pane, { type: 'commandFinish', exitCode: 0 }, { now: () => 3 }); + + expect(deriveHeader(pane, [pane])).toEqual({ + primary: 'build pane', + }); + }); + it('preserves remote identity when two panes have the same path', () => { const local = runningPane('/home/me/app', 'npm run dev', 'localhost'); const remote = runningPane('/home/me/app', 'npm run dev', 'prod-box'); diff --git a/lib/src/lib/terminal-state.ts b/lib/src/lib/terminal-state.ts index 9a1ccda..b0c44c6 100644 --- a/lib/src/lib/terminal-state.ts +++ b/lib/src/lib/terminal-state.ts @@ -34,6 +34,13 @@ export interface CommandRun { finishedAt?: number; exitCode?: number; source: CommandRunSource; + /** + * App-sent title (OSC 0 / 2 / 9) that was active when this command finished, snapshotted by + * `commandFinish` so post-finish title events (e.g. the shell resetting the title to `zsh`) + * do not overwrite the in-run title we want to show in the `<idle> ${LAST_TITLE}` header. + * Only set on finished commands; never read before `finishedAt`. + */ + finalTerminalTitle?: TerminalTitle; outputRange?: { startMarkId?: string; endMarkId?: string; @@ -199,10 +206,13 @@ export function reduceTerminalState( if (sameActivity(state.activity, next)) return state; return { ...state, activity: next }; } + const finishedAt = now(); + const finalTerminalTitle = snapshotInRunTerminalTitle(state, state.currentCommand, finishedAt); const finishedCommand: CommandRun = { ...state.currentCommand, - finishedAt: now(), + finishedAt, exitCode: event.exitCode, + ...(finalTerminalTitle ? { finalTerminalTitle } : {}), }; return { ...state, @@ -759,27 +769,33 @@ function truncateCommandTitle(title: string): string { function headerPrimary(pane: TerminalPaneState, options: HeaderOptions): string { const userTitle = titleCandidateForSource(pane, 'user')?.title.trim(); if (userTitle) return userTitle; - if (!pane.currentCommand) return DEFAULT_IDLE_TITLE; + if (pane.currentCommand) return commandHeaderLabel(pane, pane.currentCommand, options); + if (pane.lastCommand) return `${DEFAULT_IDLE_TITLE} ${commandHeaderLabel(pane, pane.lastCommand, options)}`; + return DEFAULT_IDLE_TITLE; +} + +function commandHeaderLabel(pane: TerminalPaneState, command: CommandRun, options: HeaderOptions): string { const appTitle = options.appTitleForPane?.(pane)?.trim(); - if (appTitle && isAppTitleFresh(pane)) return appTitle; - const terminalTitle = activeTerminalTitle(pane); + if (appTitle && isAppTitleFreshFor(pane, command)) return appTitle; + const terminalTitle = terminalTitleForCommand(pane, command); if (terminalTitle) return terminalTitle; - return pane.currentCommand.displayCommand; + return command.displayCommand; } // appTitleForPane is sourced from the alert manager's current OSC 9 notification. // The protocol parser populates titleCandidates.osc9 from the same OSC 9 stream, // so when both exist they share a timestamp. Use the candidate to apply the same -// staleness rule we apply in activeTerminalTitle: an OSC 9 emitted before the -// current command started must not override the command's own label. If no osc9 -// candidate exists (e.g. notification was injected without going through the -// parser), trust the appTitle to preserve legacy behaviour. -function isAppTitleFresh(pane: TerminalPaneState): boolean { - const command = pane.currentCommand; - if (!command) return true; +// staleness rule we apply in terminalTitleForCommand: an OSC 9 emitted before the +// command started (or — for finished commands — after it ended) must not override +// the command's own label. If no osc9 candidate exists (e.g. notification was +// injected without going through the parser), trust the appTitle to preserve +// legacy behaviour. +function isAppTitleFreshFor(pane: TerminalPaneState, command: CommandRun): boolean { const osc9 = pane.titleCandidates.osc9; if (!osc9) return true; - return osc9.updatedAt >= command.startedAt; + if (osc9.updatedAt < command.startedAt) return false; + if (command.finishedAt !== undefined && osc9.updatedAt > command.finishedAt) return false; + return true; } function idleLabel(pane: TerminalPaneState): string { @@ -790,13 +806,44 @@ function idleLabel(pane: TerminalPaneState): string { const HEADER_APP_TITLE_SOURCES: TerminalTitleSource[] = ['osc0', 'osc2', 'osc9']; -function activeTerminalTitle(pane: TerminalPaneState): string | null { - const command = pane.currentCommand; - if (!command) return null; - const title = latestTitleCandidateForSources(pane, HEADER_APP_TITLE_SOURCES); - if (!title || title.updatedAt < command.startedAt) return null; - const text = title.title.trim(); - return text || null; +function terminalTitleForCommand(pane: TerminalPaneState, command: CommandRun): string | null { + // For finished commands the live `titleCandidates` map may have been overwritten by post-finish + // events (e.g. the shell resetting OSC 0 to `zsh`), so trust the snapshot taken at commandFinish. + if (command.finishedAt !== undefined && command.finalTerminalTitle) { + const snapshot = command.finalTerminalTitle.title.trim(); + if (snapshot) return snapshot; + } + return findInRunTerminalTitle(pane, command)?.title.trim() || null; +} + +function snapshotInRunTerminalTitle( + state: TerminalPaneState, + command: CommandRun, + finishedAt: number, +): TerminalTitle | undefined { + // Same scan as findInRunTerminalTitle but with an explicit upper bound, used by the reducer + // before `command.finishedAt` is set. + let best: TerminalTitle | undefined; + for (const source of HEADER_APP_TITLE_SOURCES) { + const candidate = state.titleCandidates[source]; + if (!candidate) continue; + if (candidate.updatedAt < command.startedAt) continue; + if (candidate.updatedAt > finishedAt) continue; + if (!best || candidate.updatedAt > best.updatedAt) best = candidate; + } + return best; +} + +function findInRunTerminalTitle(pane: TerminalPaneState, command: CommandRun): TerminalTitle | null { + let best: TerminalTitle | null = null; + for (const source of HEADER_APP_TITLE_SOURCES) { + const candidate = pane.titleCandidates[source]; + if (!candidate) continue; + if (candidate.updatedAt < command.startedAt) continue; + if (command.finishedAt !== undefined && candidate.updatedAt > command.finishedAt) continue; + if (!best || candidate.updatedAt > best.updatedAt) best = candidate; + } + return best; } function cwdForHeader(pane: TerminalPaneState): CwdState | null { @@ -842,15 +889,3 @@ function titleCandidateForSource( return pane.titleCandidates[source] ?? null; } -function latestTitleCandidateForSources( - pane: TerminalPaneState, - sources: TerminalTitleSource[], -): TerminalTitle | null { - let latest: TerminalTitle | null = null; - for (const source of sources) { - const candidate = pane.titleCandidates[source]; - if (!candidate) continue; - if (!latest || candidate.updatedAt > latest.updatedAt) latest = candidate; - } - return latest; -} From 49e3f9aa281642409839104a04dbb33edbc1a692 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Sat, 9 May 2026 16:44:30 -0700 Subject: [PATCH 50/50] Allow `<unnamed>` as a user pin; reject anything starting with `<idle>` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that finished panes show `<idle> ${LAST_TITLE}`, any user-pin title that starts with `<idle>` would be visually indistinguishable from that derived state — so reject them all (`<idle>`, `<idle> npm run build`, `<idle>foo`, …) instead of just the bare sentinel. `<unnamed>` is just the default panel placeholder and doesn't collide with any derived header, so accept it as a deliberate pin. The cold-restore seed callers in `terminal-lifecycle.ts` already skip `<unnamed>` independently before calling `setTerminalUserTitle`, so the new permissiveness here doesn't accidentally resurrect the default placeholder as a real pin during resume. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- docs/specs/layout.md | 2 +- docs/specs/transport.md | 2 +- lib/src/lib/terminal-state-store.test.ts | 10 ++++++++-- lib/src/lib/terminal-state-store.ts | 14 ++++++++++---- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 706b2e8..f9c0697 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -259,7 +259,7 @@ The name `<span>` is replaced by an `<input>` with: - `stopPropagation` on `mousedown`/`click`/`keydown` to prevent panel click or drag - All command-mode shortcuts are bypassed while renaming -Sentinel labels (`<idle>`, `<unnamed>`) and empty values are rejected as user-pin titles. When the user submits one, the input still closes (so it is not a blocking dialog) and a small auto-dismissing warning popover anchored under the input names the offending value. The popover dismisses on the next pointerdown, scroll, resize, `Escape`, or after 3s. +User-pin titles must not start with `<idle>` (the sentinel that prefixes the auto-generated header for finished panes), and empty values are also rejected. `<unnamed>` is the default panel placeholder but is otherwise allowed as a deliberate user pin. When the user submits a rejected value, the input still closes (so it is not a blocking dialog) and a small auto-dismissing warning popover anchored under the input names the offending value. The popover dismisses on the next pointerdown, scroll, resize, `Escape`, or after 3s. ## Session lifecycle and terminal registry diff --git a/docs/specs/transport.md b/docs/specs/transport.md index 75bd49b..9a03b38 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -56,7 +56,7 @@ Both are capped at 1M chars per PTY. When the cap is reached, oldest chunks are 3. Host responds with: - { type: 'pty:list', ptys: [{ id, alive, exitCode }] } // all owned PTYs - { type: 'pty:replay', id, data } // buffered output per PTY -4. Webview restores terminals from replay data, seeds saved pane and door titles back via `setTerminalUserTitle()` (which silently drops reserved sentinels like `<idle>` and `<unnamed>`), and resumes the live stream. +4. Webview restores terminals from replay data, seeds saved pane and door titles back via `setTerminalUserTitle()` (which rejects titles starting with `<idle>`, the sentinel that prefixes the auto-generated finished-pane header). The seed callers in `terminal-lifecycle.ts` additionally skip `<unnamed>` so the default panel placeholder does not get seeded as a real user pin during cold-restore. (Persistence cannot distinguish a deliberate `<unnamed>` pin from the default placeholder, so a user who explicitly pinned `<unnamed>` will see it revert to the derived header on app reload.) 5. If the saved session covers those live PTYs, the frontend uses the saved dockview layout when its visible panels match and reattaches saved minimized doors; minimized PTYs are registered but remain doors instead of visible panes. ``` diff --git a/lib/src/lib/terminal-state-store.test.ts b/lib/src/lib/terminal-state-store.test.ts index a42e19d..5946e15 100644 --- a/lib/src/lib/terminal-state-store.test.ts +++ b/lib/src/lib/terminal-state-store.test.ts @@ -94,9 +94,10 @@ describe('terminal semantic state store command input fallback', () => { }); }); - it('refuses to pin sentinel labels as a user title and reports the reason', () => { + it('refuses to pin `<idle>` (or any title starting with `<idle>`) as a user title and reports the reason', () => { expect(setTerminalUserTitle('pane', DEFAULT_IDLE_TITLE)).toEqual({ accepted: false, reason: 'reserved' }); - expect(setTerminalUserTitle('pane', UNNAMED_PANEL_TITLE)).toEqual({ accepted: false, reason: 'reserved' }); + expect(setTerminalUserTitle('pane', `${DEFAULT_IDLE_TITLE} npm run build`)).toEqual({ accepted: false, reason: 'reserved' }); + expect(setTerminalUserTitle('pane', `${DEFAULT_IDLE_TITLE}foo`)).toEqual({ accepted: false, reason: 'reserved' }); expect(setTerminalUserTitle('pane', ' ')).toEqual({ accepted: false, reason: 'empty' }); expect(getTerminalPaneState('pane').titleCandidates.user).toBeUndefined(); @@ -105,6 +106,11 @@ describe('terminal semantic state store command input fallback', () => { expect(getTerminalPaneState('pane').titleCandidates.user?.title).toBe('Production API'); }); + it('lets the user pin `<unnamed>` explicitly even though it is the default placeholder', () => { + expect(setTerminalUserTitle('pane', UNNAMED_PANEL_TITLE)).toEqual({ accepted: true }); + expect(getTerminalPaneState('pane').titleCandidates.user?.title).toBe(UNNAMED_PANEL_TITLE); + }); + it('records PTY fallback state under the current pane after a swap', () => { registry.set('pane-b', { ptyId: 'pane-a' } as unknown as TerminalEntry); diff --git a/lib/src/lib/terminal-state-store.ts b/lib/src/lib/terminal-state-store.ts index 71255cb..b270886 100644 --- a/lib/src/lib/terminal-state-store.ts +++ b/lib/src/lib/terminal-state-store.ts @@ -4,7 +4,6 @@ import { cwdFromProcessPath, DEFAULT_IDLE_TITLE, reduceTerminalState, - UNNAMED_PANEL_TITLE, type CwdState, type TerminalPaneState, type TerminalSemanticEvent, @@ -122,16 +121,23 @@ export function recordTerminalOutputByPtyId(ptyId: string, output: string): void recordTerminalOutput(resolvePaneStateIdByPtyId(ptyId), output); } -const RESERVED_USER_TITLES = new Set<string>([DEFAULT_IDLE_TITLE, UNNAMED_PANEL_TITLE]); - export type SetTerminalUserTitleResult = | { accepted: true } | { accepted: false; reason: 'empty' | 'reserved' }; +// `<idle>` is the sentinel that prefixes the auto-generated header for finished panes +// (`<idle> ${LAST_TITLE}`); any user-pin title starting with `<idle>` would be indistinguishable +// from that derived state. `<unnamed>` is just the default panel placeholder, so we let users +// pin to it explicitly if they want — the resume/restore seed paths already skip `<unnamed>` +// before calling this function, so they never accidentally seed it as a real pin. +function isReservedUserTitle(trimmed: string): boolean { + return trimmed === DEFAULT_IDLE_TITLE || trimmed.startsWith(DEFAULT_IDLE_TITLE); +} + export function setTerminalUserTitle(id: string, title: string): SetTerminalUserTitleResult { const trimmed = title.trim(); if (!trimmed) return { accepted: false, reason: 'empty' }; - if (RESERVED_USER_TITLES.has(trimmed)) return { accepted: false, reason: 'reserved' }; + if (isReservedUserTitle(trimmed)) return { accepted: false, reason: 'reserved' }; const terminalTitle: TerminalTitle = { title: trimmed, source: 'user',