fix(terminal): stop "104;255" leaking onto agent prompts after a tab switch (#31411)

A CSI scan now stops at ESC/CAN/SUB (as xterm's parser does), so a hidden-startup carry ending mid-CSI no longer swallows the next OSC's introducer and leaks `104;255` onto the prompt. Fixes #31410.

Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
Hoang Le Quoc
2026-10-24 16:59:59 -07:00
committed by GitHub
co-authored by Neil
parent d170b5a806
commit c580d7f2d3
3 changed files with 152 additions and 0 deletions
@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Terminal } from '@xterm/headless'
import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec'
import { flushAsyncTicks, writeHeadlessTerminal } from './pty-connection-test-async'
import {
createManager,
createMockTransport,
createPane,
type ConnectCallbacks,
type MockTransport
} from './pty-connection-test-pane-fixtures'
import { buildPaneConnectionDeps } from './pty-connection-test-deps'
import { createInitialStoreState } from './pty-connection-test-store-fixtures'
import type { StoreState } from './pty-connection-test-store-state'
import {
installTerminalTestGlobals,
restoreTerminalTestGlobals
} from './pty-connection-test-environment'
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
// Why: a hidden carry ending mid-CSI must not swallow the next chunk's `ESC ]` (#31410).
const HIDDEN_STRAGGLER_ENDING_MID_SGR = '\x1b[?2026h\x1b[H\x1b[38;2;177;1'
const CLAUDE_PALETTE_RESET = '\x1b]104;255\x07'
const PROMPT_SNAPSHOT = { data: '\x1b[H❯ ', cols: 120, rows: 40, seq: 1000 }
describe('hidden query carry vs a following OSC', () => {
let mockStoreState: StoreState
let transportFactoryQueue: MockTransport[] = []
let createdTransportOptions: Record<string, unknown>[] = []
let storeSubscribers: ((state: StoreState) => void)[] = []
beforeEach(async () => {
vi.resetModules()
vi.clearAllMocks()
transportFactoryQueue = []
createdTransportOptions = []
storeSubscribers = []
mockStoreState = createInitialStoreState(() => mockStoreState)
bindPtyConnectionTestMocks({
getState: () => mockStoreState,
getTransportQueue: () => transportFactoryQueue,
getTransportOptions: () => createdTransportOptions,
getStoreSubscribers: () => storeSubscribers,
setStoreSubscribers: (subscribers) => {
storeSubscribers = subscribers
}
})
await installTerminalTestGlobals()
})
afterEach(async () => {
await restoreTerminalTestGlobals()
})
it('does not paint an OSC payload after the reveal restore when a hidden chunk ended mid-CSI', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
const dataCallback: {
current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null
} = { current: null }
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
dataCallback.current = callbacks.onData ?? null
return 'pty-id'
})
transportFactoryQueue.push(transport)
const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot)
getMainBufferSnapshot.mockResolvedValue(PROMPT_SNAPSHOT)
const isVisibleRef = { current: false }
const pane = createPane(1)
const deps = buildPaneConnectionDeps(() => mockStoreState, {
isVisibleRef,
startup: { command: 'claude' }
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixtures implement the pane, manager and deps members connectPanePty reads.
const args = [pane, createManager(1), deps] as unknown as Parameters<typeof connectPanePty>
const binding = connectPanePty(...args)
const rendered = new Terminal({ cols: 120, rows: 40, allowProposedApi: true })
try {
await flushAsyncTicks(6)
dataCallback.current?.(HIDDEN_STRAGGLER_ENDING_MID_SGR, {
seq: 1000,
rawLength: HIDDEN_STRAGGLER_ENDING_MID_SGR.length
})
isVisibleRef.current = true
const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel')
_dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'unhide', markerSeq: 1000 })
await flushAsyncTicks(40)
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1)
dataCallback.current?.(CLAUDE_PALETTE_RESET, {
seq: 1000 + CLAUDE_PALETTE_RESET.length,
rawLength: CLAUDE_PALETTE_RESET.length
})
await flushAsyncTicks(40)
const writes = pane.terminal.write.mock.calls.map(([data]) => data).join('')
await writeHeadlessTerminal(rendered, writes)
const promptLine = rendered.buffer.active.getLine(0)?.translateToString(true)
expect(promptLine).toBe('❯ ')
} finally {
rendered.dispose()
binding.dispose()
}
})
})
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import {
containsStatefulRendererQuery,
extractHiddenStartupRendererQueryData,
findCsiFinalByteIndex
} from './terminal-reply-query-extraction'
describe('findCsiFinalByteIndex', () => {
it('returns the final byte of a complete CSI', () => {
expect(findCsiFinalByteIndex('\x1b[38;2;1;2;3m', 2)).toBe(12)
})
it('keeps a CSI with no final byte open', () => {
expect(findCsiFinalByteIndex('\x1b[38;2;177;1', 2)).toBe(-1)
})
it('ends a CSI where ESC, CAN or SUB aborts it, as xterm does', () => {
// Why: without the abort, the `]` of the next OSC read as this CSI's final byte.
expect(findCsiFinalByteIndex('\x1b[38;2;177;1\x1b]104;255\x07', 2)).toBe(11)
expect(findCsiFinalByteIndex('\x1b[38;2\x18A', 2)).toBe(5)
expect(findCsiFinalByteIndex('\x1b[38;2\x1aA', 2)).toBe(5)
})
})
describe('reply query extraction after an aborted CSI', () => {
it('finds a CPR that follows a carried partial CSI', () => {
const result = extractHiddenStartupRendererQueryData('\x1b[6n', '\x1b[38;2;177;1')
expect(result.statefulQueryData).toBe('\x1b[6n')
expect(result.pending).toBe('')
})
it('reports a CPR that follows a truncated CSI in the same chunk', () => {
expect(containsStatefulRendererQuery('\x1b[38;2;1\x1b[6n')).toBe(true)
})
})
@@ -133,12 +133,16 @@ export function containsStatefulRendererQuery(data: string): boolean {
return false
}
// Why: ESC/CAN/SUB abort a CSI (as in xterm), so a carried partial can't eat the next sequence's introducer.
export function findCsiFinalByteIndex(data: string, offset: number): number {
for (let index = offset; index < data.length; index++) {
const code = data.charCodeAt(index)
if (code >= 0x40 && code <= 0x7e) {
return index
}
if (code === 0x1b || code === 0x18 || code === 0x1a) {
return index - 1
}
}
return -1
}