diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 74180bee1a7..561df768e37 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -9,6 +9,7 @@ import { POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET, POST_REPLAY_REATTACH_RESET_KEEP_MOUSE, + RESET_AFTER_BYTE_GAP, RESET_KITTY_KEYBOARD_PROTOCOL, RESET_TERMINAL_CURSOR_STYLE } from '../../../../shared/terminal-mode-reset-profiles' @@ -10210,7 +10211,7 @@ describe('connectPanePty', () => { claim: true }) expect(written).toContain(viewportClear) - expect(written).not.toContain('\x1b[0m\x1b(B\x0f\x1b[2J\x1b[3J\x1b[H') + expect(written).not.toContain(`${RESET_AFTER_BYTE_GAP}\x1b[2J\x1b[3J\x1b[H`) expect(written).toEqual( expect.arrayContaining([coldScrollback, POST_REPLAY_MODE_RESET, blankViewport]) ) @@ -12119,7 +12120,7 @@ describe('connectPanePty', () => { await flushAsyncTicks(4) const written = pane.terminal.write.mock.calls.map(([data]) => data as string) - const gapReset = written.find((data) => data.startsWith('\x1b[0m')) + const gapReset = written.find((data) => data === RESET_AFTER_BYTE_GAP) expect(gapReset).toBeDefined() // charset too: a dropped ESC(B otherwise renders text as box characters expect(gapReset).toContain('\x1b(B') @@ -15488,7 +15489,7 @@ describe('connectPanePty', () => { expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) expect(pane.terminal.resize).toHaveBeenCalledWith(100, 30) expect(pane.terminal.write).toHaveBeenCalledWith( - '\x1b[0m\x1b(B\x0f\x1b[2J\x1b[3J\x1b[H', + `${RESET_AFTER_BYTE_GAP}\x1b[2J\x1b[3J\x1b[H`, expect.any(Function) ) expect(pane.terminal.write).toHaveBeenCalledWith('snapshot-state\r\n', expect.any(Function)) @@ -15541,7 +15542,7 @@ describe('connectPanePty', () => { expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) expect(pane.terminal.write).toHaveBeenCalledWith( - '\x1b[0m\x1b(B\x0f\x1b[?1049l\x1b[2J\x1b[3J\x1b[H', + `${RESET_AFTER_BYTE_GAP}\x1b[?1049l\x1b[2J\x1b[3J\x1b[H`, expect.any(Function) ) expect(pane.terminal.write).toHaveBeenCalledWith( @@ -15549,16 +15550,16 @@ describe('connectPanePty', () => { expect.any(Function) ) expect(pane.terminal.write).toHaveBeenCalledWith( - '\x1b[0m\x1b(B\x0f\x1b[?1049h\x1b[2J\x1b[H', + `${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`, expect.any(Function) ) const writes = (pane.terminal.write as ReturnType).mock.calls.map( (call) => call[0] ) expect(writes.indexOf('preserved-shell-history\r\n')).toBeLessThan( - writes.indexOf('\x1b[0m\x1b(B\x0f\x1b[?1049h\x1b[2J\x1b[H') + writes.indexOf(`${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`) ) - expect(writes.indexOf('\x1b[0m\x1b(B\x0f\x1b[?1049h\x1b[2J\x1b[H')).toBeLessThan( + expect(writes.indexOf(`${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`)).toBeLessThan( writes.indexOf('altscreen-snapshot\r\n') ) expect(pane.terminal.write).toHaveBeenCalledWith('altscreen-snapshot\r\n', expect.any(Function)) @@ -15760,7 +15761,7 @@ describe('connectPanePty', () => { disposable.dispose() }) - it('abandons a stalled hidden restore and drains pending foreground chunks warning-first', async () => { + it('abandons a stalled hidden restore with reset, warning, then pending foreground', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') const capturedDataCallback: { @@ -15822,6 +15823,7 @@ describe('connectPanePty', () => { const warningIndex = written.findIndex((data) => data.includes('main recovery was unavailable')) const combinedLiveIndex = written.indexOf(firstLive + secondLive) expect(warningIndex).toBeGreaterThanOrEqual(0) + expect(written[warningIndex - 1]).toBe(RESET_AFTER_BYTE_GAP) expect(combinedLiveIndex).toBeGreaterThan(warningIndex) snapshot.resolve({ @@ -15888,7 +15890,7 @@ describe('connectPanePty', () => { await flushAsyncTicks(10) const written = pane.terminal.write.mock.calls.map(([data]) => data as string) - const resetIndex = written.findIndex((data) => data.startsWith('\x1b[0m')) + const resetIndex = written.indexOf(RESET_AFTER_BYTE_GAP) const liveIndex = written.findIndex((data) => data.includes('live-after-reveal')) expect(resetIndex).toBeGreaterThanOrEqual(0) expect(liveIndex).toBeGreaterThanOrEqual(0) @@ -16412,7 +16414,7 @@ describe('connectPanePty', () => { expect(pane.terminal.clear).toHaveBeenCalled() expect(pane.terminal.write).not.toHaveBeenCalledWith(live, expect.any(Function)) expect(pane.terminal.write).not.toHaveBeenCalledWith( - '\x1b[0m\x1b(B\x0f\x1b[2J\x1b[3J\x1b[H', + `${RESET_AFTER_BYTE_GAP}\x1b[2J\x1b[3J\x1b[H`, expect.any(Function) ) disposable.dispose() @@ -17310,7 +17312,7 @@ describe('connectPanePty', () => { await flushAsyncTicks(6) expect(pane.terminal.write).not.toHaveBeenCalledWith( - '\x1b[0m\x1b(B\x0f\x1b[2J\x1b[3J\x1b[H', + `${RESET_AFTER_BYTE_GAP}\x1b[2J\x1b[3J\x1b[H`, expect.any(Function) ) expect(pane.terminal.write).toHaveBeenCalledWith( diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index fefcd24816a..d8b40412b51 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -413,7 +413,7 @@ const FOREGROUND_GRID_DRIFT_CHECK_MIN_MS = 250 // Why: this is only shown if hidden renderer output was skipped and main-owned // terminal state is unavailable, so the user has an explicit loss signal. const HIDDEN_OUTPUT_RESTORE_UNAVAILABLE_WARNING = - '\x18\x1b[0m\r\n[Orca skipped hidden terminal output because main recovery was unavailable.]\r\n' + '\r\n[Orca skipped hidden terminal output because main recovery was unavailable.]\r\n' type E2eTerminalPtyDataInjectionApi = { inject: (paneKey: string, data: string, meta?: PtyDataMeta) => boolean keys: () => string[] @@ -7066,16 +7066,9 @@ export function connectPanePty( // backpressure must not outlive it — it would re-open recovery and banner a second time. clearHiddenOutputRestoreFloodRepaintTimer() writeRestoreUnavailableWarning() + } else { + writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true) } - // Why before both exits: the gate dropped renderer-bound bytes, so the pen - // that was live when it started dropping is unknown — if the dropped span - // held the `ESC[22m`/`ESC[0m` closing a run, xterm still has bold (or any - // other attribute) latched. Abandoning means no snapshot will rebuild the - // buffer, so nothing else ever clears it and every later cell inherits it - // (STA-4042). Same for the charset designation, whose loss renders text as - // box characters. Cheap and idempotent; a live TUI re-asserts both on its - // next write. - writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true) if (hadPendingOverflow) { return } @@ -7216,6 +7209,8 @@ export function connectPanePty( } function writeRestoreUnavailableWarning(): void { + // The reset must parse before both the warning and any foreground drain. + writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true) if (!shouldWritePtyOutputForeground(deps.isVisibleRef.current)) { return } diff --git a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts index 9d871773adb..9076ebf0732 100644 --- a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { Terminal } from '@xterm/headless' +import { RESET_AFTER_BYTE_GAP } from '../../../../shared/terminal-mode-reset-profiles' import { buildMainModelSnapshotReplayWrites, hasPositiveTerminalDimensions, @@ -11,6 +12,32 @@ function writeTerminal(terminal: Terminal, data: string): Promise { return new Promise((resolve) => terminal.write(data, resolve)) } +type CellWithOscLink = { + extended?: { urlId?: number } + isBold: () => number +} + +describe('RESET_AFTER_BYTE_GAP', () => { + it('grounds incomplete escapes, SGR, OSC 8, and every charset register', async () => { + const terminal = new Terminal({ cols: 40, rows: 2, allowProposedApi: true }) + try { + await writeTerminal( + terminal, + '\x1b[1m\x1b(0\x1b)0\x1b*0\x1b+0\x0e\x1b]8;;https://example.test\x1b\\q\x1b[' + ) + await writeTerminal(terminal, `${RESET_AFTER_BYTE_GAP}A\x0eq\x1bnq\x1boq`) + + const line = terminal.buffer.active.getLine(0) + const plainCell = line?.getCell(1) as CellWithOscLink | undefined + expect(line?.translateToString(true)).toBe('─Aqqq') + expect(plainCell?.isBold()).toBe(0) + expect(plainCell?.extended?.urlId ?? 0).toBe(0) + } finally { + terminal.dispose() + } + }) +}) + describe('hasPositiveTerminalDimensions', () => { it('accepts only finite positive numeric pairs', () => { expect(hasPositiveTerminalDimensions(80, 24)).toBe(true) @@ -42,7 +69,7 @@ describe('resolvePositiveTerminalDimensions', () => { describe('buildMainModelSnapshotReplayWrites', () => { it('clears normal buffer + scrollback before a normal-buffer snapshot', () => { expect(buildMainModelSnapshotReplayWrites({ data: 'shell-output' })).toEqual([ - '\x1b[0m\x1b(B\x0f\x1b[2J\x1b[3J\x1b[H', + `${RESET_AFTER_BYTE_GAP}\x1b[2J\x1b[3J\x1b[H`, 'shell-output' ]) }) @@ -59,9 +86,9 @@ describe('buildMainModelSnapshotReplayWrites', () => { scrollbackAnsi: 'normal-history' }) ).toEqual([ - '\x1b[0m\x1b(B\x0f\x1b[?1049l\x1b[2J\x1b[3J\x1b[H', + `${RESET_AFTER_BYTE_GAP}\x1b[?1049l\x1b[2J\x1b[3J\x1b[H`, 'normal-history', - '\x1b[0m\x1b(B\x0f\x1b[?1049h\x1b[2J\x1b[H', + `${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`, 'alt-frame' ]) }) @@ -69,7 +96,7 @@ describe('buildMainModelSnapshotReplayWrites', () => { it('enters a cleared alt screen when no split scrollback is available', () => { expect( buildMainModelSnapshotReplayWrites({ data: 'alt-frame', alternateScreen: true }) - ).toEqual(['\x1b[0m\x1b(B\x0f\x1b[?1049h\x1b[2J\x1b[H', 'alt-frame']) + ).toEqual([`${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`, 'alt-frame']) }) }) @@ -153,9 +180,9 @@ describe('buildMainModelSnapshotReplayWrites alt-frame skip', () => { { skipAltFrame: true } ) ).toEqual([ - '\x1b[0m\x1b(B\x0f\x1b[?1049l\x1b[2J\x1b[3J\x1b[H', + `${RESET_AFTER_BYTE_GAP}\x1b[?1049l\x1b[2J\x1b[3J\x1b[H`, 'normal-history', - '\x1b[0m\x1b(B\x0f\x1b[?1049h\x1b[2J\x1b[H', + `${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`, 'complete-live-state' ]) }) @@ -172,7 +199,7 @@ describe('buildMainModelSnapshotReplayWrites alt-frame skip', () => { }, { skipAltFrame: true } ) - ).toEqual(['\x1b[0m\x1b(B\x0f\x1b[?1049h\x1b[2J\x1b[H', 'complete-live-state']) + ).toEqual([`${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`, 'complete-live-state']) }) it('keeps composed data when an older producer omits the mode boundary', () => { @@ -181,13 +208,13 @@ describe('buildMainModelSnapshotReplayWrites alt-frame skip', () => { { data: 'legacy-modes-and-frame', alternateScreen: true }, { skipAltFrame: true } ) - ).toEqual(['\x1b[0m\x1b(B\x0f\x1b[?1049h\x1b[2J\x1b[H', 'legacy-modes-and-frame']) + ).toEqual([`${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`, 'legacy-modes-and-frame']) }) it('never drops a normal-buffer snapshot, whose rows reflow correctly', () => { expect( buildMainModelSnapshotReplayWrites({ data: 'shell-output' }, { skipAltFrame: true }) - ).toEqual(['\x1b[0m\x1b(B\x0f\x1b[2J\x1b[3J\x1b[H', 'shell-output']) + ).toEqual([`${RESET_AFTER_BYTE_GAP}\x1b[2J\x1b[3J\x1b[H`, 'shell-output']) }) // STA-4042: a replay only runs because renderer-bound bytes were dropped, so @@ -205,7 +232,7 @@ describe('buildMainModelSnapshotReplayWrites alt-frame skip', () => { buildMainModelSnapshotReplayWrites({ data: 'alt-frame', alternateScreen: true }) ] for (const writes of branches) { - expect(writes[0].startsWith('\x1b[0m')).toBe(true) + expect(writes[0].startsWith(RESET_AFTER_BYTE_GAP)).toBe(true) // charset too: a dropped ESC(B renders text as box characters expect(writes[0].includes('\x1b(B')).toBe(true) // Nothing may be replayed ahead of the first reset. diff --git a/src/shared/terminal-mode-reset-profiles.ts b/src/shared/terminal-mode-reset-profiles.ts index a41cef35480..26cc162fdde 100644 --- a/src/shared/terminal-mode-reset-profiles.ts +++ b/src/shared/terminal-mode-reset-profiles.ts @@ -40,10 +40,10 @@ export const COLD_RESTORE_SEED_MODE_RESET = RESET_MOUSE_REPORTING // drains queued foreground chunks under the stale pen). export const RESET_GRAPHIC_RENDITION = '\x1b[0m' -// Designate ASCII into G0 and shift back to it. Same failure as the pen but a -// louder symptom: a dropped `ESC(B` leaves line-drawing selected and ordinary -// text renders as box characters. -const RESET_CHARSET_TO_ASCII = '\x1b(B\x0f' +// Cancel any partial escape, close OSC 8, restore every ISO 2022 register, and select G0. +const RESET_INCOMPLETE_ESCAPE = '\x18' +const RESET_OSC_HYPERLINK = '\x1b]8;;\x1b\\' +const RESET_CHARSETS_TO_ASCII = '\x1b(B\x1b)B\x1b*B\x1b+B\x0f' /** * State to re-establish when renderer-bound bytes were dropped and the gap @@ -53,7 +53,7 @@ const RESET_CHARSET_TO_ASCII = '\x1b(B\x0f' * negotiates them at startup. So reset the state a gap can strand and that no * running TUI re-asserts on its own, and leave the rest to its next repaint. */ -export const RESET_AFTER_BYTE_GAP = `${RESET_GRAPHIC_RENDITION}${RESET_CHARSET_TO_ASCII}` +export const RESET_AFTER_BYTE_GAP = `${RESET_INCOMPLETE_ESCAPE}${RESET_OSC_HYPERLINK}${RESET_GRAPHIC_RENDITION}${RESET_CHARSETS_TO_ASCII}` // Why: DECTCEM applies in emission order, so the payload's last ?25l/?25h is the cursor state the TUI left. export function replayPayloadEndsWithCursorHidden(payload: string): boolean {