From f169d75e080580472b9eb82056bce62d54316871 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 11 Sep 2026 13:43:57 -0700 Subject: [PATCH] Preserve terminal mouse encoding and grid across workspace transfers --- TESTING_AND_MODIFICATION_GUIDE.md | 19 ++++++- docs/specs/notepad.md | 4 +- docs/specs/notepad.rationale.md | 4 ++ docs/specs/transport.md | 16 +++--- lib/src/components/wall/workspace-transfer.ts | 19 ++++--- lib/src/lib/reconnect.ts | 10 +++- lib/src/lib/terminal-lifecycle.ts | 13 +++-- lib/src/lib/terminal-transfer.test.ts | 56 +++++++++++++++++++ lib/src/lib/terminal-transfer.ts | 16 ++++++ standalone/src/workspace-move.test.ts | 38 ++++++++++++- standalone/src/workspace-move.ts | 14 ++--- 11 files changed, 174 insertions(+), 35 deletions(-) create mode 100644 lib/src/lib/terminal-transfer.test.ts create mode 100644 lib/src/lib/terminal-transfer.ts diff --git a/TESTING_AND_MODIFICATION_GUIDE.md b/TESTING_AND_MODIFICATION_GUIDE.md index f0957c3fb..e62a0fe56 100644 --- a/TESTING_AND_MODIFICATION_GUIDE.md +++ b/TESTING_AND_MODIFICATION_GUIDE.md @@ -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 @@ -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. @@ -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. diff --git a/docs/specs/notepad.md b/docs/specs/notepad.md index 3ec7038b4..8e0e3a3c6 100644 --- a/docs/specs/notepad.md +++ b/docs/specs/notepad.md @@ -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`. @@ -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. diff --git a/docs/specs/notepad.rationale.md b/docs/specs/notepad.rationale.md index c3b2271d7..efa4dcf52 100644 --- a/docs/specs/notepad.rationale.md +++ b/docs/specs/notepad.rationale.md @@ -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 diff --git a/docs/specs/transport.md b/docs/specs/transport.md index 756637fa0..5580d5a1f 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -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 @@ -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`. +- 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 diff --git a/lib/src/components/wall/workspace-transfer.ts b/lib/src/components/wall/workspace-transfer.ts index c136f9502..6b8ac4252 100644 --- a/lib/src/components/wall/workspace-transfer.ts +++ b/lib/src/components/wall/workspace-transfer.ts @@ -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'; @@ -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 @@ -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; /** 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. */ @@ -136,11 +136,12 @@ export interface TransferredTerminal { * passed, and attached to the arrival the host queued at the invoke. */ export interface WorkspaceTransferContent { terminals: Record; + /** 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 @@ -156,8 +157,10 @@ export async function captureTransferContent( const terminals: Record = {}; 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: [] }; } diff --git a/lib/src/lib/reconnect.ts b/lib/src/lib/reconnect.ts index 2ebe25ff0..74eacd6d4 100644 --- a/lib/src/lib/reconnect.ts +++ b/lib/src/lib/reconnect.ts @@ -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'; @@ -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; /** 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. */ @@ -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 }); @@ -217,15 +220,18 @@ function resumeLivePtys( ptyList: PtyInfo[], replayBuffer: Map, saved: PersistedSession | null, + grids?: ReadonlyMap, ): 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; diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index f19788271..49cb14f60 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -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'; @@ -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, @@ -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. @@ -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, @@ -544,7 +547,7 @@ export async function serializeTerminal(id: string): Promise { 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. diff --git a/lib/src/lib/terminal-transfer.test.ts b/lib/src/lib/terminal-transfer.test.ts new file mode 100644 index 000000000..c92bb6499 --- /dev/null +++ b/lib/src/lib/terminal-transfer.test.ts @@ -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(resolve => terminal.write(data, resolve)); +async function mode(terminal: Terminal, number: number): Promise { + 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(); } + }); +}); diff --git a/lib/src/lib/terminal-transfer.ts b/lib/src/lib/terminal-transfer.ts new file mode 100644 index 000000000..f971892b9 --- /dev/null +++ b/lib/src/lib/terminal-transfer.ts @@ -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; + const mode = encoding === 'SGR' ? 1006 : encoding === 'SGR_PIXELS' ? 1016 : null; + return serialize.serialize() + (mode === null ? '' : `\x1b[?${mode}h`); +} diff --git a/standalone/src/workspace-move.test.ts b/standalone/src/workspace-move.test.ts index cccdd1e01..2e49bea44 100644 --- a/standalone/src/workspace-move.test.ts +++ b/standalone/src/workspace-move.test.ts @@ -16,6 +16,9 @@ import type { const mocks = vi.hoisted(() => ({ writes: [] as string[], + grids: [] as unknown[], + deferWrites: false, + writeCallbacks: [] as (() => void)[], invoke: vi.fn(async (_cmd: string, _args?: unknown) => undefined as unknown), listen: vi.fn(async () => () => {}), })); @@ -35,11 +38,17 @@ vi.mock("@xterm/addon-serialize", () => ({ SerializeAddon: class { serialize(): vi.mock("@xterm/addon-unicode-graphemes", () => ({ UnicodeGraphemesAddon: class {} })); vi.mock("@xterm/xterm", () => ({ Terminal: class { + _core = { mouseStateService: { activeEncoding: "DEFAULT" } }; + constructor(options: unknown) { mocks.grids.push(options); } parser = { registerCsiHandler: () => ({ dispose: () => {} }) }; modes = { mouseTrackingMode: "none" as const, bracketedPasteMode: false }; loadAddon(): void {} open(): void {} - write(data: string, callback?: () => void): void { mocks.writes.push(data); callback?.(); } + write(data: string, callback?: () => void): void { + mocks.writes.push(data); + if (callback && mocks.deferWrites) mocks.writeCallbacks.push(callback); + else callback?.(); + } focus(): void {} blur(): void {} onData(): { dispose: () => void } { return { dispose: () => {} }; } @@ -203,6 +212,9 @@ function fakePlatform( } beforeEach(() => { + mocks.deferWrites = false; + mocks.writeCallbacks.length = 0; + mocks.grids.length = 0; vi.clearAllMocks(); mocks.writes.length = 0; arrivals = []; @@ -705,6 +717,27 @@ describe("the target half", () => { }); describe("a transfer's content", () => { + it("drains replay before adopting even when no note has a pin", async () => { + arrivals = [payload()]; + mocks.deferWrites = true; + const boot = bootFromTearOut(fakePlatform()); + await vi.waitFor(() => expect(mocks.writeCallbacks).toHaveLength(2)); + expect(mocks.invoke).not.toHaveBeenCalledWith("adopt_done", expect.anything()); + mocks.writeCallbacks.splice(0).forEach(callback => callback()); + await boot; + expect(mocks.invoke).toHaveBeenCalledWith("adopt_done", { workspaceId: WORKSPACE_ID }); + }); + + it("keeps notes without restoring legacy transferred pins", async () => { + arrivals = [payload({ pins: [{ + surfaceId: "pane-a", noteId: "n1", startLine: 0, endLine: 0, + startColumn: 0, endColumn: 1, shape: "linewise", expectedRawText: "x", + }] } as Partial)]; + await bootFromTearOut(fakePlatform()); + expect(getNotes("pane-a")).toHaveLength(1); + expect(getNotes("pane-a")[0].source).toBeUndefined(); + }); + it("serializes each terminal at the host's mark and hands the content over behind the invoke", async () => { const order: string[] = []; initWorkspaceMoves(fakePlatform(order, { marks: { "pane-a": 42 } })); @@ -725,10 +758,11 @@ describe("a transfer's content", () => { it("writes the source's buffer ahead of the since-mark replay when it mounts the arrival", async () => { arrivals = [payload({ - terminals: { "pane-a": { serialized: "\u001b[1mfrom-source\u001b[0m", mark: 42 } }, + terminals: { "pane-a": { serialized: "\u001b[1mfrom-source\u001b[0m", mark: 42, grid: { cols: 120, rows: 45 } } }, pins: [], } as Partial)]; await bootFromTearOut(fakePlatform()); + expect(mocks.grids[mocks.grids.length - 1]).toMatchObject({ cols: 120, rows: 45 }); // One write: the rebuilt buffer, then everything after the mark, in order. expect(mocks.writes).toContain("\u001b[1mfrom-source\u001b[0mscrollback:pane-a"); expect(mocks.writes.filter((w) => w.includes("scrollback:pane-a"))).toHaveLength(1); diff --git a/standalone/src/workspace-move.ts b/standalone/src/workspace-move.ts index 0a6a3dc2d..94ab1235a 100644 --- a/standalone/src/workspace-move.ts +++ b/standalone/src/workspace-move.ts @@ -6,7 +6,7 @@ import { flushTerminal } from "dormouse-lib/lib/terminal-registry"; import { REPLAY_MODE_RESET, writeReplay } from "dormouse-lib/lib/terminal-report-filter"; import { applyTerminalSemanticEvents } from "dormouse-lib/lib/terminal-state-store"; import { registry as terminalRegistry } from "dormouse-lib/lib/terminal-store"; -import { hydrateNotepadFromVolatile, removeSurface, restoreTerminalPins } from "dormouse-lib/lib/notepad/notepad-store"; +import { hydrateNotepadFromVolatile, removeSurface } from "dormouse-lib/lib/notepad/notepad-store"; import { getWallHandle } from "dormouse-lib/components/wall/wall-handles"; import { forgetWorkspaceBootPlan, setWorkspaceBootPlan } from "dormouse-lib/components/wall/workspace-boot-plans"; import { wallBootFromResult, type WallBootPlans } from "dormouse-lib/components/wall/wall-types"; @@ -383,16 +383,16 @@ async function planArrival( const result = resumeOrRestoreFrom(platform, live, { savedSession: payload.workspace.session, ptyIds, + terminalGrids: new Map(Object.entries(payload.terminals ?? {}).flatMap( + ([id, terminal]) => terminal.grid ? [[id, terminal.grid] as const] : [], + )), }); // The notes travelled in the payload rather than through the archive: a move // is not a closure (`docs/specs/notepad.md` → "Closure"). hydrateNotepadFromVolatile(payload.notepad, payload.allIds); - // Their pins point into the buffers just rebuilt at the same lines — once - // xterm has parsed the rebuild, which it does asynchronously. - if (payload.pins?.length) { - await Promise.all([...ptyIds].map((id) => flushTerminal(id))); - restoreTerminalPins(payload.pins); - } + // Finish parsing at the source grid before the Wall can fit the target pane. + // Notes survive, but source markers belong to the disposed xterm instance. + await Promise.all([...ptyIds].map((id) => flushTerminal(id))); return wallBootFromResult(result); }