From cd696ce2458a95f1c42c8a46e02583e01cc94811 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 18 Sep 2026 01:52:10 -0700 Subject: [PATCH] fix(terminal): disable marker row shortcut after reflow --- .../terminal-osc-link-retirement.test.ts | 24 +++++++++++++++++++ src/shared/terminal-osc-link-retirement.ts | 12 +++++++--- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/shared/terminal-osc-link-retirement.test.ts b/src/shared/terminal-osc-link-retirement.test.ts index fd548987044..ef6d3051fef 100644 --- a/src/shared/terminal-osc-link-retirement.test.ts +++ b/src/shared/terminal-osc-link-retirement.test.ts @@ -192,4 +192,28 @@ describe.each([ } } ) + + it('keeps wrapped continuation cells when reflow erased the marker row', async () => { + const terminal = new HeadlessTerminal({ + cols: 20, + rows: 5, + scrollback: 500, + allowProposedApi: true, + logLevel: 'off' + }) + const retirement = createTerminalOscLinkRetirement(terminal) + const liveUri = `${URL}/reflow-erased-marker` + try { + await write(terminal, `\x1b]8;;${liveUri}\x1b\\abcdefghijklmno${CLOSE}\r\n`) + terminal.resize(5, 5) + await write(terminal, '\x1b[1;1H\x1b[2K\x1b[5;1H') + for (let index = 0; index < 1024; index++) { + await write(terminal, `\r\x1b[2K${OPEN}s${CLOSE}`) + } + retirement() + expect(hasUri(terminal, 'normal', liveUri)).toBe(true) + } finally { + terminal.dispose() + } + }) }) diff --git a/src/shared/terminal-osc-link-retirement.ts b/src/shared/terminal-osc-link-retirement.ts index 5883d365dae..30a8c664bed 100644 --- a/src/shared/terminal-osc-link-retirement.ts +++ b/src/shared/terminal-osc-link-retirement.ts @@ -2,13 +2,15 @@ import type { IBuffer, Terminal } from '@xterm/headless' type OscLinkMarker = { dispose(): void; line?: number } type OscLinkEntry = { id: number; lines: OscLinkMarker[] } -type TerminalBuffers = Pick +type TerminalBuffers = Pick /** xterm's line markers outlive overwritten hyperlinks, including redraws without scrollback. */ export function createTerminalOscLinkRetirement(terminal: TerminalBuffers): () => number { const SWEEP_GROWTH = 1024 let nextSweepSize = SWEEP_GROWTH let previousSize = 0 + let markerRowsEnabled = true + let previousColumns = terminal.cols function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null @@ -66,7 +68,7 @@ export function createTerminalOscLinkRetirement(terminal: TerminalBuffers): () = } } - /** Marker lines identify the only rows that can still contain a registered link. */ + /** Marker lines identify link rows until a reflow can leave text on unmarked continuation rows. */ function collectMarkerRows(entries: Iterable): Set | undefined { const rows = new Set() for (const value of entries) { @@ -99,6 +101,10 @@ export function createTerminalOscLinkRetirement(terminal: TerminalBuffers): () = return 0 } const entries: Map = service._dataByLinkId + if (terminal.cols !== previousColumns) { + markerRowsEnabled = false + previousColumns = terminal.cols + } if (entries.size < previousSize) { nextSweepSize = entries.size + SWEEP_GROWTH } @@ -108,7 +114,7 @@ export function createTerminalOscLinkRetirement(terminal: TerminalBuffers): () = } const live = new Set() - const markerRows = collectMarkerRows(entries.values()) + const markerRows = markerRowsEnabled ? collectMarkerRows(entries.values()) : undefined collectBufferLinks(terminal.buffer.normal, live, markerRows) collectBufferLinks(terminal.buffer.alternate, live, markerRows) // An OSC 8 open can finish one write before its linked text arrives in the next.