diff --git a/AGENTS.md b/AGENTS.md
index 527390f..1b510d0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -32,9 +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/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 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.
- **`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`.
diff --git a/docs/specs/OSC.md b/docs/specs/OSC.md
new file mode 100644
index 0000000..7673ed9
--- /dev/null
+++ b/docs/specs/OSC.md
@@ -0,0 +1,101 @@
+# 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. 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`.
+- 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 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
+
+| Sequence | Purpose | Spec |
+|---|---|---|
+| `BEL` (standalone, outside an OSC) | Generic terminal-bell notification | [alert.md](alert.md#standalone-bel) |
+| `OSC 0 ;
ST` | Window/icon title | [terminal-state.md](terminal-state.md#supported-osc-inputs) |
+| `OSC 2 ; 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 ; ST` | iTerm2 legacy notification | [alert.md](alert.md#osc-9) |
+| `OSC 9 ; 4 ; [; ] ST` | iTerm2 progress | [alert.md](alert.md#osc-94-progress) |
+| `OSC 9 ; 9 ; ST` | CWD (Windows Terminal / ConEmu) | [terminal-state.md](terminal-state.md#supported-osc-inputs) |
+| `OSC 99 ; ; 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 ; [; ] ST` | VS Code command line | [terminal-state.md](terminal-state.md#supported-osc-inputs) |
+| `OSC 633 ; P ; Cwd= ST` | CWD (VS Code) | [terminal-state.md](terminal-state.md#supported-osc-inputs) |
+| `OSC 777 ; notify ; ; ST` | rxvt/WezTerm notification | [alert.md](alert.md#osc-777) |
+| `OSC 1337 ; CurrentDir= ST` | CWD (iTerm2 compatibility) | [terminal-state.md](terminal-state.md#supported-osc-inputs) |
+
+Some sequences are dual-purpose. The notification rows for `OSC 9 ; 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.
+
+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 and clipboard-capable sequences
+
+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 |
+|---|---|---|
+| `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 ; ST` | Set font dynamically | Font is host-controlled. |
+| `OSC 52 ; ; ST` | Programmatic clipboard write | Security: same rationale as `CopyToClipboard`. |
+
+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
+
+- 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..a81b8da 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`
@@ -222,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.
@@ -238,6 +270,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.
+
+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.
+
+| 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 +454,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,12 +462,22 @@ 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`
- 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.
@@ -280,7 +494,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 +508,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 +530,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 +563,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 +696,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 +758,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 45eb7d2..0000000
--- a/docs/specs/iTerm2.md
+++ /dev/null
@@ -1,415 +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.
-
-## 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.
-
-## 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.
-
-| 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 }`.
-
-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/layout.md b/docs/specs/layout.md
index 7bd4b6f..f9c0697 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,13 @@ 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, current command title, ` ${LAST_TITLE}` for finished panes, plain `` 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.
+
Elements from left to right:
-- Session name (click to rename, 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
@@ -102,7 +106,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
@@ -225,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)
@@ -255,6 +259,8 @@ The name `` is replaced by an ` ` with:
- `stopPropagation` on `mousedown`/`click`/`keydown` to prevent panel click or drag
- All command-mode shortcuts are bypassed while renaming
+User-pin titles must not start with `` (the sentinel that prefixes the auto-generated header for finished panes), and empty values are also rejected. `` 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
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`.
@@ -269,19 +275,21 @@ 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.
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). 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
### 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.
## Theme
@@ -362,6 +370,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..faa855a
--- /dev/null
+++ b/docs/specs/terminal-state.md
@@ -0,0 +1,256 @@
+# 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 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
+
+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
+- 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.
+
+## Core Model
+
+```ts
+type TerminalPaneState = {
+ cwd: CwdState | null;
+ activity: ShellActivity;
+ pendingCommandLine: string | null;
+ currentCommand: CommandRun | null;
+ lastCommand: CommandRun | null;
+ title: TerminalTitle | null;
+ titleCandidates: Partial>;
+};
+```
+
+```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"
+ | "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 ` ${LAST_TITLE}`.
+ * Only set on finished commands.
+ */
+ finalTerminalTitle?: TerminalTitle;
+ outputRange?: {
+ startMarkId?: string;
+ endMarkId?: string;
+ };
+};
+```
+
+```ts
+type TerminalTitle = {
+ title: string;
+ source:
+ | "osc0"
+ | "osc2"
+ | "osc9"
+ | "osc99"
+ | "osc777"
+ | "user";
+ updatedAt: number;
+};
+```
+
+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
+
+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"]; 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
+
+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. |
+
+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 |
+|---|---|
+| `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`; 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 ; ST` / `OSC 633 ; D ; ST` | `commandFinish` |
+
+Title fallback:
+
+| Sequence | Event |
+|---|---|
+| `OSC 0 ; ST` | `title(source: "osc0")` |
+| `OSC 2 ; ST` | `title(source: "osc2")` |
+
+Title candidate diagnostics:
+
+| Sequence | Candidate source | Header/door override |
+|---|---|---|
+| `OSC 9 ; ST` | `osc9` | Yes |
+| `OSC 99 ; ... title/body ... ST` | `osc99` | No |
+| `OSC 777 ; notify ; ; ST` | `osc777` | No |
+
+Only the OSC 9 *message* form (`OSC 9 ; `) feeds the title channel. The OSC 9 *progress* form (`OSC 9 ; 4 ; ; `) 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.
+
+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
+
+`reduceTerminalState(state, event)` is the only state transition surface.
+
+- `cwd` replaces the latest session CWD.
+- `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.
+- `commandStart` creates `currentCommand`, snapshots `cwdAtStart`, uses `event.startedAt` when present, clears `pendingCommandLine`, and sets `{ kind: "running" }`.
+- `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 ``.
+- 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 precedence:
+
+- 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`.
+
+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
+
+```ts
+type DerivedHeader = {
+ primary: string;
+ secondary?: string;
+};
+```
+
+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 (`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. After a command has finished (`currentCommand` is null and `lastCommand` is set): ` ${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): ``.
+
+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 `` or pollute `LAST_TITLE`.
+
+` ${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. ` ${LAST_TITLE}` persists across subsequent prompt/editing transitions until a new `commandStart` replaces it; only a fresh pane (no `lastCommand` at all) shows plain ``.
+
+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
+
+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 ?? ""
+```
+
+Status grouping projects `ShellActivity.kind` (5 values) onto 4 buckets:
+
+| `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 has the same `` 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/docs/specs/transport.md b/docs/specs/transport.md
new file mode 100644
index 0000000..9a03b38
--- /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 saved pane and door titles back via `setTerminalUserTitle()` (which rejects titles starting with ``, the sentinel that prefixes the auto-generated finished-pane header). The seed callers in `terminal-lifecycle.ts` additionally skip `` so the default panel placeholder does not get seeded as a real user pin during cold-restore. (Persistence cannot distinguish a deliberate `` pin from the default placeholder, so a user who explicitly pinned `` 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.
+```
+
+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
+
+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:**
+
+| 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` ↔ `true`) |
+| `alert:markTodo` | Set TODO to `true` |
+| `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 1617041..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.
@@ -39,6 +41,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
@@ -57,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.
@@ -98,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)
@@ -116,81 +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.
-- 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, resumes 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 (routed only to owning router) |
-| `pty:exit` | PTY process exited (with exitCode) |
-| `pty:list` | List of all resumable PTYs (response to `mouseterm:init`) |
-| `pty:replay` | Buffered output since spawn (response to `mouseterm:init`) |
-| `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
@@ -200,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
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[];
}
| undefined;
+ const primedTerminalState = context.parameters?.primedTerminalState as
+ | {
+ byId?: Record>;
+ }
+ | 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/components/Baseboard.tsx b/lib/src/components/Baseboard.tsx
index a986956..a8eada3 100644
--- a/lib/src/components/Baseboard.tsx
+++ b/lib/src/components/Baseboard.tsx
@@ -4,7 +4,17 @@ 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 {
+ buildAppTitleResolver,
+ DEFAULT_ACTIVITY_STATE,
+ deriveHeader,
+ getActivitySnapshot,
+ getTerminalPaneStateSnapshot,
+ resolveDisplayPrimary,
+ subscribeToActivity,
+ subscribeToTerminalPaneState,
+} from '../lib/terminal-registry';
+import { createTerminalPaneState, type TerminalPaneState } from '../lib/terminal-state';
export interface BaseboardProps {
items: DooredItem[];
@@ -15,6 +25,12 @@ 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 allPaneStates = useMemo(() => [...terminalStates.values()], [terminalStates]);
+ const appTitleForPane = useMemo(
+ () => buildAppTitleResolver(terminalStates, activityStates),
+ [terminalStates, activityStates],
+ );
const containerRef = useRef(null);
const [containerWidth, setContainerWidth] = useState(0);
const [startIndex, setStartIndex] = useState(0);
@@ -52,7 +68,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 +156,11 @@ export function Baseboard({ items, onReattach, notice }: BaseboardProps) {
{items.map(item => {
const activity = activityStates.get(item.id) ?? DEFAULT_ACTIVITY_STATE;
+ const title = deriveDoorTitle(item.title, item.id, terminalStates, allPaneStates, appTitleForPane);
return (
{
const activity = activityStates.get(item.id) ?? DEFAULT_ACTIVITY_STATE;
+ const title = deriveDoorTitle(item.title, item.id, terminalStates, allPaneStates, appTitleForPane);
return (
onReattach(item)}
@@ -199,3 +217,15 @@ export function Baseboard({ items, onReattach, notice }: BaseboardProps) {
);
}
+
+function deriveDoorTitle(
+ savedTitle: string,
+ id: string,
+ terminalStates: Map,
+ 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, { appTitleForPane }).primary, savedTitle);
+}
diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx
index 08f2557..7f236e8 100644
--- a/lib/src/components/Wall.tsx
+++ b/lib/src/components/Wall.tsx
@@ -16,6 +16,8 @@ import {
toggleSessionTodo,
setPendingShellOpts,
getDefaultShellOpts,
+ setTerminalUserTitle,
+ UNNAMED_PANEL_TITLE,
type SessionStatus,
} from '../lib/terminal-registry';
import { findReattachNeighbor } from '../lib/spatial-nav';
@@ -80,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 };
@@ -260,7 +267,7 @@ export function Wall({
if (!api) return;
const panel = api.getPanel(id);
if (!panel) return;
- const title = panel.title ?? id;
+ const title = persistedPanelTitle(panel.title);
const layoutAtMinimize = cloneLayout(api.toJSON());
// Capture the nearest adjacent pane and our actual relative position
@@ -444,7 +451,7 @@ export function Wall({
id: newId,
component: 'terminal',
tabComponent: 'terminal',
- title: '',
+ title: UNNAMED_PANEL_TITLE,
position: active ? { referencePanel: active.id, direction: pickSplitDirection(active) } : undefined,
});
selectPane(newId);
@@ -475,7 +482,7 @@ export function Wall({
id: newId,
component: 'terminal',
tabComponent: 'terminal',
- title: '',
+ title: UNNAMED_PANEL_TITLE,
position: ref ? { referencePanel: ref, direction } : undefined,
});
selectPane(newId);
@@ -525,10 +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);
}
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['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(null);
+ const [style, setStyle] = useState({
+ 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(
+ e.stopPropagation()}
+ >
+
Illegal name
+
{describeReason(reason, attemptedValue)}
+
,
+ 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 34f7ef6..40edea2 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';
@@ -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,
@@ -29,9 +30,20 @@ import {
clearSessionTodo,
DEFAULT_ACTIVITY_STATE,
getActivitySnapshot,
+ getTerminalPaneStateSnapshot,
subscribeToActivity,
+ subscribeToTerminalPaneState,
type SessionStatus,
} from '../../lib/terminal-registry';
+import {
+ buildAppTitleResolver,
+ createTerminalPaneState,
+ deriveHeader,
+ resolveDisplayPrimary,
+ titleCandidatesForDisplay,
+ titleSourceLabel,
+ type TerminalTitle,
+} from '../../lib/terminal-state';
import {
DialogKeyboardContext,
ModeContext,
@@ -66,6 +78,8 @@ const ALERT_BUTTON_LABELS: Record [...terminalStates.values()], [terminalStates]);
+ const visiblePaneStates = allPaneStates.length > 0 ? allPaneStates : [paneState];
+ 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';
const inOverride = mouseState.override !== 'off';
@@ -95,7 +119,10 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) {
const [tier, setTier] = useState('full');
const [dialogTriggerRect, setDialogTriggerRect] = useState(null);
const [todoPreviewRect, setTodoPreviewRect] = useState(null);
+ const [titleCandidatesRect, setTitleCandidatesRect] = useState(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';
const alertButtonLabels = ALERT_BUTTON_LABELS[activity.status];
const alertButtonAriaLabel = alertButtonLabels.aria;
@@ -108,6 +135,17 @@ 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());
@@ -137,36 +175,67 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) {
if (!activity.notification) setTodoPreviewRect(null);
}, [activity.notification]);
+ const titleCandidatesOpen = !!titleCandidatesRect;
+ useEffect(() => {
+ if (!titleCandidatesOpen) 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);
+ };
+ }, [titleCandidatesOpen]);
+
return (
actions.onClickPanel(api.id)}
>
-
+
{isRenaming ? (
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()}
/>
) : (
e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); actions.onStartRename(api.id); }}
- >{api.title}
+ onContextMenu={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setTitleCandidatesRect(e.currentTarget.getBoundingClientRect());
+ }}
+ >
+ {displayTitle}
+ {derivedHeader.secondary && (
+ {derivedHeader.secondary}
+ )}
+
)}
)}
+ {titleCandidatesRect && !dialogTriggerRect && (
+
+ )}
+ {renameWarning && (
+
+ )}
);
}
+function TitleCandidatesPopover({
+ anchorRect,
+ candidates,
+ currentTitle,
+ onClose,
+}: {
+ anchorRect: DOMRect;
+ candidates: TerminalTitle[];
+ currentTitle: string;
+ onClose: () => void;
+}) {
+ const ref = useRef
(null);
+ const [style, setStyle] = useState({
+ 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(
+ e.stopPropagation()}
+ onMouseDown={(e) => e.stopPropagation()}
+ onContextMenu={(e) => e.preventDefault()}
+ >
+
+ {candidates.length === 0 ? (
+
No title candidates
+ ) : (
+
+ {candidates.map((candidate) => (
+
+ {titleSourceLabel(candidate.source)}
+ {candidate.title}
+
+ {formatTitleCandidateTime(candidate.updatedAt)}
+
+
+ ))}
+
+ )}
+
,
+ document.body,
+ );
+}
+
function TodoNotificationPreview({
id,
notification,
@@ -379,3 +538,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/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: '',
+ 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: '' });
+ 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 ?? '' }));
+ 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/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({
onZoom: () => {},
onClickPanel: () => {},
onStartRename: () => {},
- onFinishRename: () => {},
+ onFinishRename: () => ({ accepted: true }),
onCancelRename: () => {},
});
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/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 c0f0b85..5a2be8a 100644
--- a/lib/src/lib/platform/fake-adapter.ts
+++ b/lib/src/lib/platform/fake-adapter.ts
@@ -2,9 +2,13 @@ import type { AlertStateDetail, PlatformAdapter, PtyInfo } from './types';
import { AlertManager, type SessionStatus } from '../alert-manager';
import {
applyTerminalProtocolEvents,
+ collectTerminalSemanticEvents,
collectTerminalProtocolResponses,
TerminalProtocolParser,
} from '../terminal-protocol';
+import {
+ applyTerminalSemanticEventsByPtyId,
+} from '../terminal-state-store';
export interface FakeScenario {
name: string;
@@ -344,6 +348,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.test.ts b/lib/src/lib/platform/vscode-adapter.test.ts
new file mode 100644
index 0000000..97e8464
--- /dev/null
+++ b/lib/src/lib/platform/vscode-adapter.test.ts
@@ -0,0 +1,131 @@
+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 {
+ collectTerminalSemanticEvents,
+ TerminalProtocolParser,
+} from '../terminal-protocol';
+import { VSCodeAdapter } from './vscode-adapter';
+
+describe('VSCodeAdapter PTY exit handling', () => {
+ let windowTarget: EventTarget;
+ let postMessage: ReturnType;
+
+ 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('lets lifecycle cleanup remove semantic pane state after explicitly killing a PTY', () => {
+ const adapter = new VSCodeAdapter();
+
+ adapter.killPty('pane-1');
+
+ 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);
+ });
+
+ 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);
+ });
+});
diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts
index cbca67d..7bfbc38 100644
--- a/lib/src/lib/platform/vscode-adapter.ts
+++ b/lib/src/lib/platform/vscode-adapter.ts
@@ -1,5 +1,12 @@
import type { AlertStateDetail, PlatformAdapter, PtyInfo } from './types';
import { setDefaultShellOpts } from '../shell-defaults';
+import {
+ collectTerminalSemanticEvents,
+ TerminalProtocolParser,
+} from '../terminal-protocol';
+import {
+ applyTerminalSemanticEventsByPtyId,
+} from '../terminal-state-store';
export class VSCodeAdapter implements PlatformAdapter {
private vscode: ReturnType;
@@ -41,9 +48,18 @@ export class VSCodeAdapter implements PlatformAdapter {
handler({ ptys: msg.ptys });
}
} else if (msg.type === 'pty:replay') {
+ // 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) {
- 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 });
diff --git a/lib/src/lib/reconnect.test.ts b/lib/src/lib/reconnect.test.ts
index 22de9fa..14cec0e 100644
--- a/lib/src/lib/reconnect.test.ts
+++ b/lib/src/lib/reconnect.test.ts
@@ -111,6 +111,56 @@ describe('resumeOrRestore', () => {
expect(terminalRegistryMocks.resumeTerminal).toHaveBeenCalledWith('pane-c', 'pane-c-replay', {
alive: true,
exitCode: undefined,
+ title: 'Pane C',
+ });
+ });
+
+ 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('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',
});
});
diff --git a/lib/src/lib/reconnect.ts b/lib/src/lib/reconnect.ts
index 584355a..99d5c2c 100644
--- a/lib/src/lib/reconnect.ts
+++ b/lib/src/lib/reconnect.ts
@@ -63,18 +63,23 @@ function resumeLiveSessions(platform: PlatformAdapter): Promise 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 = savedTitles.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,19 @@ function resumeLiveSessions(platform: PlatformAdapter): Promise {
+ const saved = readPersistedSession(savedState);
+ if (!saved || !Array.isArray(saved.panes)) return new Map();
+
+ const liveSet = new Set(liveIds);
+ const result = new Map();
+ for (const pane of saved.panes) {
+ if (!liveSet.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/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 {
const saved = readPersistedSession(platform.getState());
@@ -20,9 +21,13 @@ export async function saveSession(
const previousPanes = getPreviousPaneMap(platform);
const allPanes = new Map();
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;
+}
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 d83f978..b29fc84 100644
--- a/lib/src/lib/terminal-lifecycle.ts
+++ b/lib/src/lib/terminal-lifecycle.ts
@@ -25,6 +25,22 @@ import {
writeReplay,
} from './terminal-report-filter';
import { getTerminalTheme, paintTerminalHost, startThemeObserver } from './terminal-theme';
+import {
+ ensureTerminalPaneState,
+ fillTerminalProcessCwdByPtyId,
+ recordTerminalOutputByPtyId,
+ recordTerminalUserInputByPtyId,
+ removeTerminalPaneState,
+ resetTerminalPaneState,
+ seedTerminalManualCwd,
+ setTerminalUserTitle,
+ swapTerminalPaneStates,
+} from './terminal-state-store';
+import { UNNAMED_PANEL_TITLE } from './terminal-state';
+
+function seedProcessCwdAfterSpawn(id: string): void {
+ void getPlatform().getCwd(id).then((cwd) => fillTerminalProcessCwdByPtyId(id, cwd));
+}
function createXtermHost(): { terminal: Terminal; fit: FitAddon; element: HTMLDivElement } {
const styles = getComputedStyle(document.body);
@@ -55,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) {
+ recordTerminalOutputByPtyId(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`);
@@ -88,6 +107,7 @@ function wireXtermHandlers(
if (inputIsReplayTerminalReport(input) && registry.get(id)?.isReplaying) return;
if (!isSyntheticTerminalReport) {
+ recordTerminalUserInputByPtyId(id, input);
const entry = registry.get(id);
const hadTodo = entry?.todo === true;
getPlatform().alertAttend(id);
@@ -174,6 +194,7 @@ function setupTerminalEntry(id: string): TerminalEntry {
}
registry.set(id, entry);
+ ensureTerminalPaneState(id);
notifyActivityListeners();
startThemeObserver();
return entry;
@@ -188,6 +209,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 +220,7 @@ export function getOrCreateTerminal(id: string): TerminalEntry {
rows: dims?.rows || 30,
...shellOpts,
});
+ seedProcessCwdAfterSpawn(id);
return entry;
}
@@ -205,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;
@@ -218,18 +241,28 @@ 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;
}
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;
const entry = setupTerminalEntry(id);
+ resetTerminalPaneState(id);
+ seedTerminalManualCwd(id, opts.cwd);
+ const trimmedTitle = opts.title?.trim();
+ if (trimmedTitle && trimmedTitle !== UNNAMED_PANEL_TITLE) {
+ setTerminalUserTitle(id, trimmedTitle);
+ }
if (opts.scrollback) {
writeReplay(entry, opts.scrollback, '\r\n');
@@ -246,6 +279,7 @@ export function restoreTerminal(
shell: opts.shell,
args: opts.args,
});
+ seedProcessCwdAfterSpawn(id);
return entry;
}
@@ -278,6 +312,7 @@ export function disposeSession(id: string): void {
entry.element.remove();
entry.terminal.dispose();
registry.delete(id);
+ removeTerminalPaneState(id);
removeMouseSelectionState(id);
notifyActivityListeners();
}
@@ -295,6 +330,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..3d74113 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 { ITERM2_DEVICE_ATTRIBUTES_RESPONSE, TerminalProtocolParser } from './terminal-protocol';
+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', () => {
@@ -30,6 +31,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: 'osc9', updatedAt: expect.any(Number) },
+ },
+ ]);
});
it('does not add terminal bell detail for the BEL terminator of a supported OSC notification', () => {
@@ -85,6 +92,12 @@ describe('TerminalProtocolParser', () => {
expect(result.events).toEqual([
{ kind: 'notification', notification: { source: 'OSC 777', title: 'Title', body: 'one;two;three' } },
]);
+ expect(collectTerminalSemanticEvents(result.events)).toEqual([
+ {
+ type: 'title',
+ title: { title: 'Title', source: 'osc777', updatedAt: expect.any(Number) },
+ },
+ ]);
});
it('assembles OSC 99 title and body chunks', () => {
@@ -100,6 +113,12 @@ describe('TerminalProtocolParser', () => {
notification: { source: 'OSC 99', title: 'Build', body: 'Finished successfully' },
},
]);
+ 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', () => {
@@ -125,12 +144,164 @@ 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]555;unknown\x07text');
+ 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('\x1b]0;title\x07text');
+ 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');
+
+ 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('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',
+ });
+
+ 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',
+ });
+ });
+
+ 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');
+
+ 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`);
@@ -173,3 +344,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 7a3c52b..a620a86 100644
--- a/lib/src/lib/terminal-protocol.ts
+++ b/lib/src/lib/terminal-protocol.ts
@@ -1,9 +1,20 @@
import type { ActivityNotification, ProtocolProgressUpdate } from './alert-manager';
+import {
+ cwdFromOsc1337,
+ cwdFromOsc633,
+ cwdFromOsc7,
+ cwdFromOsc9_9,
+ terminalTitleFromNotification,
+ 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,15 +99,27 @@ 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);
+ if (isKnownUnsupportedIterm2Osc(content)) return [];
return null;
}
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 +231,54 @@ export function collectTerminalProtocolResponses(events: TerminalProtocolEvent[]
return events.flatMap((event) => (event.kind === 'response' ? [event.data] : []));
}
+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(timestampSemanticEvent(event.event, nextTimestamp));
+ continue;
+ }
+ if (event.kind !== 'notification') continue;
+ const title = terminalTitleFromNotification(event.notification, nextTimestamp());
+ if (!title) continue;
+ semanticEvents.push({
+ type: 'title',
+ title,
+ });
+ }
+ 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;
@@ -260,6 +331,112 @@ 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 [];
+ 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 [];
+ // VS Code shell integration encodes the command as [;], with
+ // any literal `;` inside 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) } }];
+ }
+ 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(commandStartSource)];
+ case 'D':
+ return [{ kind: 'semantic', event: { type: 'commandFinish', exitCode: parseExitCode(fields[2]) } }];
+ 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 [];
+ // updatedAt is set authoritatively by collectTerminalSemanticEvents in stream order.
+ return [{
+ kind: 'semantic',
+ event: {
+ type: 'title',
+ title: { title: titleText, source, updatedAt: 0 },
+ },
+ }];
+}
+
+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 } };
+}
+
+function parseExitCode(raw: string | undefined): number | undefined {
+ if (raw === undefined || raw === '') return undefined;
+ const value = Number(raw);
+ 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 ProtocolProgressUpdate | null> = {
diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts
index b86b7ba..6f6ee0e 100644
--- a/lib/src/lib/terminal-registry.ts
+++ b/lib/src/lib/terminal-registry.ts
@@ -2,6 +2,16 @@ 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,
+ TerminalTitleCandidates,
+} from './terminal-state';
export {
clearPrimedActivity,
@@ -42,3 +52,45 @@ export {
} from './terminal-lifecycle';
export { setDefaultShellOpts, getDefaultShellOpts } from './shell-defaults';
+
+export {
+ applyTerminalSemanticEvents,
+ applyTerminalSemanticEventsByPtyId,
+ ensureTerminalPaneState,
+ fillTerminalProcessCwd,
+ fillTerminalProcessCwdByPtyId,
+ getTerminalPaneState,
+ getTerminalPaneStateSnapshot,
+ removeTerminalPaneState,
+ resetTerminalPaneState,
+ seedTerminalManualCwd,
+ setTerminalUserTitle,
+ subscribeToTerminalPaneState,
+} from './terminal-state-store';
+export type { SetTerminalUserTitleResult } from './terminal-state-store';
+
+export {
+ cwdDisplay,
+ cwdFromManualPath,
+ cwdFromOsc1337,
+ cwdFromOsc633,
+ cwdFromOsc7,
+ cwdFromOsc9_9,
+ cwdFromProcessPath,
+ cwdIdentity,
+ buildAppTitleResolver,
+ DEFAULT_COMMAND_TITLE,
+ DEFAULT_IDLE_TITLE,
+ deriveFallbackCommandTitle,
+ deriveHeader,
+ groupTerminalPanes,
+ notificationDisplayTitle,
+ reduceTerminalState,
+ resolveDisplayPrimary,
+ shortestUniqueCwdLabels,
+ summarizeCommandLine,
+ terminalTitleFromNotification,
+ titleCandidatesForDisplay,
+ titleSourceLabel,
+ UNNAMED_PANEL_TITLE,
+} from './terminal-state';
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..5946e15
--- /dev/null
+++ b/lib/src/lib/terminal-state-store.test.ts
@@ -0,0 +1,144 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import {
+ applyTerminalSemanticEvents,
+ fillTerminalProcessCwd,
+ fillTerminalProcessCwdByPtyId,
+ getTerminalPaneState,
+ getTerminalPaneStateSnapshot,
+ recordTerminalOutput,
+ recordTerminalOutputByPtyId,
+ recordTerminalUserInput,
+ recordTerminalUserInputByPtyId,
+ removeTerminalPaneState,
+ resetTerminalPaneState,
+ seedTerminalManualCwd,
+ 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(() => {
+ removeTerminalPaneState('pane');
+ removeTerminalPaneState('pane-a');
+ removeTerminalPaneState('pane-b');
+ registry.delete('pane-b');
+ });
+
+ 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');
+ });
+
+ 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);
+ });
+
+ 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 `` (or any title starting with ``) as a user title and reports the reason', () => {
+ expect(setTerminalUserTitle('pane', DEFAULT_IDLE_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();
+
+ expect(setTerminalUserTitle('pane', 'Production API')).toEqual({ accepted: true });
+ expect(getTerminalPaneState('pane').titleCandidates.user?.title).toBe('Production API');
+ });
+
+ it('lets the user pin `` 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);
+
+ 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' });
+ });
+
+ 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
new file mode 100644
index 0000000..b270886
--- /dev/null
+++ b/lib/src/lib/terminal-state-store.ts
@@ -0,0 +1,279 @@
+import {
+ createTerminalPaneState,
+ cwdFromManualPath,
+ cwdFromProcessPath,
+ DEFAULT_IDLE_TITLE,
+ reduceTerminalState,
+ type CwdState,
+ type TerminalPaneState,
+ 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();
+const promptInputStates = new Map();
+const promptOutputBuffers = new Map();
+const listeners = new Set<() => void>();
+let cachedSnapshot: Map | null = null;
+
+export function subscribeToTerminalPaneState(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+}
+
+export function getTerminalPaneStateSnapshot(): Map {
+ 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 {
+ 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): 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();
+}
+
+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;
+ 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) {
+ next = reduceTerminalState(next, event);
+ }
+ if (next === prev && paneStates.has(id)) return;
+ paneStates.set(id, next);
+ 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 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);
+ 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 recordTerminalOutputByPtyId(ptyId: string, output: string): void {
+ recordTerminalOutput(resolvePaneStateIdByPtyId(ptyId), output);
+}
+
+export type SetTerminalUserTitleResult =
+ | { accepted: true }
+ | { accepted: false; reason: 'empty' | 'reserved' };
+
+// `` is the sentinel that prefixes the auto-generated header for finished panes
+// (` ${LAST_TITLE}`); any user-pin title starting with `` would be indistinguishable
+// from that derived state. `` is just the default panel placeholder, so we let users
+// pin to it explicitly if they want — the resume/restore seed paths already skip ``
+// 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 (isReservedUserTitle(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 {
+ const cwd = path ? cwdFromManualPath(path) : null;
+ 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 {
+ if (!path) return;
+ const cwd = cwdFromProcessPath(path);
+ if (!cwd) return;
+ 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);
+ 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();
+}
+
+function updateCwdIfAllowed(id: string, cwd: CwdState): void {
+ 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 });
+ notifyTerminalPaneStateListeners();
+}
+
+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');
+ // 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 {
+ 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
new file mode 100644
index 0000000..6dd6027
--- /dev/null
+++ b/lib/src/lib/terminal-state.test.ts
@@ -0,0 +1,440 @@
+import { describe, expect, it } from 'vitest';
+import {
+ createTerminalPaneState,
+ cwdDisplay,
+ cwdFromManualPath,
+ cwdFromOsc7,
+ cwdFromOsc9_9,
+ cwdIdentity,
+ DEFAULT_IDLE_TITLE,
+ deriveHeader,
+ buildAppTitleResolver,
+ groupTerminalPanes,
+ notificationDisplayTitle,
+ reduceTerminalState,
+ shortestUniqueCwdLabels,
+ summarizeCommandLine,
+ terminalTitleFromNotification,
+ titleCandidatesForDisplay,
+ 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');
+ });
+
+ 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', () => {
+ 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' });
+ });
+
+ 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' });
+ 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(state.activity).toEqual({ kind: 'finished', exitCode: 0 });
+ expect(deriveHeader(state, [state])).toEqual({
+ primary: `${DEFAULT_IDLE_TITLE} lazygit`,
+ });
+
+ state = reduceTerminalState(state, { type: 'promptStart' });
+ expect(deriveHeader(state, [state])).toEqual({
+ primary: `${DEFAULT_IDLE_TITLE} lazygit`,
+ });
+ });
+
+ 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',
+ });
+
+ state = reduceTerminalState(state, { type: 'promptStart' });
+
+ expect(state.currentCommand).toBeNull();
+ expect(deriveHeader(state, [state])).toEqual({
+ primary: DEFAULT_IDLE_TITLE,
+ });
+ });
+});
+
+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 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,
+ });
+ });
+
+ 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',
+ });
+ expect(deriveHeader(api, [app, api])).toEqual({
+ primary: 'pnpm test --watch',
+ secondary: 'api',
+ });
+ });
+
+ it('lets fresh app-sent terminal titles override running command labels', () => {
+ 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',
+ });
+ });
+
+ it('ignores stale shell titles from before a command started', () => {
+ const pane = reduceTerminalState(
+ runningPane('/repo/app', 'lazygit'),
+ { type: 'title', title: { title: 'zsh', source: 'osc0', updatedAt: 0 } },
+ );
+
+ expect(deriveHeader(pane, [pane])).toEqual({
+ primary: 'lazygit',
+ });
+ });
+
+ 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',
+ });
+ 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]]);
+ 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',
+ });
+ });
+
+ 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',
+ });
+ });
+
+ 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',
+ });
+ });
+
+ it('shows ` ${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 ` ${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');
+
+ 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', DEFAULT_IDLE_TITLE]);
+ 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..b0c44c6
--- /dev/null
+++ b/lib/src/lib/terminal-state.ts
@@ -0,0 +1,891 @@
+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'
+ | 'user_input';
+
+export interface CommandRun {
+ id: string;
+ rawCommandLine: string | null;
+ displayCommand: string;
+ cwdAtStart: CwdState | null;
+ startedAt: number;
+ 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 ` ${LAST_TITLE}` header.
+ * Only set on finished commands; never read before `finishedAt`.
+ */
+ finalTerminalTitle?: TerminalTitle;
+ outputRange?: {
+ startMarkId?: string;
+ endMarkId?: string;
+ };
+}
+
+export type TerminalTitleSource =
+ | 'osc0'
+ | 'osc2'
+ | 'osc9'
+ | 'osc99'
+ | 'osc777'
+ | 'user';
+
+export interface TerminalTitle {
+ title: string;
+ source: TerminalTitleSource;
+ updatedAt: number;
+}
+
+export type TerminalTitleCandidates = Partial>;
+
+export interface TerminalPaneState {
+ cwd: CwdState | null;
+ activity: ShellActivity;
+ pendingCommandLine: string | null;
+ currentCommand: CommandRun | null;
+ lastCommand: CommandRun | null;
+ title: TerminalTitle | null;
+ titleCandidates: TerminalTitleCandidates;
+}
+
+export type TerminalSemanticEvent =
+ | { type: 'cwd'; cwd: CwdState }
+ | { type: 'promptStart' }
+ | { type: 'promptEnd' }
+ | { type: 'commandLine'; commandLine: string }
+ | { type: 'commandStart'; source?: CommandRunSource; startedAt?: number }
+ | { 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;
+ appTitleForPane?: (pane: TerminalPaneState) => string | null | undefined;
+}
+
+export interface DerivedHeader {
+ primary: string;
+ secondary?: string;
+}
+
+export type TerminalGroupingMode = 'none' | 'directory' | 'command' | 'status';
+
+export interface TerminalGroup {
+ key: string;
+ label: string;
+ 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),
+ pendingCommandLine: null,
+ currentCommand: null,
+ lastCommand: null,
+ title: null,
+ titleCandidates: Object.freeze({}),
+});
+
+export const DEFAULT_IDLE_TITLE = '';
+export const DEFAULT_COMMAND_TITLE = 'shell';
+export const UNNAMED_PANEL_TITLE = '';
+const DEFAULT_DIRECTORY_LABEL = 'Unknown directory';
+const COMMAND_TITLE_LIMIT = 48;
+let nextCommandRunId = 0;
+
+export function createTerminalPaneState(initial?: Partial): TerminalPaneState {
+ 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,
+ titleCandidates,
+ };
+}
+
+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':
+ if (state.cwd && sameCwd(state.cwd, event.cwd)) return state;
+ return { ...state, cwd: event.cwd };
+ case 'promptStart':
+ 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' && 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 };
+ 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: event.startedAt ?? now(),
+ source,
+ },
+ activity: { kind: 'running' },
+ pendingCommandLine: null,
+ };
+ }
+ case 'commandFinish': {
+ if (!state.currentCommand) {
+ const next = finishedActivity(event.exitCode);
+ 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,
+ exitCode: event.exitCode,
+ ...(finalTerminalTitle ? { finalTerminalTitle } : {}),
+ };
+ return {
+ ...state,
+ currentCommand: null,
+ lastCommand: finishedCommand,
+ activity: finishedActivity(event.exitCode),
+ };
+ }
+ 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,
+ [event.title.source]: 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 && a.updatedAt === b.updatedAt;
+}
+
+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 {
+ 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 {
+ const uniqueCwds = uniqueByIdentity(cwds);
+ let labels = new Map();
+ 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();
+ 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 = latestTerminalTitleCandidate(state)?.title.trim();
+ if (title) return title;
+ return options.shellName?.trim() || DEFAULT_COMMAND_TITLE;
+}
+
+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;
+ return derivedPrimary;
+}
+
+export function deriveHeader(
+ pane: TerminalPaneState,
+ visiblePanes: TerminalPaneState[],
+ options: HeaderOptions = {},
+): DerivedHeader {
+ const primary = headerPrimary(pane, options);
+ 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;
+ }
+ }
+
+ return { primary, secondary };
+}
+
+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 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,
+ activityStates: Map,
+): (pane: TerminalPaneState) => string | null {
+ const titlesByPane = new WeakMap();
+ 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 titleCandidatesForDisplay(pane: TerminalPaneState): TerminalTitle[] {
+ 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 {
+ 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 'user':
+ return 'user';
+ }
+}
+
+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 = 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;
+ 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: 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();
+ 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): Set {
+ const counts = new Map();
+ 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,
+ options: DirectoryDisplayOptions,
+): Map {
+ const result = new Map(baseLabels);
+ const hostMode = options.includeHost ?? 'auto';
+ const groups = new Map();
+ 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 {
+ const userTitle = titleCandidateForSource(pane, 'user')?.title.trim();
+ if (userTitle) return userTitle;
+ 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 && isAppTitleFreshFor(pane, command)) return appTitle;
+ const terminalTitle = terminalTitleForCommand(pane, command);
+ if (terminalTitle) return terminalTitle;
+ 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 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;
+ if (osc9.updatedAt < command.startedAt) return false;
+ if (command.finishedAt !== undefined && osc9.updatedAt > command.finishedAt) return false;
+ return true;
+}
+
+function idleLabel(pane: TerminalPaneState): string {
+ const userTitle = titleCandidateForSource(pane, 'user')?.title.trim();
+ if (userTitle) return userTitle;
+ return DEFAULT_IDLE_TITLE;
+}
+
+const HEADER_APP_TITLE_SOURCES: TerminalTitleSource[] = ['osc0', 'osc2', 'osc9'];
+
+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 {
+ if (pane.currentCommand?.cwdAtStart) return pane.currentCommand.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();
+ 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()];
+}
+
+function latestTerminalTitleCandidate(state: TerminalPaneState | null | undefined): TerminalTitle | null {
+ if (!state) return 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 {
+ return pane.titleCandidates[source] ?? null;
+}
+
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/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>) {
+function withState(items: DooredItem[], byId: Record>) {
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 (
@@ -38,11 +55,34 @@ const meta: Meta
= {
export default meta;
type Story = StoryObj;
+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',
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
new file mode 100644
index 0000000..1201fde
--- /dev/null
+++ b/lib/src/stories/ShellCwd.stories.tsx
@@ -0,0 +1,451 @@
+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: () => ({ accepted: true }),
+ onCancelRename: () => {},
+};
+
+const meta: Meta = {
+ title: 'Terminal State/Shell and CWD',
+ component: ShellCwdMatrix,
+ parameters: {
+ controls: { disable: true },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+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', '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'),
+ 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-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'),
+ 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'),
+ 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 (
+
+
+ {cases.map((item) => {
+ const header = deriveHeader(item.state, states);
+ return (
+
+
+
{item.label}
+ {item.note &&
{item.note}
}
+
+
+
+
+
+
+
+
+
+ );
+ })}
+
+ {showGroups &&
}
+
+ );
+}
+
+function HeaderPreview({ id, title }: { id: string; title: string }) {
+ const mockApi = { id, title } as any;
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function BaseboardPreview({ id, title }: { id: string; title: string }) {
+ return (
+ {}}
+ />
+ );
+}
+
+function DerivedBadge({
+ state,
+ header,
+}: {
+ state: TerminalPaneState;
+ header: ReturnType;
+}) {
+ const exit = state.activity.kind === 'finished' ? state.activity.exitCode : undefined;
+ const status = state.activity.kind;
+ return (
+
+
{status}{exit !== undefined ? ` ${exit}` : ''}
+
{header.secondary ?? 'no secondary'}
+
+ );
+}
+
+function GroupingPreview({ cases }: { cases: ShellCwdCase[] }) {
+ const panes = cases.map((item) => item.state);
+ return (
+
+ {(['directory', 'command', 'status'] as const).map((mode) => (
+
+ {mode}
+
+ {groupTerminalPanes(panes, mode).map((group) => (
+
+ {group.label}
+ : {casesForGroup(cases, group.panes).join(', ')}
+
+ ))}
+
+
+ ))}
+
+ );
+}
+
+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 terminalTitleAt(title, source, BASE_TIME);
+}
+
+function terminalTitleAt(title: string, source: TerminalTitle['source'], updatedAt: number): TerminalTitle {
+ 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('[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,
+ 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)!;
+}
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) {
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 (
-
+
(
+ `[data-renaming-input-for="${SESSION_ID}"]`,
+ ) ?? document.querySelector
('input');
+ if (!input) return;
+ input.value = '';
+ 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,
+};
diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts
index d11e72c..559bee3 100644
--- a/standalone/src/tauri-adapter.ts
+++ b/standalone/src/tauri-adapter.ts
@@ -4,9 +4,13 @@ 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,
+} from "mouseterm-lib/lib/terminal-state-store";
function invoke(cmd: string, args?: Record): void {
rawInvoke(cmd, args).catch((err) =>
@@ -54,6 +58,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 });
}
@@ -69,6 +74,7 @@ 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);
for (const handler of this.exitHandlers) {
handler(event.payload);
}
@@ -85,8 +91,14 @@ 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));
for (const handler of this.replayHandlers) {
- handler(event.payload);
+ handler({ id, data: parsed.visibleData });
}
}),
);
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();
// the protocol parser once per chunk regardless of webview count.
type ProcessedDataListener = (id: string, visibleData: string) => void;
const processedDataListeners = new Set();
+type SemanticEventsListener = (id: string, events: TerminalSemanticEvent[]) => void;
+const semanticEventsListeners = new Set();
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 }