Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/specs/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -268,14 +270,15 @@ 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`).
- **Swap**: `swapTerminals` swaps two registry entries and reattaches DOM elements to each other's containers.

### 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.

Expand Down
2 changes: 2 additions & 0 deletions docs/specs/transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ interface PersistedPane {
title: string;
scrollback: string | null;
resumeCommand: string | null;
untouched: boolean;
alert?: PersistedAlertState | null;
}

Expand Down Expand Up @@ -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.
42 changes: 29 additions & 13 deletions lib/src/components/Wall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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]);
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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 });
},
Expand Down Expand Up @@ -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;

Expand All @@ -569,6 +584,7 @@ export function Wall({
enterTerminalMode,
exitTerminalMode,
minimizePane,
killPaneImmediately,
acceptKill,
rejectKill,
setConfirmKill,
Expand Down
5 changes: 1 addition & 4 deletions lib/src/components/wall/keyboard/handle-kill-confirm.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { orchestrateKill } from '../../../lib/kill-animation';
import type { WallKeyboardCtx } from './types';

/**
Expand All @@ -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();
Expand Down
115 changes: 115 additions & 0 deletions lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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' },
);
});
});
12 changes: 11 additions & 1 deletion lib/src/components/wall/keyboard/handle-pane-shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { findPaneInDirection } from '../../../lib/spatial-nav';
import {
dismissOrToggleAlert,
getActivity,
isUntouched,
swapTerminals,
toggleSessionTodo,
} from '../../../lib/terminal-registry';
Expand Down Expand Up @@ -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();
Expand Down
5 changes: 3 additions & 2 deletions lib/src/components/wall/keyboard/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,14 @@ export interface WallKeyboardCtx {
killInProgressRef: RefObject<boolean>;
overlayElRef: RefObject<HTMLDivElement | null>;
wallActionsRef: RefObject<WallActions>;
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<SetStateAction<ConfirmKill | null>>;
setRenamingPaneId: Dispatch<SetStateAction<string | null>>;
Expand Down
3 changes: 2 additions & 1 deletion lib/src/lib/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
if (!text) return;
Expand Down Expand Up @@ -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);
}

Expand Down
41 changes: 41 additions & 0 deletions lib/src/lib/reconnect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading