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 e804cfbedf1..90069139515 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -10210,7 +10210,7 @@ describe('connectPanePty', () => { claim: true }) expect(written).toContain(viewportClear) - expect(written).not.toContain('\x1b[0m\x1b[2J\x1b[3J\x1b[H') + expect(written).not.toContain('\x1b[0m\x1b(B\x0f\x1b[2J\x1b[3J\x1b[H') expect(written).toEqual( expect.arrayContaining([coldScrollback, POST_REPLAY_MODE_RESET, blankViewport]) ) @@ -15466,7 +15466,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[2J\x1b[3J\x1b[H', + '\x1b[0m\x1b(B\x0f\x1b[2J\x1b[3J\x1b[H', expect.any(Function) ) expect(pane.terminal.write).toHaveBeenCalledWith('snapshot-state\r\n', expect.any(Function)) @@ -15519,7 +15519,7 @@ describe('connectPanePty', () => { expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) expect(pane.terminal.write).toHaveBeenCalledWith( - '\x1b[0m\x1b[?1049l\x1b[2J\x1b[3J\x1b[H', + '\x1b[0m\x1b(B\x0f\x1b[?1049l\x1b[2J\x1b[3J\x1b[H', expect.any(Function) ) expect(pane.terminal.write).toHaveBeenCalledWith( @@ -15527,16 +15527,16 @@ describe('connectPanePty', () => { expect.any(Function) ) expect(pane.terminal.write).toHaveBeenCalledWith( - '\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', + '\x1b[0m\x1b(B\x0f\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[?1049h\x1b[2J\x1b[H') + writes.indexOf('\x1b[0m\x1b(B\x0f\x1b[?1049h\x1b[2J\x1b[H') ) - expect(writes.indexOf('\x1b[0m\x1b[?1049h\x1b[2J\x1b[H')).toBeLessThan( + expect(writes.indexOf('\x1b[0m\x1b(B\x0f\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)) @@ -15866,7 +15866,7 @@ describe('connectPanePty', () => { await flushAsyncTicks(10) const written = pane.terminal.write.mock.calls.map(([data]) => data as string) - const resetIndex = written.indexOf('\x1b[0m') + const resetIndex = written.findIndex((data) => data.startsWith('\x1b[0m')) const liveIndex = written.findIndex((data) => data.includes('live-after-reveal')) expect(resetIndex).toBeGreaterThanOrEqual(0) expect(liveIndex).toBeGreaterThanOrEqual(0) @@ -16390,7 +16390,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[2J\x1b[3J\x1b[H', + '\x1b[0m\x1b(B\x0f\x1b[2J\x1b[3J\x1b[H', expect.any(Function) ) disposable.dispose() @@ -17288,7 +17288,7 @@ describe('connectPanePty', () => { await flushAsyncTicks(6) expect(pane.terminal.write).not.toHaveBeenCalledWith( - '\x1b[0m\x1b[2J\x1b[3J\x1b[H', + '\x1b[0m\x1b(B\x0f\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 0440c8049c5..8c770f48fdc 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -124,7 +124,7 @@ import { POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET, POST_REPLAY_REATTACH_RESET_KEEP_MOUSE, - RESET_GRAPHIC_RENDITION, + RESET_AFTER_BYTE_GAP, RESET_KITTY_KEYBOARD_PROTOCOL, RESET_TERMINAL_CURSOR_STYLE } from '../../../../shared/terminal-mode-reset-profiles' @@ -7064,9 +7064,10 @@ export function connectPanePty( // 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). Cheap and idempotent; a live TUI re-arms its own attributes - // on the next write. - writePtyOutputToXterm(RESET_GRAPHIC_RENDITION, true) + // (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 } 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 10a2305790e..9d871773adb 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 @@ -42,7 +42,7 @@ describe('resolvePositiveTerminalDimensions', () => { describe('buildMainModelSnapshotReplayWrites', () => { it('clears normal buffer + scrollback before a normal-buffer snapshot', () => { expect(buildMainModelSnapshotReplayWrites({ data: 'shell-output' })).toEqual([ - '\x1b[0m\x1b[2J\x1b[3J\x1b[H', + '\x1b[0m\x1b(B\x0f\x1b[2J\x1b[3J\x1b[H', 'shell-output' ]) }) @@ -59,9 +59,9 @@ describe('buildMainModelSnapshotReplayWrites', () => { scrollbackAnsi: 'normal-history' }) ).toEqual([ - '\x1b[0m\x1b[?1049l\x1b[2J\x1b[3J\x1b[H', + '\x1b[0m\x1b(B\x0f\x1b[?1049l\x1b[2J\x1b[3J\x1b[H', 'normal-history', - '\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', + '\x1b[0m\x1b(B\x0f\x1b[?1049h\x1b[2J\x1b[H', 'alt-frame' ]) }) @@ -69,7 +69,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[?1049h\x1b[2J\x1b[H', 'alt-frame']) + ).toEqual(['\x1b[0m\x1b(B\x0f\x1b[?1049h\x1b[2J\x1b[H', 'alt-frame']) }) }) @@ -153,9 +153,9 @@ describe('buildMainModelSnapshotReplayWrites alt-frame skip', () => { { skipAltFrame: true } ) ).toEqual([ - '\x1b[0m\x1b[?1049l\x1b[2J\x1b[3J\x1b[H', + '\x1b[0m\x1b(B\x0f\x1b[?1049l\x1b[2J\x1b[3J\x1b[H', 'normal-history', - '\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', + '\x1b[0m\x1b(B\x0f\x1b[?1049h\x1b[2J\x1b[H', 'complete-live-state' ]) }) @@ -172,7 +172,7 @@ describe('buildMainModelSnapshotReplayWrites alt-frame skip', () => { }, { skipAltFrame: true } ) - ).toEqual(['\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', 'complete-live-state']) + ).toEqual(['\x1b[0m\x1b(B\x0f\x1b[?1049h\x1b[2J\x1b[H', 'complete-live-state']) }) it('keeps composed data when an older producer omits the mode boundary', () => { @@ -181,13 +181,13 @@ describe('buildMainModelSnapshotReplayWrites alt-frame skip', () => { { data: 'legacy-modes-and-frame', alternateScreen: true }, { skipAltFrame: true } ) - ).toEqual(['\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', 'legacy-modes-and-frame']) + ).toEqual(['\x1b[0m\x1b(B\x0f\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[2J\x1b[3J\x1b[H', 'shell-output']) + ).toEqual(['\x1b[0m\x1b(B\x0f\x1b[2J\x1b[3J\x1b[H', 'shell-output']) }) // STA-4042: a replay only runs because renderer-bound bytes were dropped, so @@ -206,6 +206,8 @@ describe('buildMainModelSnapshotReplayWrites alt-frame skip', () => { ] for (const writes of branches) { expect(writes[0].startsWith('\x1b[0m')).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. expect(writes.indexOf('scrollback')).not.toBe(0) } diff --git a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts index 9ac41a4aaed..81fa4b6b525 100644 --- a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts +++ b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts @@ -1,6 +1,6 @@ import type { ManagedPane } from '@/lib/pane-manager/pane-manager-types' import { readProposedPaneFitDimensions } from '@/lib/pane-manager/pane-fit' -import { RESET_GRAPHIC_RENDITION } from '../../../../shared/terminal-mode-reset-profiles' +import { RESET_AFTER_BYTE_GAP } from '../../../../shared/terminal-mode-reset-profiles' /** * Shared guards and write choreography for painting a main-model snapshot into @@ -86,7 +86,7 @@ export function buildMainModelSnapshotReplayWrites( // bytes were dropped, so the pen the drop interrupted is unknown — without // clearing it the whole replayed buffer inherits it (STA-4042). The // alt-screen branches below already reset; this one did not. - return [`${RESET_GRAPHIC_RENDITION}\x1b[2J\x1b[3J\x1b[H`, snapshot.data] + return [`${RESET_AFTER_BYTE_GAP}\x1b[2J\x1b[3J\x1b[H`, snapshot.data] } // Older snapshot producers do not expose the mode/frame boundary. Keep their // composed data rather than dropping terminal modes together with the frame. @@ -101,14 +101,14 @@ export function buildMainModelSnapshotReplayWrites( // the existing \x1b[0m, so the normal-buffer history inherited the stale pen // even though the alt frame after it did not (STA-4042). return [ - `${RESET_GRAPHIC_RENDITION}\x1b[?1049l\x1b[2J\x1b[3J\x1b[H`, + `${RESET_AFTER_BYTE_GAP}\x1b[?1049l\x1b[2J\x1b[3J\x1b[H`, snapshot.scrollbackAnsi, - `${RESET_GRAPHIC_RENDITION}\x1b[?1049h\x1b[2J\x1b[H`, + `${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`, ...altFrame ] } // Why: the snapshot's ?1049h no-ops when already on alt screen and skips // blank cells; clear the alt buffer so the pre-hide frame can't bleed // through blank cells (spares normal-buffer scrollback). - return ['\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', ...altFrame] + return [`${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`, ...altFrame] } diff --git a/src/shared/terminal-mode-reset-profiles.ts b/src/shared/terminal-mode-reset-profiles.ts index 37cc59e603e..a41cef35480 100644 --- a/src/shared/terminal-mode-reset-profiles.ts +++ b/src/shared/terminal-mode-reset-profiles.ts @@ -40,6 +40,21 @@ 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' + +/** + * State to re-establish when renderer-bound bytes were dropped and the gap + * cannot be replayed away. Deliberately NOT a soft reset (DECSTR): xterm's + * DECSTR wipes kitty flags and stacks (see terminal-kitty-keyboard-mode-tracker + * applySoftReset), which would silence Option chords for a live agent that only + * 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}` + // 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 { const hideIndex = payload.lastIndexOf('\x1b[?25l')