mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
fix(terminal): fully ground byte-gap recovery state
This commit is contained in:
@@ -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<typeof vi.fn>).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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user