fix(terminal): also restore charset designation after a dropped-byte gap

A gap can strand more than the pen: a dropped `ESC(B` leaves line-drawing
selected and ordinary text renders as box characters. Route both recovery
paths through one RESET_AFTER_BYTE_GAP profile covering SGR + charset.

Deliberately not a soft reset (DECSTR): xterm's DECSTR wipes kitty flags and
stacks (terminal-kitty-keyboard-mode-tracker applySoftReset), which would
silence Option chords for a live agent that negotiates them only at startup.
Reset what a gap strands and no running TUI re-asserts on its own; leave the
rest to its next repaint.
This commit is contained in:
Brennan Benson
2026-08-13 01:06:41 -07:00
parent 70a5d35b1f
commit 3f3f6ea192
5 changed files with 45 additions and 27 deletions
@@ -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<typeof vi.fn>).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(
@@ -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
}
@@ -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)
}
@@ -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]
}
@@ -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')