mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 08:02:54 +00:00
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:
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user