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
19 changes: 16 additions & 3 deletions TESTING_AND_MODIFICATION_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,8 @@ Tests that pin the stack's non-obvious rules, by concern:

## 5. Manual test checklist

Nothing below has been run in the Tauri app yet. Items marked **WKWebView** are
the ones the design depends on and that were validated only in Chromium.
User-run results are recorded under "Transfer findings" below. Items marked
**WKWebView** need native testing beyond their Chromium coverage.

**Hidden-Workspace minimize (WKWebView)**
- Two Workspaces, three terminals each. Switch away; in Safari Web Inspector the
Expand All @@ -143,7 +143,7 @@ the ones the design depends on and that were validated only in Chromium.
- Move a Workspace with a long-running TUI and 10k+ lines of scrollback to a
second window: scrollback, cursor, and colors intact; output continues with
nothing repeated or lost at the seam.
- A note pinned to scrollback survives the move (click the pin in the target).
- A captured note survives the move without its source pin.
- Kill the app mid-drag (after the drop, before the target finishes): on
relaunch the Workspace is in the target window with fresh shells, and not in
the source.
Expand Down Expand Up @@ -237,3 +237,16 @@ the heading, and the rule gets a `(rationale)` marker.
until a window re-seeds.
- The one-frame blank on switch-back and WKWebView context release are unverified
(§5).

## Transfer findings (user-run Tauri, 2026-09-11)

- `ascii-splash` mouse interaction failed after both tear-out and transfer into
an existing window; resizing did not repair it, restarting the TUI did.
Ordinary Workspace switching and window focus changes preserved mouse input.
- Moving a window's last Workspace into another window left stale TUI drawing
until a resize. Tear-out drawing appeared correct.
- A source pin remained visible after moving, then reported unavailable and
disappeared on use. Pins are now intentionally dropped on transfer.
- Earlier checks passed: switching, idle tear-out, stable cross-window refs,
continuous numbered output without observed gaps, and identical retained
scrollback before and after transfer.
4 changes: 3 additions & 1 deletion docs/specs/notepad.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ A pin is the runtime link from a captured note back to the scrollback it came fr
- **While the alternate buffer is active a pin is temporarily unavailable and kept** — the markers belong to the normal buffer and resolve again once the program exits; the notepad says to exit it.
- **Every other pin failure removes the pin and keeps the note.** Disposed markers, rows out of range, and a text mismatch all report that the source is no longer available, the notepad kept or reopened to say so.
- **Disposing or replacing a terminal instance drops its pins immediately**, notes untouched — a marker belongs to one xterm instance.
- **Must drop source pins when a Workspace moves between windows**, keeping the
notes (rationale).
- **Pins never affect ordering and are not user-controlled favorites.**

Source of truth: `registerTerminalSource`, `resolveTerminalSource` and `revealResolvedSource` in `lib/src/lib/notepad/source-link.ts`; `revealNoteSource` in `lib/src/lib/notepad/pin.ts`; `setTerminalSelectionBaseline` in `lib/src/lib/terminal-store.ts`; `dropSourcesForTerminal` in `lib/src/lib/notepad/notepad-store.ts`, called from `disposeSession` in `lib/src/lib/terminal-lifecycle.ts`.
Expand Down Expand Up @@ -140,7 +142,7 @@ Source of truth: `archiveSurfaceNotes` in `lib/src/lib/notepad/close-coordinator

**Archiving is a gate step before teardown**: after the running-work confirmation, or immediately on an all-idle quit, and **before the first `quit_progress`** (`docs/specs/standalone.md` → "Quit flow"; rationale). **It is bounded at 3 s.**

**Both deliberate endings run the same gate**, over their own window's Surfaces: a quit, and closing one window of several (`docs/specs/standalone.md` → "Per-window close"). **Moving a Workspace to another window runs neither** — nothing ended, so the notes ride the move and the target hydrates them, minus their pins, which are markers in the xterm instances the source disposed. **Several windows archiving at once contend through the archive's own file lock and compare-and-swap retry** ([The archive port](#the-archive-port)), so a window whose write lost the race retries against fresh bytes rather than dropping the other window's batches.
**Both deliberate endings run the same gate**, over their own window's Surfaces: a quit, and closing one window of several (`docs/specs/standalone.md` → "Per-window close"). **Moving a Workspace to another window runs neither** — nothing ended, so the target hydrates the notes; pin behavior follows [Source links](#source-links). **Several windows archiving at once contend through the archive's own file lock and compare-and-swap retry** ([The archive port](#the-archive-port)), so a window whose write lost the race retries against fresh bytes rather than dropping the other window's batches.

**Standalone still archives at quit even though it now restores its windows** (`docs/specs/transport.md` → "The governing rule"): VS Code's live notes survive a Reload only through the extension host's in-memory mirror ([Live resume](#live-resume)), and quitting standalone leaves no such survivor.

Expand Down
4 changes: 4 additions & 0 deletions docs/specs/notepad.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ first sixteen are the user's and the rest are a fixed formula xterm itself appli

## Source links

In Tauri manual testing (2026-09-11), a transferred pin remained visible but failed
its text proof on use. Rebuilt buffers do not guarantee identical absolute row
positions. Transfers therefore retain notes without presenting unusable pins.

The pin could have stored a scrollback line number. It stores two xterm markers
because a marker is the only handle xterm keeps correct as the buffer scrolls, and
scrolling is the normal case — a capture is usually of something that has already
Expand Down
16 changes: 9 additions & 7 deletions docs/specs/transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ nothing holds first paint for 500 ms, not the whole budget. Source of truth:

A Workspace can move from one webview to another with its Sessions still
running (`docs/specs/standalone.md` → Transfer). It is a resume, not a restore,
and it turns on three rules:
with these transfer rules:

- **Release, never dispose, and only once the target has adopted the Workspace.**
The source detaches its half of each Session — the alert, the pins, the
Expand Down Expand Up @@ -161,14 +161,16 @@ and it turns on three rules:
is not empty** rule `interrupt` carries — a caller forwarding a computed set
that came out empty gets a no-op, not every PTY in the process. The moving
ids include each pane's helper Session, which no other field names.
- **Pins travel with the buffers.** The source takes each note's marker lines at
the instant it serializes (`snapshotTerminalPins`); the target re-registers
them at those lines once the rebuilt buffer has been parsed
(`restoreTerminalPins`), and the pin's byte-for-byte proof still decides
whether it is trusted (`docs/specs/notepad.md` → Source pins).
- **Must replay a transfer at its source grid and drain parsing before mounting
the target Wall**, then fit the target pane. **Must preserve mouse encoding
as well as tracking**, including SGR and SGR-pixel encoding omitted by xterm's
serializer. Pinned by `lib/src/lib/terminal-transfer.test.ts` and
`standalone/src/workspace-move.test.ts`.
Comment on lines +167 to +168

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENTS.md: "Name the test that pins a rule" — and the Source of truth: paragraph just below already cites by test title. This fits the 5,850-word budget (5,840); adding the workspace-move.test.ts case needs a --ratchet in the same commit.

Suggested change
serializer. Pinned by `lib/src/lib/terminal-transfer.test.ts` and
`standalone/src/workspace-move.test.ts`.
serializer. Pinned by `preserves mouse tracking and encoding` and `rebuilds a
full-screen grid larger than xterm defaults without clipping` in
`lib/src/lib/terminal-transfer.test.ts`.

- Source-pin limitations belong to `docs/specs/notepad.md` → Source links.

Source of truth: `captureTransferContent` in
`lib/src/components/wall/workspace-transfer.ts`; `mark` / `list` in
`lib/src/components/wall/workspace-transfer.ts`; `serializeTransferTerminal` in
`lib/src/lib/terminal-transfer.ts`; `mark` / `list` in
`standalone/sidecar/pty-core.js`; `standalone/src/workspace-move.ts`. Pinned by
`a mark is ordered in the stream and a since-mark replay is exactly the
remainder` in `standalone/sidecar/pty-core.test.js` and
Expand Down
19 changes: 11 additions & 8 deletions lib/src/components/wall/workspace-transfer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { snapshotNotepadForTransfer, snapshotTerminalPins, removeSurface } from '../../lib/notepad/notepad-store';
import { snapshotNotepadForTransfer, removeSurface } from '../../lib/notepad/notepad-store';
import type { TransferredPin } from '../../lib/notepad/source-link';
import type { TerminalGrid } from '../../lib/terminal-transfer';
import { forgetHelper, getHelper } from '../../lib/helper-terminal';
import { releaseSession, serializeTerminal } from '../../lib/terminal-registry';
import { releaseSession, serializeTerminal, getTerminalInstance } from '../../lib/terminal-registry';
import type { VolatileNotepadSnapshot } from '../../lib/notepad/types';
import type { PersistedSession, PersistedWorkspace, WorkspaceId } from '../../lib/session-types';
import type { SaveOptions } from '../../lib/session-save';
Expand All @@ -19,9 +20,7 @@ export interface WorkspaceTransferPayload {
workspaceId: WorkspaceId;
/** What the target restores the Workspace from. */
workspace: PersistedWorkspace;
/** The notes riding along; the target hydrates them. Their pins follow in
* the content (`captureTransferContent`), once the buffers they point
* into have been serialized. */
/** The notes riding along; the target hydrates them. Runtime source pins are dropped on arrival. */
notepad: VolatileNotepadSnapshot;
/** Member Surfaces holding a PTY, **plus each one's helper Session**: exactly
* what changes ownership. A helper is not a member Surface — it has no pane
Expand Down Expand Up @@ -125,6 +124,7 @@ export interface TransferredTerminal {
/** The buffer as the escape stream that rebuilds it; `''` for a Session this
* Window no longer held. */
serialized: string;
grid?: TerminalGrid;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only undocumented field in this interface, and the one whose contract isn't guessable from the name — that the target constructs xterm at it and only then fits.

Suggested change
grid?: TerminalGrid;
/** The source's grid, which the target rebuilds the buffer at before it fits
* the pane; absent for a Session this Window no longer held. */
grid?: TerminalGrid;

/** The sidecar's output position the serialization stands at; absent when
* the host never stamped one, and the target then replays the whole buffer
* behind the serialized one. */
Expand All @@ -136,11 +136,12 @@ export interface TransferredTerminal {
* passed, and attached to the arrival the host queued at the invoke. */
export interface WorkspaceTransferContent {
terminals: Record<string, TransferredTerminal>;
/** Kept empty; old payloads may contain pins, which arrivals ignore. */
pins: TransferredPin[];
}

/**
* Serialize every terminal at its mark, and take its pins at the same instant.
* Serialize every terminal at its mark with its source grid. Pins do not transfer.
*
* **Only after the host's `marked` line for each id**: everything this Window
* was sent before that line is in the buffer once the write queue drains, and
Expand All @@ -156,8 +157,10 @@ export async function captureTransferContent(
const terminals: Record<string, TransferredTerminal> = {};
for (const id of terminalIds) {
const serialized = (await serializeTerminal(id)) ?? '';
const terminal = getTerminalInstance(id);
const grid = terminal ? { cols: terminal.cols, rows: terminal.rows } : undefined;
const mark = marks.get(id);
terminals[id] = mark === undefined ? { serialized } : { serialized, mark };
terminals[id] = { serialized, ...(grid ? { grid } : {}), ...(mark === undefined ? {} : { mark }) };
}
return { terminals, pins: snapshotTerminalPins(terminalIds) };
return { terminals, pins: [] };
}
10 changes: 8 additions & 2 deletions lib/src/lib/reconnect.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { TerminalGrid } from './terminal-transfer';
import { adoptOrphanedHelper, restoreHelper } from './helper-terminal';
import type { LathPersistedLayout } from './lath/persistence';
import type { PlatformAdapter, PtyInfo } from './platform/types';
Expand Down Expand Up @@ -38,6 +39,8 @@ export interface LivePtys {
* same behavior through `resumeOrRestore`.
*/
export interface ResumePlanOptions {
/** Source grids for serialized transfer buffers, before target layout fits. */
terminalGrids?: ReadonlyMap<string, TerminalGrid>;
/** The record to plan from; `undefined` reads the platform slot, `null` is "none". */
savedSession?: PersistedSession | null;
/** Live ids this plan owns by name. Omitted claims every live PTY. */
Expand Down Expand Up @@ -196,7 +199,7 @@ export function resumeOrRestoreFrom(

const mine = live.ptys.filter((pty) =>
opts.ptyIds === undefined || opts.ptyIds.has(pty.id) || opts.claimUnowned?.has(pty.id));
const resumed = mine.length > 0 ? resumeLivePtys(mine, live.replay, saved) : null;
const resumed = mine.length > 0 ? resumeLivePtys(mine, live.replay, saved, opts.terminalGrids) : null;
if (resumed) return hydrateNotepad(platform, resumed);

const restored = restoreSession(platform, { savedSession: saved });
Expand All @@ -217,15 +220,18 @@ function resumeLivePtys(
ptyList: PtyInfo[],
replayBuffer: Map<string, string>,
saved: PersistedSession | null,
grids?: ReadonlyMap<string, TerminalGrid>,
): ReconnectResult {
const savedResumeInfo = getSavedPaneResumeInfo(saved, ptyList.map((pty) => pty.id));
const ids: string[] = [];
const ptyById = new Map(ptyList.map((pty) => [pty.id, pty]));
for (const pty of ptyList) {
const resumeInfo: { alive: boolean; exitCode?: number; shell?: string; title?: string; untouched?: boolean; helper?: PtyInfo['helper'] } = {
const resumeInfo: { alive: boolean; exitCode?: number; shell?: string; title?: string; untouched?: boolean; helper?: PtyInfo['helper']; grid?: TerminalGrid } = {
alive: pty.alive,
exitCode: pty.exitCode,
};
const grid = grids?.get(pty.id);
if (grid) resumeInfo.grid = grid;
if (pty.shell !== undefined) resumeInfo.shell = pty.shell;
const savedInfo = savedResumeInfo.get(pty.id);
if (savedInfo?.title !== undefined) resumeInfo.title = savedInfo.title;
Expand Down
13 changes: 8 additions & 5 deletions lib/src/lib/terminal-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { serializeTransferTerminal, type TerminalGrid } from './terminal-transfer';
import { Terminal, type IBufferRange } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { SerializeAddon } from '@xterm/addon-serialize';
Expand Down Expand Up @@ -133,13 +134,14 @@ function readDisplayTextFromBuffer(terminal: Terminal, range: IBufferRange): str
}
}

function createXtermHost(): { terminal: Terminal; fit: FitAddon; serialize: SerializeAddon; element: HTMLDivElement } {
function createXtermHost(grid?: TerminalGrid): { terminal: Terminal; fit: FitAddon; serialize: SerializeAddon; element: HTMLDivElement } {
const styles = getComputedStyle(document.body);
const editorFontSize = parseInt(styles.getPropertyValue('--vscode-editor-font-size'), 10) || 12;
const editorFontFamily = styles.getPropertyValue('--vscode-editor-font-family').trim() || "'SF Mono', Menlo, Monaco, monospace";

const theme = getTerminalTheme();
const terminal = new Terminal({
...grid,
allowProposedApi: true,
fontSize: editorFontSize,
fontFamily: editorFontFamily,
Expand Down Expand Up @@ -290,8 +292,8 @@ function wireXtermHandlers(
};
}

function setupTerminalEntry(id: string, options: { shell?: string; untouched?: boolean; helper?: HelperIdentity } = {}): TerminalEntry {
const { terminal, fit, serialize, element } = createXtermHost();
function setupTerminalEntry(id: string, options: { shell?: string; untouched?: boolean; helper?: HelperIdentity; grid?: TerminalGrid } = {}): TerminalEntry {
const { terminal, fit, serialize, element } = createXtermHost(options.grid);
const selectionBaselineRef = { current: null as string | null };
// Every module that finalizes a selection arms the render handler through
// this one setter: the mouse router at drag end, a note's pin on reveal.
Expand Down Expand Up @@ -439,12 +441,13 @@ export function getOrCreateTerminal(id: string): TerminalEntry {
export function resumeTerminal(
id: string,
replayData: string | null,
exitInfo?: { alive: boolean; exitCode?: number; shell?: string; title?: string | null; untouched?: boolean; helper?: HelperIdentity },
exitInfo?: { alive: boolean; exitCode?: number; shell?: string; title?: string | null; untouched?: boolean; helper?: HelperIdentity; grid?: TerminalGrid },
): TerminalEntry {
const existing = registry.get(id);
if (existing) return existing;

const entry = setupTerminalEntry(id, {
grid: exitInfo?.grid,
helper: exitInfo?.helper,
shell: exitInfo?.shell,
untouched: exitInfo?.untouched ?? false,
Expand Down Expand Up @@ -544,7 +547,7 @@ export async function serializeTerminal(id: string): Promise<string | null> {
const entry = registry.get(id);
if (!entry) return null;
await flushTerminal(id);
return entry.serialize.serialize();
return serializeTransferTerminal(entry.terminal, entry.serialize);
}

/** Resolves once everything written to the Session so far is in its buffer.
Expand Down
56 changes: 56 additions & 0 deletions lib/src/lib/terminal-transfer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { Terminal } from '@xterm/xterm';
import { SerializeAddon } from '@xterm/addon-serialize';
import { serializeTransferTerminal } from './terminal-transfer';

const write = (terminal: Terminal, data: string) => new Promise<void>(resolve => terminal.write(data, resolve));
async function mode(terminal: Terminal, number: number): Promise<string> {
let report = '';
const listener = terminal.onData(data => { report += data; });
await write(terminal, `\x1b[?${number}$p`);
listener.dispose();
return report;
}

describe('terminal transfer serialization', () => {
it.each([1006, 1016])('preserves mouse tracking and encoding %i through real xterm parsing', async encoding => {
const source = new Terminal({ allowProposedApi: true });
const target = new Terminal({ allowProposedApi: true });
const serializer = new SerializeAddon();
source.loadAddon(serializer);
try {
await write(source, `\x1b[?1003h\x1b[?${encoding}h`);
await write(target, serializeTransferTerminal(source, serializer));
expect(target.modes.mouseTrackingMode).toBe('any');
expect(await mode(target, encoding)).toBe(`\x1b[?${encoding};1$y`);
} finally { source.dispose(); target.dispose(); }
});

it.each(['\x1b[?1006l', '\x1bc'])('does not resurrect encoding after reset %j', async reset => {
const source = new Terminal({ allowProposedApi: true });
const target = new Terminal({ allowProposedApi: true });
const serializer = new SerializeAddon();
source.loadAddon(serializer);
try {
await write(source, '\x1b[?1006h' + reset);
await write(target, serializeTransferTerminal(source, serializer));
expect(await mode(target, 1006)).toBe('\x1b[?1006;2$y');
} finally { source.dispose(); target.dispose(); }
});

it('rebuilds a full-screen grid larger than xterm defaults without clipping', async () => {
const grid = { cols: 120, rows: 45 };
const source = new Terminal({ ...grid, allowProposedApi: true });
const target = new Terminal({ ...grid, allowProposedApi: true });
const serializer = new SerializeAddon();
source.loadAddon(serializer);
try {
await write(source, '\x1b[?1049h\x1b[45;100Hbottom-right');
await write(target, serializeTransferTerminal(source, serializer));
expect(target.buffer.active.type).toBe('alternate');
expect(target.buffer.active.getLine(44)?.translateToString(true)).toContain('bottom-right');
expect(target.buffer.active.cursorY).toBe(source.buffer.active.cursorY);
} finally { source.dispose(); target.dispose(); }
});
});
16 changes: 16 additions & 0 deletions lib/src/lib/terminal-transfer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { Terminal } from '@xterm/xterm';
import type { SerializeAddon } from '@xterm/addon-serialize';

export interface TerminalGrid { cols: number; rows: number }

/** The pinned serializer omits mouse encoding. Read xterm's resolved state,
* including resets, rather than infer it from output chunks. This private
* accessor is pinned by real-xterm round-trip tests in terminal-transfer.test.ts.
*/
export function serializeTransferTerminal(terminal: Terminal, serialize: SerializeAddon): string {
const encoding = (terminal as unknown as {
_core: { mouseStateService: { activeEncoding: string } };
})._core.mouseStateService.activeEncoding;
Comment on lines +11 to +13

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A missing _core.mouseStateService here throws out through captureTransferContent and past handOff's only try, so transfer_workspace_content never fires and the move hangs until the arrival watchdog hands it back. Optional-chaining degrades it to what it should be: a lost mouse mode.

Suggested change
const encoding = (terminal as unknown as {
_core: { mouseStateService: { activeEncoding: string } };
})._core.mouseStateService.activeEncoding;
const encoding = (terminal as unknown as {
_core?: { mouseStateService?: { activeEncoding?: string } };
})._core?.mouseStateService?.activeEncoding;

const mode = encoding === 'SGR' ? 1006 : encoding === 'SGR_PIXELS' ? 1016 : null;
return serialize.serialize() + (mode === null ? '' : `\x1b[?${mode}h`);
}
Loading