-
Notifications
You must be signed in to change notification settings - Fork 0
Preserve terminal mouse encoding and grid across workspace transfers #630
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: workspaces-harness
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'; | ||||||||||
|
|
@@ -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; | ||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
|
||||||||||
| /** 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<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 | ||||||||||
|
|
@@ -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: [] }; | ||||||||||
| } | ||||||||||
| 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(); } | ||
| }); | ||
| }); |
| 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A missing
Suggested change
|
||||||||||||||
| const mode = encoding === 'SGR' ? 1006 : encoding === 'SGR_PIXELS' ? 1016 : null; | ||||||||||||||
| return serialize.serialize() + (mode === null ? '' : `\x1b[?${mode}h`); | ||||||||||||||
| } | ||||||||||||||
There was a problem hiding this comment.
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 theworkspace-move.test.tscase needs a--ratchetin the same commit.