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..975c3ec 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,12 @@ export function Wall({ const handleReattach = useCallback(( item: DooredItem, - options?: { enterPassthrough?: boolean; confirmKill?: 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 afterRestore = options?.afterRestore; const currentLayoutSignature = getLayoutStructureSignature(api.toJSON()); // Exact reattach is only safe when the layout structure matches AND the @@ -424,12 +433,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 (afterRestore === 'kill-immediately') { + killPaneImmediately(item.id); + } else if (afterRestore === 'confirm-kill') { setConfirmKill({ id: item.id, char: randomKillChar() }); } }); } - }, [selectPane, enterTerminalMode]); + }, [selectPane, enterTerminalMode, killPaneImmediately]); const handleReattachRef = useRef(handleReattach); handleReattachRef.current = handleReattach; @@ -494,6 +505,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 +561,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 +584,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..15a6ecc --- /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, afterRestore: 'kill-immediately' }, + ); + }); + + 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, 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 62ab782..9480f63 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,16 @@ 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, { + enterPassthrough: false, + afterRestore: isUntouched(sid) ? 'kill-immediately' : 'confirm-kill', + }); + } + 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..adcd8e9 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; afterRestore?: 'confirm-kill' | 'kill-immediately' }) => 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 {