From 2d2d37588e0a333ec1555dcc020544195b85a65a Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 9 May 2026 21:22:02 -0700 Subject: [PATCH 1/3] Add untouched shell close behavior --- docs/specs/layout.md | 5 +- docs/specs/transport.md | 2 + lib/src/components/Wall.tsx | 41 +++++-- .../wall/keyboard/handle-kill-confirm.ts | 5 +- .../keyboard/handle-pane-shortcuts.test.ts | 115 ++++++++++++++++++ .../wall/keyboard/handle-pane-shortcuts.ts | 14 ++- lib/src/components/wall/keyboard/types.ts | 5 +- lib/src/lib/clipboard.ts | 3 +- lib/src/lib/reconnect.test.ts | 41 +++++++ lib/src/lib/reconnect.ts | 15 +-- lib/src/lib/session-migration.test.ts | 21 +++- lib/src/lib/session-restore.test.ts | 17 +++ lib/src/lib/session-restore.ts | 1 + lib/src/lib/session-save.test.ts | 22 ++++ lib/src/lib/session-save.ts | 3 +- lib/src/lib/session-types.ts | 29 ++++- lib/src/lib/terminal-lifecycle.ts | 33 +++-- lib/src/lib/terminal-registry.alert.test.ts | 82 +++++++++++++ lib/src/lib/terminal-registry.ts | 2 + lib/src/lib/terminal-store.ts | 1 + 20 files changed, 415 insertions(+), 42 deletions(-) create mode 100644 lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts diff --git a/docs/specs/layout.md b/docs/specs/layout.md index f9c0697..d25e013 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -181,6 +181,8 @@ All handled in a single capture-phase `keydown` listener on `window`. Every hand Pressing `x` (or clicking the kill button) enters command mode and shows a pane-centered semi-transparent overlay (`KillConfirmOverlay` → `KillConfirmCard`) with a random uppercase letter (A-Z, excluding X). Typing that letter confirms the kill (destroys session, removes pane). Cancel with Escape key, clicking the `[ESC] to cancel` button, or clicking another panel. Any other key triggers a shake animation (400ms `shake-x` keyframe) then auto-dismisses the confirmation. +Untouched sessions skip this confirmation. A newly spawned shell starts `untouched: true`; the first user-originated PTY input flips it to false. Inputs that count include printable keys, Enter, control keys, keyboard CSI such as arrows/history, paste, and file-drop path insertion. Replay-time terminal reports, synthetic terminal reports, and stripped mouse-report-only input do not count. Killing an untouched pane runs the normal kill animation/dispose path immediately. Killing an untouched door first reattaches it only far enough to reuse the same pane removal path, then kills it without showing the confirmation overlay. + ## Selection overlay A fixed-positioned element rendered on top of dockview. Covers the active element's area inflated by 3px (half the 6px gap) for panes, or 2px for doors. @@ -268,6 +270,7 @@ Pane IDs are session IDs. `TerminalPane` calls `getOrCreateTerminal(id)` on Reac - **Create**: `getOrCreateTerminal` spawns xterm.js + FitAddon + PTY, returns existing if already created - **Resume**: `resumeTerminal` creates xterm entry and writes replay data without spawning a new PTY. Used when the webview is recreated while the host retains Live PTYs (Link: Severed → Resuming → Live). - **Restore**: `restoreTerminal` creates xterm entry and spawns a new PTY with saved cwd and scrollback. Used on cold start from a saved Snapshot (Link: Cold → Live). +- **Untouched**: new `getOrCreateTerminal` sessions start untouched. `isUntouched(id)` exposes the flag, and user-originated PTY input clears it via the registry input paths. Resume/restore seed the persisted flag; missing legacy snapshot data defaults to touched (`false`) so close confirmation remains conservative. - During resume/restore replay, xterm.js may emit terminal-generated replies for OSC/CSI/DCS queries that were embedded in saved output. The registry drops those replay-time replies before they reach the new shell. This filter is limited to query/focus reports, and must not swallow user keyboard escape sequences such as arrows, function keys, or bracketed paste. - **mount / unmount (DOM)**: `mountElement` reparents the persistent DOM element into a container; `unmountElement` removes it. The Registry entry survives. - **Dispose**: `disposeSession` kills the PTY, disposes xterm, removes the registry entry. Only called on explicit kill (`x`). @@ -275,7 +278,7 @@ Pane IDs are session IDs. `TerminalPane` calls `getOrCreateTerminal(id)` on Reac ### Session persistence -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. +Layout, scrollback, cwd, minimized items, user-pinned titles, untouched state, and alert state are saved to persistent storage via a debounced save (500ms). Derived command/app labels shown on minimized doors are display-only and are not persisted as user-pinned titles. Saves are triggered by layout changes, panel add/remove, and a 30s periodic interval. Saves are flushed immediately on PTY exit, `pagehide`, and extension shutdown requests. Saved snapshots are read through `readPersistedSession()`, which accepts the canonical object shape and defensively parses a JSON-stringified blob before validation and migration. This keeps malformed storage inert while covering hosts that hand back serialized JSON instead of the parsed object. diff --git a/docs/specs/transport.md b/docs/specs/transport.md index 9a03b38..0914d9b 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -126,6 +126,7 @@ interface PersistedPane { title: string; scrollback: string | null; resumeCommand: string | null; + untouched: boolean; alert?: PersistedAlertState | null; } @@ -155,5 +156,6 @@ These rules apply to every adapter. Adapter-specific layering (deactivate orderi - **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. +- **Untouched defaults conservatively.** New saved panes include `untouched`. Older saved panes without the field are read as `untouched: false`, so legacy sessions still require kill confirmation. - **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/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 7f236e8..1e54c72 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -16,10 +16,12 @@ import { toggleSessionTodo, setPendingShellOpts, getDefaultShellOpts, + isUntouched, setTerminalUserTitle, UNNAMED_PANEL_TITLE, type SessionStatus, } from '../lib/terminal-registry'; +import { orchestrateKill } from '../lib/kill-animation'; import { findReattachNeighbor } from '../lib/spatial-nav'; import { cloneLayout, getLayoutStructureSignature } from '../lib/layout-snapshot'; import type { PersistedDoor } from '../lib/session-types'; @@ -209,14 +211,6 @@ export function Wall({ setConfirmKill({ ...ck, exit: 'shake' }); shakeTimerRef.current = setTimeout(() => setConfirmKill(null), KILL_SHAKE_MS); }, []); - const acceptKill = useCallback((onExit: () => void) => { - const ck = confirmKillRef.current; - if (!ck || ck.exit) return; - setConfirmKill({ ...ck, exit: 'confirm' }); - onExit(); - fireEvent({ type: 'kill', id: ck.id }); - confirmTimerRef.current = setTimeout(() => setConfirmKill(null), KILL_CONFIRM_MS); - }, [fireEvent]); useEffect(() => { onEventRef.current?.({ type: 'modeChange', mode }); }, [mode]); useEffect(() => { onEventRef.current?.({ type: 'zoomChange', zoomed }); }, [zoomed]); @@ -235,6 +229,21 @@ export function Wall({ if (panel) panel.api.setActive(); }, []); + const killPaneImmediately = useCallback((id: string) => { + const api = apiRef.current; + if (!api?.getPanel(id)) return; + orchestrateKill(api, id, selectPane, setSelectedId, killInProgressRef, overlayElRef); + fireEvent({ type: 'kill', id }); + }, [fireEvent, selectPane]); + + const acceptKill = useCallback(() => { + const ck = confirmKillRef.current; + if (!ck || ck.exit) return; + setConfirmKill({ ...ck, exit: 'confirm' }); + killPaneImmediately(ck.id); + confirmTimerRef.current = setTimeout(() => setConfirmKill(null), KILL_CONFIRM_MS); + }, [killPaneImmediately]); + /** Select a door in the baseboard */ const selectDoor = useCallback((id: string) => { selectedIdRef.current = id; @@ -347,12 +356,13 @@ export function Wall({ const handleReattach = useCallback(( item: DooredItem, - options?: { enterPassthrough?: boolean; confirmKill?: boolean }, + options?: { enterPassthrough?: boolean; confirmKill?: boolean; killImmediately?: boolean }, ) => { const api = apiRef.current; if (!api) return; const enterPassthrough = options?.enterPassthrough ?? true; const confirmKillAfterRestore = options?.confirmKill ?? false; + const killImmediatelyAfterRestore = options?.killImmediately ?? false; const currentLayoutSignature = getLayoutStructureSignature(api.toJSON()); // Exact reattach is only safe when the layout structure matches AND the @@ -424,12 +434,14 @@ export function Wall({ // Guard against panel removal between scheduling and execution if (!apiRef.current?.getPanel(item.id)) return; focusSession(item.id, false); - if (confirmKillAfterRestore) { + if (killImmediatelyAfterRestore) { + killPaneImmediately(item.id); + } else if (confirmKillAfterRestore) { setConfirmKill({ id: item.id, char: randomKillChar() }); } }); } - }, [selectPane, enterTerminalMode]); + }, [selectPane, enterTerminalMode, killPaneImmediately]); const handleReattachRef = useRef(handleReattach); handleReattachRef.current = handleReattach; @@ -494,6 +506,10 @@ export function Wall({ const wallActions: WallActions = useMemo(() => ({ onKill: (id: string) => { exitTerminalMode(); + if (isUntouched(id)) { + killPaneImmediately(id); + return; + } const char = randomKillChar(); setConfirmKill({ id, char }); }, @@ -546,7 +562,7 @@ export function Wall({ onCancelRename: () => { setRenamingPaneId(null); }, - }), [addSplitPanel, minimizePane, enterTerminalMode, exitTerminalMode]); + }), [addSplitPanel, minimizePane, enterTerminalMode, exitTerminalMode, killPaneImmediately]); const wallActionsRef = useRef(wallActions); wallActionsRef.current = wallActions; @@ -569,6 +585,7 @@ export function Wall({ enterTerminalMode, exitTerminalMode, minimizePane, + killPaneImmediately, acceptKill, rejectKill, setConfirmKill, diff --git a/lib/src/components/wall/keyboard/handle-kill-confirm.ts b/lib/src/components/wall/keyboard/handle-kill-confirm.ts index 4b8e34f..b68f215 100644 --- a/lib/src/components/wall/keyboard/handle-kill-confirm.ts +++ b/lib/src/components/wall/keyboard/handle-kill-confirm.ts @@ -1,4 +1,3 @@ -import { orchestrateKill } from '../../../lib/kill-animation'; import type { WallKeyboardCtx } from './types'; /** @@ -15,9 +14,7 @@ export function handleKillConfirm(e: KeyboardEvent, ctx: WallKeyboardCtx): boole const api = ctx.apiRef.current; if (e.key.toLowerCase() === ck.char.toLowerCase() && api) { - ctx.acceptKill(() => - orchestrateKill(api, ck.id, ctx.selectPane, ctx.setSelectedId, ctx.killInProgressRef, ctx.overlayElRef), - ); + ctx.acceptKill(); return true; } ctx.rejectKill(); diff --git a/lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts b/lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts new file mode 100644 index 0000000..fbf4278 --- /dev/null +++ b/lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts @@ -0,0 +1,115 @@ +/** + * @vitest-environment jsdom + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { handlePaneShortcuts } from './handle-pane-shortcuts'; +import type { WallKeyboardCtx } from './types'; + +const terminalRegistryMocks = vi.hoisted(() => ({ + dismissOrToggleAlert: vi.fn(), + getActivity: vi.fn(() => ({ status: 'ALERT_DISABLED' })), + isUntouched: vi.fn(), + swapTerminals: vi.fn(), + toggleSessionTodo: vi.fn(), +})); + +vi.mock('../../../lib/terminal-registry', () => ({ + dismissOrToggleAlert: terminalRegistryMocks.dismissOrToggleAlert, + getActivity: terminalRegistryMocks.getActivity, + isUntouched: terminalRegistryMocks.isUntouched, + swapTerminals: terminalRegistryMocks.swapTerminals, + toggleSessionTodo: terminalRegistryMocks.toggleSessionTodo, +})); + +vi.mock('../../KillConfirm', () => ({ + randomKillChar: () => 'Q', +})); + +function makeCtx(overrides: Partial = {}): WallKeyboardCtx { + return { + apiRef: { current: {} }, + modeRef: { current: 'command' }, + selectedIdRef: { current: 'pane-a' }, + selectedTypeRef: { current: 'pane' }, + doorsRef: { current: [{ id: 'pane-a', title: 'Pane A' }] }, + dialogKeyboardActiveRef: { current: false }, + paneElements: new Map(), + wallActionsRef: { + current: { + onSplitH: vi.fn(), + onSplitV: vi.fn(), + onZoom: vi.fn(), + }, + }, + handleReattachRef: { current: vi.fn() }, + enterTerminalMode: vi.fn(), + killPaneImmediately: vi.fn(), + setConfirmKill: vi.fn(), + setRenamingPaneId: vi.fn(), + fireEvent: vi.fn(), + ...overrides, + } as unknown as WallKeyboardCtx; +} + +function keydown(key: string): KeyboardEvent { + return new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }); +} + +describe('handlePaneShortcuts kill behavior', () => { + beforeEach(() => { + vi.clearAllMocks(); + terminalRegistryMocks.isUntouched.mockReturnValue(false); + }); + + it('kills untouched panes immediately without staging confirmation', () => { + terminalRegistryMocks.isUntouched.mockReturnValue(true); + const ctx = makeCtx(); + const event = keydown('x'); + + expect(handlePaneShortcuts(event, ctx, { current: null })).toBe(true); + + expect(ctx.killPaneImmediately).toHaveBeenCalledWith('pane-a'); + expect(ctx.setConfirmKill).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(true); + }); + + it('keeps confirmation for touched panes', () => { + const ctx = makeCtx(); + + expect(handlePaneShortcuts(keydown('x'), ctx, { current: null })).toBe(true); + + expect(ctx.killPaneImmediately).not.toHaveBeenCalled(); + expect(ctx.setConfirmKill).toHaveBeenCalledWith({ id: 'pane-a', char: 'Q' }); + }); + + it('reattaches untouched doors into an immediate kill path', () => { + terminalRegistryMocks.isUntouched.mockReturnValue(true); + const reattach = vi.fn(); + const ctx = makeCtx({ + selectedTypeRef: { current: 'door' }, + handleReattachRef: { current: reattach }, + }); + + expect(handlePaneShortcuts(keydown('x'), ctx, { current: null })).toBe(true); + + expect(reattach).toHaveBeenCalledWith( + { id: 'pane-a', title: 'Pane A' }, + { enterPassthrough: false, killImmediately: true }, + ); + }); + + it('reattaches touched doors into the confirmation path', () => { + const reattach = vi.fn(); + const ctx = makeCtx({ + selectedTypeRef: { current: 'door' }, + handleReattachRef: { current: reattach }, + }); + + expect(handlePaneShortcuts(keydown('x'), ctx, { current: null })).toBe(true); + + expect(reattach).toHaveBeenCalledWith( + { id: 'pane-a', title: 'Pane A' }, + { enterPassthrough: false, confirmKill: true }, + ); + }); +}); diff --git a/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts b/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts index 62ab782..9b01133 100644 --- a/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts +++ b/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts @@ -2,6 +2,7 @@ import { findPaneInDirection } from '../../../lib/spatial-nav'; import { dismissOrToggleAlert, getActivity, + isUntouched, swapTerminals, toggleSessionTodo, } from '../../../lib/terminal-registry'; @@ -82,7 +83,18 @@ export function handlePaneShortcuts( e.stopPropagation(); if (ctx.selectedTypeRef.current === 'door') { const item = ctx.doorsRef.current.find((d) => d.id === sid); - if (item) ctx.handleReattachRef.current(item, { enterPassthrough: false, confirmKill: true }); + if (item) { + ctx.handleReattachRef.current( + item, + isUntouched(sid) + ? { enterPassthrough: false, killImmediately: true } + : { enterPassthrough: false, confirmKill: true }, + ); + } + return true; + } + if (isUntouched(sid)) { + ctx.killPaneImmediately(sid); return true; } const char = randomKillChar(); diff --git a/lib/src/components/wall/keyboard/types.ts b/lib/src/components/wall/keyboard/types.ts index 4653b77..a3626ca 100644 --- a/lib/src/components/wall/keyboard/types.ts +++ b/lib/src/components/wall/keyboard/types.ts @@ -19,13 +19,14 @@ export interface WallKeyboardCtx { killInProgressRef: RefObject; overlayElRef: RefObject; wallActionsRef: RefObject; - handleReattachRef: RefObject<(item: DooredItem, options?: { enterPassthrough?: boolean; confirmKill?: boolean }) => void>; + handleReattachRef: RefObject<(item: DooredItem, options?: { enterPassthrough?: boolean; confirmKill?: boolean; killImmediately?: boolean }) => void>; selectPane: (id: string) => void; selectDoor: (id: string) => void; enterTerminalMode: (id: string) => void; exitTerminalMode: () => void; minimizePane: (id: string) => void; - acceptKill: (onExit: () => void) => void; + killPaneImmediately: (id: string) => void; + acceptKill: () => void; rejectKill: () => void; setConfirmKill: Dispatch>; setRenamingPaneId: Dispatch>; diff --git a/lib/src/lib/clipboard.ts b/lib/src/lib/clipboard.ts index 29837d1..0d2201d 100644 --- a/lib/src/lib/clipboard.ts +++ b/lib/src/lib/clipboard.ts @@ -3,7 +3,7 @@ import { rewrap } from './rewrap'; import { extractSelectionText } from './selection-text'; import { getPlatform } from './platform'; import { shellEscapePath } from './shell-escape'; -import { getTerminalInstance } from './terminal-registry'; +import { getTerminalInstance, markSessionTouched } from './terminal-registry'; async function writeText(text: string): Promise { if (!text) return; @@ -47,6 +47,7 @@ function writePasteToPty(terminalId: string, text: string): void { if (!text) return; const bracketed = getMouseSelectionState(terminalId).bracketedPaste; const payload = bracketed ? `\x1b[200~${text}\x1b[201~` : text; + markSessionTouched(terminalId); getPlatform().writePty(terminalId, payload); } diff --git a/lib/src/lib/reconnect.test.ts b/lib/src/lib/reconnect.test.ts index 14cec0e..ab8a96a 100644 --- a/lib/src/lib/reconnect.test.ts +++ b/lib/src/lib/reconnect.test.ts @@ -135,6 +135,47 @@ describe('resumeOrRestore', () => { }); }); + it('seeds saved untouched state when resuming live PTYs', async () => { + const saved: PersistedSession = { + version: 3, + layout: { panels: { 'pane-a': {} } }, + panes: [ + { id: 'pane-a', title: 'Pane A', cwd: null, scrollback: null, resumeCommand: null, untouched: true }, + ], + }; + + await resumeOrRestore(createPlatform([ + { id: 'pane-a', alive: true }, + ], saved)); + + expect(terminalRegistryMocks.resumeTerminal).toHaveBeenCalledWith('pane-a', 'pane-a-replay', { + alive: true, + exitCode: undefined, + title: 'Pane A', + untouched: true, + }); + }); + + it('defaults missing saved untouched state to touched when resuming live PTYs', async () => { + const saved = { + version: 3 as const, + layout: { panels: { 'pane-a': {} } }, + panes: [ + { id: 'pane-a', title: 'Pane A', cwd: null, scrollback: null, resumeCommand: null }, + ], + }; + + await resumeOrRestore(createPlatform([ + { id: 'pane-a', alive: true }, + ], saved as PersistedSession)); + + expect(terminalRegistryMocks.resumeTerminal).toHaveBeenCalledWith('pane-a', 'pane-a-replay', { + alive: true, + exitCode: undefined, + title: 'Pane A', + }); + }); + it('seeds saved minimized door titles when resuming live PTYs', async () => { const saved: PersistedSession = { version: 3, diff --git a/lib/src/lib/reconnect.ts b/lib/src/lib/reconnect.ts index 99d5c2c..46b9e3b 100644 --- a/lib/src/lib/reconnect.ts +++ b/lib/src/lib/reconnect.ts @@ -64,15 +64,16 @@ function resumeLiveSessions(platform: PlatformAdapter): Promise pty.id)); + const savedResumeInfo = getSavedPaneResumeInfo(savedState, ptyList.map((pty) => pty.id)); const ids: string[] = []; for (const pty of ptyList) { - const resumeInfo: { alive: boolean; exitCode?: number; title?: string } = { + const resumeInfo: { alive: boolean; exitCode?: number; title?: string; untouched?: boolean } = { alive: pty.alive, exitCode: pty.exitCode, }; - const savedTitle = savedTitles.get(pty.id); - if (savedTitle !== undefined) resumeInfo.title = savedTitle; + const savedInfo = savedResumeInfo.get(pty.id); + if (savedInfo?.title !== undefined) resumeInfo.title = savedInfo.title; + if (savedInfo?.untouched) resumeInfo.untouched = true; resumeTerminal(pty.id, replayBuffer.get(pty.id) ?? null, resumeInfo); ids.push(pty.id); } @@ -94,15 +95,15 @@ function resumeLiveSessions(platform: PlatformAdapter): Promise { +function getSavedPaneResumeInfo(savedState: unknown, liveIds: string[]): Map { const saved = readPersistedSession(savedState); if (!saved || !Array.isArray(saved.panes)) return new Map(); const liveSet = new Set(liveIds); - const result = new Map(); + const result = new Map(); for (const pane of saved.panes) { if (!liveSet.has(pane.id)) continue; - result.set(pane.id, pane.title); + result.set(pane.id, { title: pane.title, untouched: pane.untouched }); } return result; } diff --git a/lib/src/lib/session-migration.test.ts b/lib/src/lib/session-migration.test.ts index f683aab..1e8c20a 100644 --- a/lib/src/lib/session-migration.test.ts +++ b/lib/src/lib/session-migration.test.ts @@ -81,6 +81,7 @@ describe('session migration v2 → v3', () => { }; const v3 = migrateSessionV2toV3(v2); expect(v3.panes[0].alert?.todo).toBe(true); + expect(v3.panes[0].untouched).toBe(false); expect(v3.version).toBe(3); }); @@ -195,17 +196,30 @@ describe('readPersistedSession', () => { const v3 = { version: 3 as const, layout: null, - panes: [{ id: 'pane-a', title: 'Pane A', cwd: null, scrollback: null, resumeCommand: null }], + panes: [{ id: 'pane-a', title: 'Pane A', cwd: null, scrollback: null, resumeCommand: null, untouched: true }], doors: [], }; expect(readPersistedSession(v3)).toBe(v3); }); + it('defaults missing v3 untouched state to false', () => { + const v3 = { + version: 3 as const, + layout: null, + panes: [{ id: 'pane-a', title: 'Pane A', cwd: null, scrollback: null, resumeCommand: null }], + doors: [], + }; + expect(readPersistedSession(v3)).toEqual({ + ...v3, + panes: [{ ...v3.panes[0], untouched: false }], + }); + }); + it('reads a JSON-stringified v3 blob', () => { const v3 = { version: 3 as const, layout: null, - panes: [{ id: 'pane-a', title: 'Pane A', cwd: null, scrollback: 'saved output', resumeCommand: null }], + panes: [{ id: 'pane-a', title: 'Pane A', cwd: null, scrollback: 'saved output', resumeCommand: null, untouched: true }], doors: [], }; @@ -216,7 +230,7 @@ describe('readPersistedSession', () => { const v3 = { version: 3 as const, layout: null, - panes: [{ id: 'pane-a', title: 'Pane A', cwd: null, scrollback: '\u001b[31mred', resumeCommand: null }], + panes: [{ id: 'pane-a', title: 'Pane A', cwd: null, scrollback: '\u001b[31mred', resumeCommand: null, untouched: false }], doors: [], }; @@ -234,6 +248,7 @@ describe('readPersistedSession', () => { cwd: null, scrollback: null, resumeCommand: null, + untouched: false, alert: { status: 'ALERT_RINGING' as const, todo: true, diff --git a/lib/src/lib/session-restore.test.ts b/lib/src/lib/session-restore.test.ts index 2855dfd..1defd21 100644 --- a/lib/src/lib/session-restore.test.ts +++ b/lib/src/lib/session-restore.test.ts @@ -83,6 +83,23 @@ describe('restoreSession', () => { title: 'Pane A', shell: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', args: ['-NoLogo'], + untouched: false, }); }); + + it('seeds restored untouched state', () => { + const saved: PersistedSession = { + version: 3, + layout: { panels: { 'pane-a': {} } }, + panes: [ + { id: 'pane-a', title: 'Pane A', cwd: null, scrollback: null, resumeCommand: null, untouched: true }, + ], + }; + + restoreSession(createPlatform(saved)); + + expect(terminalRegistryMocks.restoreTerminal).toHaveBeenCalledWith('pane-a', expect.objectContaining({ + untouched: true, + })); + }); }); diff --git a/lib/src/lib/session-restore.ts b/lib/src/lib/session-restore.ts index abd3947..103895f 100644 --- a/lib/src/lib/session-restore.ts +++ b/lib/src/lib/session-restore.ts @@ -22,6 +22,7 @@ export function restoreSession(platform: PlatformAdapter): RestoredSession | nul title: pane.title, shell: shellOpts?.shell, args: shellOpts?.args, + untouched: pane.untouched, }); } diff --git a/lib/src/lib/session-save.test.ts b/lib/src/lib/session-save.test.ts index 0c3e16a..e193388 100644 --- a/lib/src/lib/session-save.test.ts +++ b/lib/src/lib/session-save.test.ts @@ -5,12 +5,14 @@ import type { PersistedSession } from './session-types'; const terminalRegistryMocks = vi.hoisted(() => ({ getLivePersistedAlertState: vi.fn(), getTerminalPaneState: vi.fn(), + isUntouched: vi.fn(), resolveTerminalSessionId: vi.fn(), })); vi.mock('./terminal-registry', () => ({ getLivePersistedAlertState: terminalRegistryMocks.getLivePersistedAlertState, getTerminalPaneState: terminalRegistryMocks.getTerminalPaneState, + isUntouched: terminalRegistryMocks.isUntouched, resolveTerminalSessionId: terminalRegistryMocks.resolveTerminalSessionId, })); @@ -70,6 +72,7 @@ describe('saveSession', () => { terminalRegistryMocks.resolveTerminalSessionId.mockImplementation((id: string) => id); terminalRegistryMocks.getLivePersistedAlertState.mockReturnValue(null); terminalRegistryMocks.getTerminalPaneState.mockReturnValue({ titleCandidates: {} }); + terminalRegistryMocks.isUntouched.mockReturnValue(false); }); it('persists the live alert state even when the previous snapshot was empty', async () => { @@ -184,4 +187,23 @@ describe('saveSession', () => { ], }); }); + + it('persists untouched state from the live registry entry', async () => { + const platform = createPlatform(null); + terminalRegistryMocks.isUntouched.mockReturnValue(true); + + await saveSession(platform, { root: true }, [{ id: 'pane-a', title: 'Pane A' }]); + + expect(platform.saveState).toHaveBeenCalledWith({ + version: 3, + layout: { root: true }, + doors: [], + panes: [ + expect.objectContaining({ + id: 'pane-a', + untouched: true, + }), + ], + }); + }); }); diff --git a/lib/src/lib/session-save.ts b/lib/src/lib/session-save.ts index 63a44fe..07f3512 100644 --- a/lib/src/lib/session-save.ts +++ b/lib/src/lib/session-save.ts @@ -1,7 +1,7 @@ import type { PlatformAdapter } from './platform/types'; import { readPersistedSession, type PersistedDoor, type PersistedPane, type PersistedSession } from './session-types'; import { detectResumeCommand } from './resume-patterns'; -import { getLivePersistedAlertState, getTerminalPaneState, resolveTerminalSessionId } from './terminal-registry'; +import { getLivePersistedAlertState, getTerminalPaneState, isUntouched, resolveTerminalSessionId } from './terminal-registry'; import { UNNAMED_PANEL_TITLE } from './terminal-state'; function getPreviousPaneMap(platform: PlatformAdapter): Map { @@ -47,6 +47,7 @@ export async function saveSession( cwd: cwd ?? previousPane?.cwd ?? null, scrollback: resolvedScrollback, resumeCommand: resolvedScrollback ? detectResumeCommand(resolvedScrollback) : null, + untouched: isUntouched(pane.id), alert: liveAlert ?? previousPane?.alert ?? null, }; }), diff --git a/lib/src/lib/session-types.ts b/lib/src/lib/session-types.ts index 7727002..baaf37c 100644 --- a/lib/src/lib/session-types.ts +++ b/lib/src/lib/session-types.ts @@ -14,6 +14,7 @@ export interface PersistedPane { title: string; scrollback: string | null; resumeCommand: string | null; + untouched: boolean; alert?: PersistedAlertState | null; } @@ -34,6 +35,15 @@ export interface PersistedSession { layout: unknown; // SerializedDockview — kept as `unknown` to avoid dockview dep in types } +type PersistedPaneInput = Omit & { untouched?: boolean }; + +interface PersistedSessionV3Input { + version: 3; + panes: PersistedPaneInput[]; + doors?: PersistedDoor[]; + layout: unknown; +} + // --- Legacy v2 shapes (read-only, for migration) --- export interface PersistedAlertStateV2 { @@ -108,6 +118,7 @@ function isPersistedPaneShape(value: unknown): boolean { (typeof value.cwd === 'string' || value.cwd === null) && (typeof value.scrollback === 'string' || value.scrollback === null) && (typeof value.resumeCommand === 'string' || value.resumeCommand === null) && + (value.untouched === undefined || typeof value.untouched === 'boolean') && (value.alert === undefined || isPersistedAlertShape(value.alert)) ); } @@ -158,7 +169,7 @@ function isPersistedSessionV2(value: unknown): value is PersistedSessionV2 { ); } -function isPersistedSessionV3(value: unknown): value is PersistedSession { +function isPersistedSessionV3(value: unknown): value is PersistedSessionV3Input { if (!isRecord(value) || value.version !== 3) return false; return ( Array.isArray(value.panes) && @@ -194,6 +205,7 @@ export function migrateSessionV2toV3(v2: PersistedSessionV2): PersistedSession { doors: v2.doors, panes: v2.panes.map((pane) => ({ ...pane, + untouched: false, alert: pane.alert ? { status: pane.alert.status, todo: migrateTodoState(pane.alert.todo) } : pane.alert, @@ -204,12 +216,25 @@ export function migrateSessionV2toV3(v2: PersistedSessionV2): PersistedSession { export function readPersistedSession(raw: unknown): PersistedSession | null { const value = parseJsonString(raw); if (!isRecord(value)) return null; - if (isPersistedSessionV3(value)) return value; + if (isPersistedSessionV3(value)) return normalizeSessionV3(value); if (isPersistedSessionV2(value)) return migrateSessionV2toV3(value); if (isPersistedSessionV1(value)) return migrateSessionV2toV3(migrateSessionV1toV2(value)); return null; } +function normalizeSessionV3(session: PersistedSessionV3Input): PersistedSession { + if (session.panes.every((pane) => typeof pane.untouched === 'boolean')) { + return session as PersistedSession; + } + return { + ...session, + panes: session.panes.map((pane) => ({ + ...pane, + untouched: pane.untouched ?? false, + })), + }; +} + function parseJsonString(raw: unknown): unknown { if (typeof raw !== 'string') return raw; try { diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index b29fc84..c9ed5ff 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -102,9 +102,15 @@ function wireXtermHandlers( if (input.length === 0) return; } - const isSyntheticTerminalReport = inputIsSyntheticTerminalReport(input); + const isReplayTerminalReport = inputIsReplayTerminalReport(input); + + if (isReplayTerminalReport && registry.get(id)?.isReplaying) return; - if (inputIsReplayTerminalReport(input) && registry.get(id)?.isReplaying) return; + if (!isReplayTerminalReport) { + markSessionTouched(id); + } + + const isSyntheticTerminalReport = inputIsSyntheticTerminalReport(input); if (!isSyntheticTerminalReport) { recordTerminalUserInputByPtyId(id, input); @@ -149,7 +155,7 @@ function wireXtermHandlers( }; } -function setupTerminalEntry(id: string): TerminalEntry { +function setupTerminalEntry(id: string, options: { untouched?: boolean } = {}): TerminalEntry { const { terminal, fit, element } = createXtermHost(); const selectionBaselineRef = { current: null as string | null }; @@ -184,6 +190,7 @@ function setupTerminalEntry(id: string): TerminalEntry { notification: null, attentionDismissedRing: false, isReplaying: false, + untouched: options.untouched ?? false, }; const primed = consumePrimedActivity(id); @@ -208,7 +215,7 @@ export function getOrCreateTerminal(id: string): TerminalEntry { const existing = registry.get(id); if (existing) return existing; - const entry = setupTerminalEntry(id); + const entry = setupTerminalEntry(id, { untouched: true }); resetTerminalPaneState(id); const shellOpts = pendingShellOpts.get(id); @@ -228,12 +235,12 @@ export function getOrCreateTerminal(id: string): TerminalEntry { export function resumeTerminal( id: string, replayData: string | null, - exitInfo?: { alive: boolean; exitCode?: number; title?: string | null }, + exitInfo?: { alive: boolean; exitCode?: number; title?: string | null; untouched?: boolean }, ): TerminalEntry { const existing = registry.get(id); if (existing) return existing; - const entry = setupTerminalEntry(id); + const entry = setupTerminalEntry(id, { untouched: exitInfo?.untouched ?? false }); if (replayData) { writeReplay(entry, replayData); @@ -251,12 +258,12 @@ export function resumeTerminal( export function restoreTerminal( id: string, - opts: { cwd?: string | null; scrollback?: string | null; title?: string | null; cwdWarning?: string | null; shell?: string; args?: string[] }, + opts: { cwd?: string | null; scrollback?: string | null; title?: string | null; cwdWarning?: string | null; shell?: string; args?: string[]; untouched?: boolean }, ): TerminalEntry { const existing = registry.get(id); if (existing) return existing; - const entry = setupTerminalEntry(id); + const entry = setupTerminalEntry(id, { untouched: opts.untouched ?? false }); resetTerminalPaneState(id); seedTerminalManualCwd(id, opts.cwd); const trimmedTitle = opts.title?.trim(); @@ -389,6 +396,16 @@ export function getTerminalOverlayDims(id: string): TerminalOverlayDims | null { }; } +export function isUntouched(id: string): boolean { + return registry.get(id)?.untouched ?? false; +} + +export function markSessionTouched(id: string): void { + const entry = registry.get(id); + if (!entry || !entry.untouched) return; + entry.untouched = false; +} + export function focusSession(id: string, focused: boolean): void { const entry = registry.get(id); if (!entry) return; diff --git a/lib/src/lib/terminal-registry.alert.test.ts b/lib/src/lib/terminal-registry.alert.test.ts index 0a0408e..00f2d1f 100644 --- a/lib/src/lib/terminal-registry.alert.test.ts +++ b/lib/src/lib/terminal-registry.alert.test.ts @@ -101,13 +101,17 @@ import { getOrCreateTerminal, getActivity, initAlertStateReceiver, + isUntouched, + markSessionTouched, markSessionAttention, markSessionTodo, + resumeTerminal, restoreTerminal, swapTerminals, toggleSessionAlert, toggleSessionTodo, } from './terminal-registry'; +import { pasteFilePaths } from './clipboard'; interface MockTerminalInstance { writes: string[]; @@ -248,6 +252,84 @@ describe('terminal-registry alert behavior', () => { vi.useRealTimers(); }); + it('starts brand-new sessions as untouched', () => { + const id = 'new-untouched'; + + createSession(id); + + expect(isUntouched(id)).toBe(true); + }); + + it('marks a session touched on first real terminal input', () => { + const id = 'typed-touched'; + const entry = createSession(id); + + entry.terminal.emitInput('x'); + + expect(isUntouched(id)).toBe(false); + }); + + it('does not mark synthetic terminal reports as touched', () => { + const id = 'synthetic-report-untouched'; + const entry = createSession(id); + + entry.terminal.emitInput('\x1b[I'); + + expect(isUntouched(id)).toBe(true); + }); + + it('does not mark replay-time terminal reports as touched', () => { + const id = 'replay-report-untouched'; + const entry = restoreTerminal(id, { scrollback: 'saved output', untouched: true }) as TestTerminalEntry; + + entry.terminal.emitInput('\x1b[?1;2c'); + + expect(isUntouched(id)).toBe(true); + }); + + it('marks a replayed session touched for user keyboard CSI input', () => { + const id = 'replay-arrow-touched'; + const entry = restoreTerminal(id, { scrollback: 'saved output', untouched: true }) as TestTerminalEntry; + + entry.terminal.emitInput('\x1b[A'); + + expect(isUntouched(id)).toBe(false); + }); + + it('marks paste and file-drop path insertion as touched', () => { + const id = 'paste-touched'; + createSession(id); + + pasteFilePaths(id, ['/tmp/example file.txt']); + + expect(isUntouched(id)).toBe(false); + }); + + it('keeps untouched state with session content when swapping panes', () => { + const alpha = 'swap-alpha'; + const beta = 'swap-beta'; + createSession(alpha); + createSession(beta); + + markSessionTouched(alpha); + swapTerminals(alpha, beta); + + expect(isUntouched(alpha)).toBe(true); + expect(isUntouched(beta)).toBe(false); + }); + + it('seeds untouched state on resume and restore while defaulting missing state to touched', () => { + resumeTerminal('resume-untouched', null, { alive: true, untouched: true }); + resumeTerminal('resume-legacy', null, { alive: true }); + restoreTerminal('restore-untouched', { untouched: true }); + restoreTerminal('restore-legacy', {}); + + expect(isUntouched('resume-untouched')).toBe(true); + expect(isUntouched('resume-legacy')).toBe(false); + expect(isUntouched('restore-untouched')).toBe(true); + expect(isUntouched('restore-legacy')).toBe(false); + }); + it('Story 1: quick response never becomes busy', () => { const id = 'story-1'; createSession( diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index 6f6ee0e..8439fdb 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -42,6 +42,8 @@ export { getOrCreateTerminal, getTerminalInstance, getTerminalOverlayDims, + isUntouched, + markSessionTouched, mountElement, refitSession, restoreTerminal, diff --git a/lib/src/lib/terminal-store.ts b/lib/src/lib/terminal-store.ts index 45daf7e..ae5faba 100644 --- a/lib/src/lib/terminal-store.ts +++ b/lib/src/lib/terminal-store.ts @@ -20,6 +20,7 @@ export interface TerminalEntry { notification: ActivityNotification | null; attentionDismissedRing: boolean; isReplaying: boolean; + untouched: boolean; } export interface TerminalOverlayDims { From 6d108b1cdaf4bb7da9814e9ef8b58c49207533a8 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 9 May 2026 21:47:48 -0700 Subject: [PATCH 2/3] Collapse handleReattach kill flags into a single afterRestore field Replaces mutually exclusive `confirmKill` / `killImmediately` booleans with `afterRestore: 'confirm-kill' | 'kill-immediately'`, removing the parallel option-object construction at the call site. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/components/Wall.tsx | 9 ++++----- .../wall/keyboard/handle-pane-shortcuts.test.ts | 4 ++-- .../components/wall/keyboard/handle-pane-shortcuts.ts | 10 ++++------ lib/src/components/wall/keyboard/types.ts | 2 +- 4 files changed, 11 insertions(+), 14 deletions(-) diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 1e54c72..975c3ec 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -356,13 +356,12 @@ export function Wall({ const handleReattach = useCallback(( item: DooredItem, - options?: { enterPassthrough?: boolean; confirmKill?: boolean; killImmediately?: boolean }, + options?: { enterPassthrough?: boolean; afterRestore?: 'confirm-kill' | 'kill-immediately' }, ) => { const api = apiRef.current; if (!api) return; const enterPassthrough = options?.enterPassthrough ?? true; - const confirmKillAfterRestore = options?.confirmKill ?? false; - const killImmediatelyAfterRestore = options?.killImmediately ?? false; + const afterRestore = options?.afterRestore; const currentLayoutSignature = getLayoutStructureSignature(api.toJSON()); // Exact reattach is only safe when the layout structure matches AND the @@ -434,9 +433,9 @@ export function Wall({ // Guard against panel removal between scheduling and execution if (!apiRef.current?.getPanel(item.id)) return; focusSession(item.id, false); - if (killImmediatelyAfterRestore) { + if (afterRestore === 'kill-immediately') { killPaneImmediately(item.id); - } else if (confirmKillAfterRestore) { + } else if (afterRestore === 'confirm-kill') { setConfirmKill({ id: item.id, char: randomKillChar() }); } }); diff --git a/lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts b/lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts index fbf4278..15a6ecc 100644 --- a/lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts +++ b/lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts @@ -94,7 +94,7 @@ describe('handlePaneShortcuts kill behavior', () => { expect(reattach).toHaveBeenCalledWith( { id: 'pane-a', title: 'Pane A' }, - { enterPassthrough: false, killImmediately: true }, + { enterPassthrough: false, afterRestore: 'kill-immediately' }, ); }); @@ -109,7 +109,7 @@ describe('handlePaneShortcuts kill behavior', () => { expect(reattach).toHaveBeenCalledWith( { id: 'pane-a', title: 'Pane A' }, - { enterPassthrough: false, confirmKill: true }, + { enterPassthrough: false, afterRestore: 'confirm-kill' }, ); }); }); diff --git a/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts b/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts index 9b01133..9480f63 100644 --- a/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts +++ b/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts @@ -84,12 +84,10 @@ export function handlePaneShortcuts( if (ctx.selectedTypeRef.current === 'door') { const item = ctx.doorsRef.current.find((d) => d.id === sid); if (item) { - ctx.handleReattachRef.current( - item, - isUntouched(sid) - ? { enterPassthrough: false, killImmediately: true } - : { enterPassthrough: false, confirmKill: true }, - ); + ctx.handleReattachRef.current(item, { + enterPassthrough: false, + afterRestore: isUntouched(sid) ? 'kill-immediately' : 'confirm-kill', + }); } return true; } diff --git a/lib/src/components/wall/keyboard/types.ts b/lib/src/components/wall/keyboard/types.ts index a3626ca..adcd8e9 100644 --- a/lib/src/components/wall/keyboard/types.ts +++ b/lib/src/components/wall/keyboard/types.ts @@ -19,7 +19,7 @@ export interface WallKeyboardCtx { killInProgressRef: RefObject; overlayElRef: RefObject; wallActionsRef: RefObject; - handleReattachRef: RefObject<(item: DooredItem, options?: { enterPassthrough?: boolean; confirmKill?: boolean; killImmediately?: boolean }) => void>; + handleReattachRef: RefObject<(item: DooredItem, options?: { enterPassthrough?: boolean; afterRestore?: 'confirm-kill' | 'kill-immediately' }) => void>; selectPane: (id: string) => void; selectDoor: (id: string) => void; enterTerminalMode: (id: string) => void; From 13a921b12d49aeff5269ae34b9f3e0243f1af19a Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Mon, 11 May 2026 09:02:06 -0700 Subject: [PATCH 3/3] Replace untouched terminal in-place when the selected shell changes Shell-selection dropdown (standalone) and Select Shell command (VS Code) now send mouseterm:newTerminal with replaceUntouched and announce. When the focused pane or door is untouched, Wall swaps the new session into the same dockview slot and disposes the old one without kill confirmation; otherwise the request falls through to the regular spawn path. A short pane-anchored notice ("Switched to zsh" / "Opened bash") provides feedback. VS Code post is retried briefly so it works right after focusing the view. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/specs/layout.md | 3 + docs/specs/transport.md | 2 + docs/specs/vscode.md | 20 ++- lib/src/components/Wall.tsx | 142 +++++++++++++++++++- lib/src/components/wall/keyboard/types.ts | 4 +- lib/src/components/wall/wall-types.ts | 10 ++ lib/src/index.css | 17 +++ lib/src/lib/platform/vscode-adapter.test.ts | 38 ++++++ lib/src/lib/platform/vscode-adapter.ts | 8 +- standalone/src/AppBar.tsx | 18 ++- vscode-ext/src/extension.ts | 41 +++++- vscode-ext/src/message-types.ts | 9 +- 12 files changed, 294 insertions(+), 18 deletions(-) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index d25e013..e356845 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -271,6 +271,7 @@ Pane IDs are session IDs. `TerminalPane` calls `getOrCreateTerminal(id)` on Reac - **Resume**: `resumeTerminal` creates xterm entry and writes replay data without spawning a new PTY. Used when the webview is recreated while the host retains Live PTYs (Link: Severed → Resuming → Live). - **Restore**: `restoreTerminal` creates xterm entry and spawns a new PTY with saved cwd and scrollback. Used on cold start from a saved Snapshot (Link: Cold → Live). - **Untouched**: new `getOrCreateTerminal` sessions start untouched. `isUntouched(id)` exposes the flag, and user-originated PTY input clears it via the registry input paths. Resume/restore seed the persisted flag; missing legacy snapshot data defaults to touched (`false`) so close confirmation remains conservative. +- **Shell selection replacement**: the standalone shell dropdown and VS Code shell picker send `mouseterm:new-terminal` with `replaceUntouched` when the selected shell type changes. `Wall` always creates a new session id for that request. If the currently selected pane or door is untouched, the new terminal is inserted in the same dockview position (`direction: 'within'`; doors first reattach through the normal restore path), the old untouched session is disposed, and the old panel is removed without kill confirmation. If the selected terminal is touched or no terminal is selected, the request spawns a new pane near the active panel. Announced shell-selection spawns show a transient pane-anchored notice such as `Switched to zsh` or `Opened bash`. - During resume/restore replay, xterm.js may emit terminal-generated replies for OSC/CSI/DCS queries that were embedded in saved output. The registry drops those replay-time replies before they reach the new shell. This filter is limited to query/focus reports, and must not swallow user keyboard escape sequences such as arrows, function keys, or bracketed paste. - **mount / unmount (DOM)**: `mountElement` reparents the persistent DOM element into a container; `unmountElement` removes it. The Registry entry survives. - **Dispose**: `disposeSession` kills the PTY, disposes xterm, removes the registry entry. Only called on explicit kill (`x`). @@ -320,6 +321,8 @@ When a pane is added, its dockview group element gets a directional `.pane-spawn The direction is carried via `FreshlySpawnedContext` — a `Map` written by the spawn call site and consumed once by `TerminalPanel`'s `useLayoutEffect` on first mount. +Shell-selection replacement uses the same pane add/remove primitives but also shows a short fixed-position notice over the resulting pane. The notice fades in/out over 1500ms via `.shell-spawn-notice` and is suppressed to a static render for reduced-motion users. + ### Kill (in-place fade + FLIP reclaim) `orchestrateKill(api, killedId)` in `lib/src/lib/kill-animation.ts` runs on kill confirmation. `Wall.tsx` owns the command dispatch and calls it after the user confirms. It fades the real pane element in place (its content dissolves against the same-colored background), then removes the panel and FLIP-reveals the survivors: diff --git a/docs/specs/transport.md b/docs/specs/transport.md index 0914d9b..7cb2cd8 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -104,6 +104,8 @@ Message types live in `vscode-ext/src/message-types.ts` (the canonical schema; o | `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:newTerminal` | Host/UI request to spawn a terminal. Payload may include `shell`, `args`, display `name`, `replaceUntouched`, and `announce`; the webview replaces the selected untouched terminal in-place only when `replaceUntouched` is true, otherwise it spawns a new pane. | +| `mouseterm:selectedShell` | Update the webview's default shell options for later split/spawn/restore paths. | | `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) | diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index c58178d..f0b9a06 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -85,8 +85,20 @@ Universal PTY/transport invariants live in `docs/specs/transport.md`. The rules { "command": "mouseterm.focus", "title": "MouseTerm: Focus", "icon": { "light": "icon-tiny-light.png", "dark": "icon-tiny-dark.png" } }, { "command": "mouseterm.open", "title": "MouseTerm: Open in Editor" }, - { "command": "mouseterm.debugTheme", "title": "MouseTerm: Debug Theme" } + { "command": "mouseterm.debugTheme", "title": "MouseTerm: Debug Theme" }, + { "command": "mouseterm.newTerminal", "title": "MouseTerm: New Terminal", + "icon": "$(add)" }, + { "command": "mouseterm.selectShell", "title": "MouseTerm: Select Shell", + "icon": "$(gear)" } ], + "menus": { + "view/title": [ + { "command": "mouseterm.selectShell", "group": "navigation@1", + "when": "view == mouseterm.view" }, + { "command": "mouseterm.newTerminal", "group": "navigation@2", + "when": "view == mouseterm.view" } + ] + }, "viewsContainers": { "panel": [ { "id": "mouseterm-panel", "title": "MouseTerm", "icon": "$(terminal)" } @@ -127,6 +139,12 @@ VS Code-specific consequences: PTY lifecycle, buffering, the reconnection sequence, and the full message protocol live in `docs/specs/transport.md`. +### Shell selection + +The VS Code view title contributes `MouseTerm: Select Shell` and `MouseTerm: New Terminal`. The selected shell name is mirrored into the `WebviewView.description`, and `mouseterm:selectedShell` keeps the webview's default-shell slot current for split/spawn/restore paths. + +`mouseterm.newTerminal` focuses the MouseTerm view and posts `mouseterm:newTerminal` with the currently selected shell. `mouseterm.selectShell` opens a QuickPick, saves the shell path globally or per workspace, applies the description/default-shell update, and, when the picked shell differs from the previous selection, focuses the view and posts `mouseterm:newTerminal` with `replaceUntouched: true` and `announce: true`. The shared `Wall` logic then replaces only a selected untouched terminal in-place; touched terminals cause an additional pane to be spawned instead. + ### Serialization and restore `WebviewPanelSerializer` is registered so VS Code can restore editor panels after restart: diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 975c3ec..580e7dd 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -10,6 +10,7 @@ import { Baseboard } from './Baseboard'; import { KILL_CONFIRM_MS, KILL_SHAKE_MS, KillConfirmOverlay, randomKillChar, type ConfirmKill } from './KillConfirm'; import { clearSessionAttention, + disposeSession, dismissOrToggleAlert, focusSession, markSessionAttention, @@ -47,9 +48,23 @@ import { ZoomedContext, type WallActions, } from './wall/wall-context'; -import type { DooredItem, WallEvent, WallMode, WallSelectionKind, SpawnDirection } from './wall/wall-types'; +import type { DoorAfterRestoreAction, DooredItem, WallEvent, WallMode, WallSelectionKind, SpawnDirection } from './wall/wall-types'; + +type ShellSpawnRequest = { + shell?: string; + args?: string[]; + name?: string; + replaceUntouched?: boolean; + announce?: boolean; +}; + +type ShellSpawnNoticeState = { + id: string; + text: string; + nonce: number; +}; -export type { DooredItem, WallEvent, WallMode, WallSelectionKind, SpawnDirection } from './wall/wall-types'; +export type { DoorAfterRestoreAction, DooredItem, WallEvent, WallMode, WallSelectionKind, SpawnDirection } from './wall/wall-types'; export { DialogKeyboardContext, DoorElementsContext, @@ -89,6 +104,35 @@ function persistedPanelTitle(title: string | null | undefined): string { return trimmed || UNNAMED_PANEL_TITLE; } +function ShellSpawnNotice({ + notice, + paneElements, + version, +}: { + notice: ShellSpawnNoticeState | null; + paneElements: Map; + version: number; +}) { + void version; + if (!notice) return null; + const target = paneElements.get(notice.id); + if (!target) return null; + const rect = target.getBoundingClientRect(); + return ( +
+ {notice.text} +
+ ); +} + const components = { terminal: TerminalPanel }; const tabComponents = { terminal: TerminalPaneHeader }; @@ -170,6 +214,9 @@ export function Wall({ const [renamingPaneId, setRenamingPaneId] = useState(null); const [doors, setDoors] = useState(() => (initialDoors ?? []) as DooredItem[]); const [zoomed, setZoomed] = useState(false); + const [shellSpawnNotice, setShellSpawnNotice] = useState(null); + const shellSpawnNoticeCounterRef = useRef(0); + const shellSpawnNoticeTimerRef = useRef | null>(null); // Use refs so the capture-phase listener always sees latest state without re-registering const modeRef = useRef(mode); @@ -194,6 +241,7 @@ export function Wall({ useEffect(() => () => { if (shakeTimerRef.current) clearTimeout(shakeTimerRef.current); if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current); + if (shellSpawnNoticeTimerRef.current) clearTimeout(shellSpawnNoticeTimerRef.current); }, []); // --- External event notifications --- @@ -229,6 +277,21 @@ export function Wall({ if (panel) panel.api.setActive(); }, []); + const showShellSpawnNotice = useCallback((id: string, text: string) => { + if (shellSpawnNoticeTimerRef.current) { + clearTimeout(shellSpawnNoticeTimerRef.current); + } + setShellSpawnNotice({ + id, + text, + nonce: ++shellSpawnNoticeCounterRef.current, + }); + shellSpawnNoticeTimerRef.current = setTimeout(() => { + setShellSpawnNotice(null); + shellSpawnNoticeTimerRef.current = null; + }, 1500); + }, []); + const killPaneImmediately = useCallback((id: string) => { const api = apiRef.current; if (!api?.getPanel(id)) return; @@ -356,7 +419,7 @@ export function Wall({ const handleReattach = useCallback(( item: DooredItem, - options?: { enterPassthrough?: boolean; afterRestore?: 'confirm-kill' | 'kill-immediately' }, + options?: { enterPassthrough?: boolean; afterRestore?: DoorAfterRestoreAction }, ) => { const api = apiRef.current; if (!api) return; @@ -437,10 +500,26 @@ export function Wall({ killPaneImmediately(item.id); } else if (afterRestore === 'confirm-kill') { setConfirmKill({ id: item.id, char: randomKillChar() }); + } else if (typeof afterRestore === 'object' && afterRestore.type === 'replace-terminal') { + const panel = apiRef.current?.getPanel(item.id); + if (!panel) return; + apiRef.current?.addPanel({ + id: afterRestore.newId, + component: 'terminal', + tabComponent: 'terminal', + title: UNNAMED_PANEL_TITLE, + position: { referencePanel: panel, direction: 'within' }, + }); + disposeSession(item.id); + apiRef.current?.removePanel(panel); + selectPane(afterRestore.newId); + if (afterRestore.announce) { + showShellSpawnNotice(afterRestore.newId, `Switched to ${afterRestore.shellName}`); + } } }); } - }, [selectPane, enterTerminalMode, killPaneImmediately]); + }, [selectPane, enterTerminalMode, killPaneImmediately, showShellSpawnNotice]); const handleReattachRef = useRef(handleReattach); handleReattachRef.current = handleReattach; @@ -449,7 +528,7 @@ export function Wall({ const handler = (e: Event) => { const api = apiRef.current; if (!api) return; - const detail = (e as CustomEvent).detail; + const detail = ((e as CustomEvent).detail ?? {}) as ShellSpawnRequest; const newId = generatePaneId(); // Store shell options so getOrCreateTerminal picks them up on mount @@ -457,6 +536,48 @@ export function Wall({ setPendingShellOpts(newId, { shell: detail.shell, args: detail.args }); } + const selectedPaneId = selectedTypeRef.current === 'pane' ? selectedIdRef.current : null; + const selectedPanel = selectedPaneId ? api.getPanel(selectedPaneId) : undefined; + const selectedDoor = selectedTypeRef.current === 'door' + ? doorsRef.current.find((door) => door.id === selectedIdRef.current) + : undefined; + const shouldReplaceUntouched = + detail.replaceUntouched === true && + !!selectedPaneId && + !!selectedPanel && + isUntouched(selectedPaneId); + const shellName = detail.name?.trim() || 'terminal'; + + if (shouldReplaceUntouched) { + api.addPanel({ + id: newId, + component: 'terminal', + tabComponent: 'terminal', + title: UNNAMED_PANEL_TITLE, + position: { referencePanel: selectedPanel, direction: 'within' }, + }); + disposeSession(selectedPaneId); + api.removePanel(selectedPanel); + selectPane(newId); + if (detail.announce) { + showShellSpawnNotice(newId, `Switched to ${shellName}`); + } + return; + } + + if (detail.replaceUntouched === true && selectedDoor && isUntouched(selectedDoor.id)) { + handleReattachRef.current(selectedDoor, { + enterPassthrough: false, + afterRestore: { + type: 'replace-terminal', + newId, + shellName, + announce: detail.announce === true, + }, + }); + return; + } + const active = api.activePanel; api.addPanel({ id: newId, @@ -466,10 +587,13 @@ export function Wall({ position: active ? { referencePanel: active.id, direction: pickSplitDirection(active) } : undefined, }); selectPane(newId); + if (detail.announce) { + showShellSpawnNotice(newId, `Opened ${shellName}`); + } }; window.addEventListener('mouseterm:new-terminal', handler); return () => window.removeEventListener('mouseterm:new-terminal', handler); - }, [generatePaneId, selectPane]); + }, [generatePaneId, selectPane, showShellSpawnNotice]); const addSplitPanel = useCallback(( id: string | null, @@ -633,6 +757,12 @@ export function Wall({ /> )} + + diff --git a/lib/src/components/wall/keyboard/types.ts b/lib/src/components/wall/keyboard/types.ts index adcd8e9..f961145 100644 --- a/lib/src/components/wall/keyboard/types.ts +++ b/lib/src/components/wall/keyboard/types.ts @@ -1,7 +1,7 @@ import type { Dispatch, RefObject, SetStateAction } from 'react'; import type { DockviewApi } from 'dockview-react'; import type { ConfirmKill } from '../../KillConfirm'; -import type { DooredItem, WallEvent, WallMode, WallSelectionKind } from '../wall-types'; +import type { DoorAfterRestoreAction, DooredItem, WallEvent, WallMode, WallSelectionKind } from '../wall-types'; import type { WallActions } from '../wall-context'; /** Refs + callbacks shared by every keyboard branch. Bundled to avoid 25-arg @@ -19,7 +19,7 @@ export interface WallKeyboardCtx { killInProgressRef: RefObject; overlayElRef: RefObject; wallActionsRef: RefObject; - handleReattachRef: RefObject<(item: DooredItem, options?: { enterPassthrough?: boolean; afterRestore?: 'confirm-kill' | 'kill-immediately' }) => void>; + handleReattachRef: RefObject<(item: DooredItem, options?: { enterPassthrough?: boolean; afterRestore?: DoorAfterRestoreAction }) => void>; selectPane: (id: string) => void; selectDoor: (id: string) => void; enterTerminalMode: (id: string) => void; diff --git a/lib/src/components/wall/wall-types.ts b/lib/src/components/wall/wall-types.ts index 8097c6f..d32f691 100644 --- a/lib/src/components/wall/wall-types.ts +++ b/lib/src/components/wall/wall-types.ts @@ -9,6 +9,16 @@ export type WallMode = 'command' | 'passthrough'; export type WallSelectionKind = 'pane' | 'door'; +export type DoorAfterRestoreAction = + | 'confirm-kill' + | 'kill-immediately' + | { + type: 'replace-terminal'; + newId: string; + shellName: string; + announce: boolean; + }; + export type WallEvent = | { type: 'modeChange'; mode: WallMode } | { type: 'zoomChange'; zoomed: boolean } diff --git a/lib/src/index.css b/lib/src/index.css index c1c717b..02b1f5a 100644 --- a/lib/src/index.css +++ b/lib/src/index.css @@ -165,3 +165,20 @@ body { @media (prefers-reduced-motion: reduce) { .animate-copy-flash { animation: none; } } + +/* --- Shell selection feedback --- */ + +@keyframes shell-spawn-notice { + 0% { opacity: 0; transform: translateX(-50%) translateY(-4px); } + 15% { opacity: 1; transform: translateX(-50%) translateY(0); } + 80% { opacity: 1; transform: translateX(-50%) translateY(0); } + 100% { opacity: 0; transform: translateX(-50%) translateY(-3px); } +} + +.shell-spawn-notice { + animation: shell-spawn-notice 1500ms ease-out forwards; +} + +@media (prefers-reduced-motion: reduce) { + .shell-spawn-notice { animation: none; } +} diff --git a/lib/src/lib/platform/vscode-adapter.test.ts b/lib/src/lib/platform/vscode-adapter.test.ts index 97e8464..c6271f3 100644 --- a/lib/src/lib/platform/vscode-adapter.test.ts +++ b/lib/src/lib/platform/vscode-adapter.test.ts @@ -23,7 +23,18 @@ describe('VSCodeAdapter PTY exit handling', () => { beforeEach(() => { windowTarget = new EventTarget(); postMessage = vi.fn(); + class TestCustomEvent extends Event { + readonly detail: T; + + constructor(type: string, eventInitDict?: CustomEventInit) { + super(type, eventInitDict); + this.detail = eventInitDict?.detail as T; + } + + initCustomEvent(): void {} + } vi.stubGlobal('window', windowTarget); + vi.stubGlobal('CustomEvent', TestCustomEvent); vi.stubGlobal('acquireVsCodeApi', () => ({ postMessage, getState: vi.fn(), @@ -128,4 +139,31 @@ describe('VSCodeAdapter PTY exit handling', () => { expect(terminalStateStoreMocks.applyTerminalSemanticEventsByPtyId).toHaveBeenCalledTimes(1); expect(terminalStateStoreMocks.applyTerminalSemanticEventsByPtyId).toHaveBeenCalledWith('pane-1', hostEvents); }); + + it('forwards shell replacement requests from the extension host', () => { + const requests: unknown[] = []; + windowTarget.addEventListener('mouseterm:new-terminal', (event) => { + requests.push((event as CustomEvent).detail); + }); + + new VSCodeAdapter(); + windowTarget.dispatchEvent(new MessageEvent('message', { + data: { + type: 'mouseterm:newTerminal', + shell: '/bin/zsh', + args: ['-l'], + name: 'zsh', + replaceUntouched: true, + announce: true, + }, + })); + + expect(requests).toEqual([{ + shell: '/bin/zsh', + args: ['-l'], + name: 'zsh', + replaceUntouched: true, + announce: true, + }]); + }); }); diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index 7bfbc38..83c53b4 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -76,7 +76,13 @@ export class VSCodeAdapter implements PlatformAdapter { } } else if (msg.type === 'mouseterm:newTerminal') { window.dispatchEvent(new CustomEvent('mouseterm:new-terminal', { - detail: { shell: msg.shell, args: msg.args }, + detail: { + shell: msg.shell, + args: msg.args, + name: msg.name, + replaceUntouched: msg.replaceUntouched, + announce: msg.announce, + }, })); } else if (msg.type === 'mouseterm:selectedShell') { setDefaultShellOpts(msg.shell ? { shell: msg.shell, args: msg.args } : null); diff --git a/standalone/src/AppBar.tsx b/standalone/src/AppBar.tsx index ecd796d..0a38240 100644 --- a/standalone/src/AppBar.tsx +++ b/standalone/src/AppBar.tsx @@ -105,8 +105,18 @@ function ShellDropdown({ shells }: { shells: ShellEntry[] }) { setDefaultShellOpts(selected ? { shell: selected.path, args: selected.args } : null); }, [selected]); - const spawn = useCallback((shell: ShellEntry) => { - window.dispatchEvent(new CustomEvent('mouseterm:new-terminal', { detail: { shell: shell.path, args: shell.args } })); + const spawn = useCallback(( + shell: ShellEntry, + options: { replaceUntouched?: boolean; announce?: boolean } = {}, + ) => { + window.dispatchEvent(new CustomEvent('mouseterm:new-terminal', { + detail: { + shell: shell.path, + args: shell.args, + name: shell.name, + ...options, + }, + })); }, []); // Close on click outside @@ -167,8 +177,10 @@ function ShellDropdown({ shells }: { shells: ShellEntry[] }) { aria-checked={isSelected} className="flex w-full items-center gap-2 whitespace-nowrap px-3 py-1.5 text-left text-sm text-foreground transition-colors hover:bg-surface-raised" onClick={() => { - setSelected(shell); setOpen(false); + if (isSelected) return; + setSelected(shell); + spawn(shell, { replaceUntouched: true, announce: true }); }} > diff --git a/vscode-ext/src/extension.ts b/vscode-ext/src/extension.ts index 0383490..912f0f5 100644 --- a/vscode-ext/src/extension.ts +++ b/vscode-ext/src/extension.ts @@ -8,6 +8,9 @@ import { log } from './log'; import { mergeAlertStates, refreshSavedSessionStateFromPtys } from './session-state'; import { readPersistedSession } from '../../lib/src/lib/session-types'; import { resolveSelectedShell, setSelectedShellPath, getSelectedShellPath } from './shell-selection'; +import type { ExtensionMessage } from './message-types'; + +type NewTerminalMessage = Extract; let extensionContext: vscode.ExtensionContext | null = null; @@ -70,6 +73,21 @@ export function activate(context: vscode.ExtensionContext) { provider.setSelectedShell(shell ? { shell: shell.path, args: shell.args } : null); }; + const postNewTerminal = async (message: Omit) => { + await vscode.commands.executeCommand('mouseterm.view.focus'); + for (const delay of [0, 50, 200]) { + if (delay > 0) { + await new Promise((resolve) => setTimeout(resolve, delay)); + } + const posted = await provider.postMessage({ + type: 'mouseterm:newTerminal', + ...message, + }); + if (posted) return true; + } + return false; + }; + // Warm up shell detection in the background so the picker/+ buttons // don't pay the cold-start cost (child fork + WSL probe) when the user // first clicks them. Also seeds the view description / webview state @@ -119,14 +137,16 @@ export function activate(context: vscode.ExtensionContext) { void vscode.window.showWarningMessage('MouseTerm: open the MouseTerm view before debugging the theme.'); }), vscode.commands.registerCommand('mouseterm.newTerminal', async () => { - await vscode.commands.executeCommand('mouseterm.view.focus'); const shells = await ptyManager.getAvailableShells(); const shell = resolveSelectedShell(context, shells); - await provider.postMessage({ - type: 'mouseterm:newTerminal', + const posted = await postNewTerminal({ shell: shell?.path, args: shell?.args, + name: shell?.name, }); + if (!posted) { + void vscode.window.showWarningMessage('MouseTerm: open the MouseTerm view before creating a terminal.'); + } }), vscode.commands.registerCommand('mouseterm.selectShell', async () => { const shells = await ptyManager.getAvailableShells(); @@ -144,9 +164,10 @@ export function activate(context: vscode.ExtensionContext) { })); const picked = await vscode.window.showQuickPick(items, { title: 'Select default shell for MouseTerm', - placeHolder: 'The [+] button will spawn a terminal with this shell.', + placeHolder: 'Changing this opens a matching terminal; new panes reuse it.', }); if (!picked) return; + const changed = picked.path !== currentPath; const hasWorkspace = (vscode.workspace.workspaceFolders?.length ?? 0) > 0; let scope: 'workspace' | 'global' = 'global'; @@ -163,6 +184,18 @@ export function activate(context: vscode.ExtensionContext) { } await setSelectedShellPath(context, picked.path, scope); applyShell({ name: picked.label, path: picked.path, args: picked.args }); + if (changed) { + const posted = await postNewTerminal({ + shell: picked.path, + args: picked.args, + name: picked.label, + replaceUntouched: true, + announce: true, + }); + if (!posted) { + void vscode.window.showWarningMessage('MouseTerm: open the MouseTerm view before changing the active terminal type.'); + } + } }), ); } diff --git a/vscode-ext/src/message-types.ts b/vscode-ext/src/message-types.ts index 1a66bd3..09e44a3 100644 --- a/vscode-ext/src/message-types.ts +++ b/vscode-ext/src/message-types.ts @@ -46,7 +46,14 @@ export type ExtensionMessage = | { type: 'pty:shells'; shells: Array<{ name: string; path: string; args: string[] }>; requestId?: string } | { type: 'clipboard:files'; paths: string[] | null; requestId: string } | { type: 'clipboard:image'; path: string | null; requestId: string } - | { type: 'mouseterm:newTerminal'; shell?: string; args?: string[] } + | { + type: 'mouseterm:newTerminal'; + shell?: string; + args?: string[]; + name?: string; + replaceUntouched?: boolean; + announce?: boolean; + } | { type: 'mouseterm:selectedShell'; shell?: string; args?: string[] } | { type: 'mouseterm:openThemeDebugger' } | { type: 'mouseterm:flushSessionSave'; requestId: string }