mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 16:02:24 +00:00
Prevent hidden terminal TUI overlap (#7054)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -140,7 +140,7 @@ type StoreState = {
|
||||
}
|
||||
|
||||
type ConnectCallbacks = {
|
||||
onData?: (data: string, meta?: { seq?: number; rawLength?: number }) => void
|
||||
onData?: (data: string, meta?: { seq?: number; rawLength?: number; background?: boolean }) => void
|
||||
onReplayData?: (data: string, meta?: { clearBeforeReplay?: boolean }) => void
|
||||
onError?: (msg: string) => void
|
||||
}
|
||||
@@ -371,6 +371,21 @@ function createPane(paneId: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function captureCallbackTerminalWrites(pane: ReturnType<typeof createPane>): {
|
||||
writes: string[]
|
||||
parseCallbacks: (() => void)[]
|
||||
} {
|
||||
const writes: string[] = []
|
||||
const parseCallbacks: (() => void)[] = []
|
||||
pane.terminal.write = function write(data: string, callback?: () => void): void {
|
||||
writes.push(data)
|
||||
if (callback) {
|
||||
parseCallbacks.push(callback)
|
||||
}
|
||||
} as typeof pane.terminal.write
|
||||
return { writes, parseCallbacks }
|
||||
}
|
||||
|
||||
function createManager(paneCount = 1, initialActivePaneId: number | null = null) {
|
||||
let activePaneId = initialActivePaneId
|
||||
const panes = Array.from({ length: paneCount }, (_, index) => ({
|
||||
@@ -5375,6 +5390,269 @@ describe('connectPanePty', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('schedules WebGL atlas recovery after hidden synchronized output parses', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('pty-id')
|
||||
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
|
||||
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
capturedDataCallback.current = callbacks.onData ?? null
|
||||
return 'pty-id'
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
|
||||
const pane = createPane(1)
|
||||
const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane)
|
||||
|
||||
connectPanePty(
|
||||
pane as never,
|
||||
createManager(1) as never,
|
||||
createDeps({
|
||||
isVisibleRef: { current: false }
|
||||
}) as never
|
||||
)
|
||||
await flushAsyncTicks(6)
|
||||
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const startChunk = '\x1b[?2026h'
|
||||
const plainRowChunk = '| hidden Claude row |\r\n'
|
||||
const endChunk = '\x1b[?2026l'
|
||||
|
||||
capturedDataCallback.current?.(startChunk)
|
||||
capturedDataCallback.current?.(plainRowChunk)
|
||||
capturedDataCallback.current?.(endChunk)
|
||||
|
||||
expect(writes).toEqual([])
|
||||
vi.advanceTimersByTime(50)
|
||||
|
||||
expect(writes).toEqual([`${startChunk}${plainRowChunk}${endChunk}`])
|
||||
expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled()
|
||||
|
||||
parseCallbacks[0]?.()
|
||||
|
||||
expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(3)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('recognizes hidden synchronized output markers split across PTY chunks', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('pty-id')
|
||||
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
|
||||
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
capturedDataCallback.current = callbacks.onData ?? null
|
||||
return 'pty-id'
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
|
||||
const pane = createPane(1)
|
||||
const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane)
|
||||
|
||||
connectPanePty(
|
||||
pane as never,
|
||||
createManager(1) as never,
|
||||
createDeps({
|
||||
isVisibleRef: { current: false }
|
||||
}) as never
|
||||
)
|
||||
await flushAsyncTicks(6)
|
||||
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
capturedDataCallback.current?.('\x1b[?202')
|
||||
capturedDataCallback.current?.('6hbody row\r\n')
|
||||
capturedDataCallback.current?.('tail\x1b[?20')
|
||||
capturedDataCallback.current?.('26l')
|
||||
|
||||
vi.advanceTimersByTime(50)
|
||||
|
||||
expect(writes.join('')).toBe('\x1b[?2026hbody row\r\ntail\x1b[?2026l')
|
||||
expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled()
|
||||
|
||||
parseCallbacks[0]?.()
|
||||
|
||||
expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(3)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not schedule hidden atlas recovery for ordinary rich text or metadata output', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('pty-id')
|
||||
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
|
||||
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
capturedDataCallback.current = callbacks.onData ?? null
|
||||
return 'pty-id'
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
|
||||
const pane = createPane(1)
|
||||
const { parseCallbacks } = captureCallbackTerminalWrites(pane)
|
||||
|
||||
connectPanePty(
|
||||
pane as never,
|
||||
createManager(1) as never,
|
||||
createDeps({
|
||||
isVisibleRef: { current: false }
|
||||
}) as never
|
||||
)
|
||||
await flushAsyncTicks(6)
|
||||
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
capturedDataCallback.current?.('plain hidden emoji 😀 and CJK 没改什么\r\n')
|
||||
capturedDataCallback.current?.('\x1b[48;2;52;52;52mcolored shell text\x1b[0m\r\n')
|
||||
capturedDataCallback.current?.('\x1b]0;hidden title\x07\x1b]133;A\x07')
|
||||
|
||||
vi.advanceTimersByTime(50)
|
||||
for (const callback of parseCallbacks) {
|
||||
callback()
|
||||
}
|
||||
|
||||
expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('schedules hidden atlas recovery for high-confidence TUI redraw controls', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('pty-id')
|
||||
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
|
||||
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
capturedDataCallback.current = callbacks.onData ?? null
|
||||
return 'pty-id'
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
|
||||
const pane = createPane(1)
|
||||
const { parseCallbacks } = captureCallbackTerminalWrites(pane)
|
||||
|
||||
connectPanePty(
|
||||
pane as never,
|
||||
createManager(1) as never,
|
||||
createDeps({
|
||||
isVisibleRef: { current: false }
|
||||
}) as never
|
||||
)
|
||||
await flushAsyncTicks(6)
|
||||
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
capturedDataCallback.current?.('\x1b[2J\x1b[Hredrawn hidden table\x1b[K')
|
||||
|
||||
vi.advanceTimersByTime(50)
|
||||
expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled()
|
||||
|
||||
parseCallbacks[0]?.()
|
||||
|
||||
expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('advances hidden rewrite state when synchronized output already requests recovery', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('pty-id')
|
||||
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
|
||||
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
capturedDataCallback.current = callbacks.onData ?? null
|
||||
return 'pty-id'
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
|
||||
const pane = createPane(1)
|
||||
const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane)
|
||||
|
||||
connectPanePty(
|
||||
pane as never,
|
||||
createManager(1) as never,
|
||||
createDeps({
|
||||
isVisibleRef: { current: false }
|
||||
}) as never
|
||||
)
|
||||
await flushAsyncTicks(6)
|
||||
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
capturedDataCallback.current?.('prompt rewrite\r')
|
||||
vi.advanceTimersByTime(50)
|
||||
expect(writes).toEqual(['prompt rewrite\r'])
|
||||
parseCallbacks.shift()?.()
|
||||
expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled()
|
||||
|
||||
capturedDataCallback.current?.('\x1b[?2026hredraw frame\x1b[?2026l')
|
||||
vi.advanceTimersByTime(50)
|
||||
expect(writes).toEqual(['prompt rewrite\r', '\x1b[?2026hredraw frame\x1b[?2026l'])
|
||||
parseCallbacks.shift()?.()
|
||||
expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1)
|
||||
scheduleTerminalWebglAtlasRecovery.mockClear()
|
||||
|
||||
capturedDataCallback.current?.('plain after frame')
|
||||
vi.advanceTimersByTime(50)
|
||||
parseCallbacks.shift()?.()
|
||||
|
||||
expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('resets hidden synchronized state when hidden renderer output is skipped', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('pty-id')
|
||||
const capturedDataCallback: {
|
||||
current: ((data: string, meta?: { background?: boolean }) => void) | null
|
||||
} = { current: null }
|
||||
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
capturedDataCallback.current = callbacks.onData ?? null
|
||||
return 'pty-id'
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
|
||||
const pane = createPane(1)
|
||||
const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane)
|
||||
const isVisibleRef = { current: false }
|
||||
|
||||
connectPanePty(
|
||||
pane as never,
|
||||
createManager(1) as never,
|
||||
createDeps({
|
||||
isVisibleRef
|
||||
}) as never
|
||||
)
|
||||
await flushAsyncTicks(6)
|
||||
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
capturedDataCallback.current?.('\x1b[?2026h')
|
||||
vi.advanceTimersByTime(50)
|
||||
parseCallbacks.shift()?.()
|
||||
expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1)
|
||||
scheduleTerminalWebglAtlasRecovery.mockClear()
|
||||
writes.length = 0
|
||||
|
||||
isVisibleRef.current = true
|
||||
;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate'
|
||||
capturedDataCallback.current?.('\x1b[?2026l', { background: true })
|
||||
expect(writes).toEqual([])
|
||||
|
||||
isVisibleRef.current = false
|
||||
;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'normal'
|
||||
capturedDataCallback.current?.('plain after skipped close\r\n')
|
||||
vi.advanceTimersByTime(50)
|
||||
parseCallbacks.shift()?.()
|
||||
|
||||
expect(writes).toEqual(['plain after skipped close\r\n'])
|
||||
expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('queues visible split-pane PTY bytes when the pane is not active', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport()
|
||||
@@ -8263,6 +8541,39 @@ describe('connectPanePty', () => {
|
||||
expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not schedule WebGL atlas recovery for plain synchronized foreground frames', async () => {
|
||||
const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)')
|
||||
try {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport()
|
||||
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
|
||||
transport.connect.mockImplementation(
|
||||
async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
capturedDataCallback.current = callbacks.onData ?? null
|
||||
return 'pty-id'
|
||||
}
|
||||
)
|
||||
transportFactoryQueue.push(transport)
|
||||
|
||||
const pane = createPane(1)
|
||||
let parseCallback: (() => void) | undefined
|
||||
pane.terminal.write = vi.fn((_data: string, callback?: () => void) => {
|
||||
parseCallback = callback
|
||||
})
|
||||
|
||||
connectPanePty(pane as never, createManager(1) as never, createDeps() as never)
|
||||
await flushAsyncTicks(6)
|
||||
|
||||
capturedDataCallback.current?.('\x1b[?2026hplain claude frame\x1b[?2026l')
|
||||
|
||||
expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled()
|
||||
parseCallback?.()
|
||||
expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
restoreNavigator()
|
||||
}
|
||||
})
|
||||
|
||||
it('forces a viewport refresh when foreground background SGR is split across PTY chunks', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport()
|
||||
|
||||
@@ -181,6 +181,9 @@ const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MS = 50
|
||||
const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MAX = 3
|
||||
const HIDDEN_OUTPUT_RESTORE_FOREGROUND_TIMEOUT_MS = 750
|
||||
const TERMINAL_RENDERER_RISK_SCAN_TAIL_CHARS = 256
|
||||
const SYNCHRONIZED_OUTPUT_START_SEQUENCE = '\x1b[?2026h'
|
||||
const SYNCHRONIZED_OUTPUT_END_SEQUENCE = '\x1b[?2026l'
|
||||
const SYNCHRONIZED_OUTPUT_MARKER_TAIL_CHARS = SYNCHRONIZED_OUTPUT_START_SEQUENCE.length - 1
|
||||
const CURSOR_SHOW_SEQUENCE = '\x1b[?25h'
|
||||
const CURSOR_HIDE_SEQUENCE = '\x1b[?25l'
|
||||
const REATTACH_IDLE_AGENT_CURSOR_RESET_DELAY_MS = 250
|
||||
@@ -796,16 +799,16 @@ function shouldWritePtyOutputForeground(isPaneVisible: boolean): boolean {
|
||||
}
|
||||
|
||||
function containsSynchronizedOutputStart(data: string): boolean {
|
||||
return data.includes('\x1b[?2026h')
|
||||
return data.includes(SYNCHRONIZED_OUTPUT_START_SEQUENCE)
|
||||
}
|
||||
|
||||
function containsSynchronizedOutputEnd(data: string): boolean {
|
||||
return data.includes('\x1b[?2026l')
|
||||
return data.includes(SYNCHRONIZED_OUTPUT_END_SEQUENCE)
|
||||
}
|
||||
|
||||
function shouldSynchronizedOutputRemainActive(data: string, wasActive: boolean): boolean {
|
||||
const lastStartIndex = data.lastIndexOf('\x1b[?2026h')
|
||||
const lastEndIndex = data.lastIndexOf('\x1b[?2026l')
|
||||
const lastStartIndex = data.lastIndexOf(SYNCHRONIZED_OUTPUT_START_SEQUENCE)
|
||||
const lastEndIndex = data.lastIndexOf(SYNCHRONIZED_OUTPUT_END_SEQUENCE)
|
||||
if (lastStartIndex === -1 && lastEndIndex === -1) {
|
||||
return wasActive
|
||||
}
|
||||
@@ -3244,6 +3247,96 @@ export function connectPanePty(
|
||||
return prefersRefresh
|
||||
}
|
||||
|
||||
function resetHiddenRendererRiskState(ptyId: string | null = null): void {
|
||||
hiddenRiskPtyId = ptyId
|
||||
hiddenSynchronizedOutputActive = false
|
||||
hiddenSynchronizedOutputMarkerTail = ''
|
||||
hiddenRewriteChunkEndedWithCarriageReturn = false
|
||||
hiddenRewriteCsiScanTail = ''
|
||||
}
|
||||
|
||||
function ensureHiddenRendererRiskStateForCurrentPty(): void {
|
||||
const ptyId = transport.getPtyId()
|
||||
if (hiddenRiskPtyId === ptyId) {
|
||||
return
|
||||
}
|
||||
resetHiddenRendererRiskState(ptyId)
|
||||
}
|
||||
|
||||
function resetSkippedHiddenRendererRiskState(): void {
|
||||
// Why: skipped/backlog bytes were not parsed by xterm; reset any live hidden
|
||||
// frame instead of letting dropped DEC starts make later plain bytes risky.
|
||||
resetHiddenRendererRiskState(transport.getPtyId())
|
||||
}
|
||||
|
||||
function hiddenSynchronizedOutputTouchesParsedFrame(data: string): boolean {
|
||||
const scanData = hiddenSynchronizedOutputMarkerTail
|
||||
? `${hiddenSynchronizedOutputMarkerTail}${data}`
|
||||
: data
|
||||
const currentChunkStartIndex = scanData.length - data.length
|
||||
let active = hiddenSynchronizedOutputActive
|
||||
let touchesParsedFrame = active && data.length > 0
|
||||
let offset = 0
|
||||
|
||||
while (offset < scanData.length) {
|
||||
const startIndex = scanData.indexOf(SYNCHRONIZED_OUTPUT_START_SEQUENCE, offset)
|
||||
const endIndex = scanData.indexOf(SYNCHRONIZED_OUTPUT_END_SEQUENCE, offset)
|
||||
if (startIndex === -1 && endIndex === -1) {
|
||||
break
|
||||
}
|
||||
if (endIndex !== -1 && (startIndex === -1 || endIndex < startIndex)) {
|
||||
if (
|
||||
active &&
|
||||
endIndex + SYNCHRONIZED_OUTPUT_END_SEQUENCE.length > currentChunkStartIndex
|
||||
) {
|
||||
touchesParsedFrame = true
|
||||
}
|
||||
active = false
|
||||
offset = endIndex + SYNCHRONIZED_OUTPUT_END_SEQUENCE.length
|
||||
continue
|
||||
}
|
||||
if (startIndex !== -1) {
|
||||
active = true
|
||||
if (startIndex + SYNCHRONIZED_OUTPUT_START_SEQUENCE.length > currentChunkStartIndex) {
|
||||
touchesParsedFrame = true
|
||||
}
|
||||
offset = startIndex + SYNCHRONIZED_OUTPUT_START_SEQUENCE.length
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (active && data.length > 0) {
|
||||
touchesParsedFrame = true
|
||||
}
|
||||
hiddenSynchronizedOutputActive = active
|
||||
hiddenSynchronizedOutputMarkerTail = scanData.slice(-SYNCHRONIZED_OUTPUT_MARKER_TAIL_CHARS)
|
||||
return touchesParsedFrame
|
||||
}
|
||||
|
||||
function hiddenTuiRedrawOutputPrefersAtlasRecovery(data: string): boolean {
|
||||
if (!data) {
|
||||
return false
|
||||
}
|
||||
const scanData = hiddenRewriteCsiScanTail ? `${hiddenRewriteCsiScanTail}${data}` : data
|
||||
const decision = terminalRewriteOutputRenderRefreshDecision(data, {
|
||||
previousChunkEndsWithCarriageReturn: hiddenRewriteChunkEndedWithCarriageReturn,
|
||||
previousRewriteCsiScanTail: hiddenRewriteCsiScanTail
|
||||
})
|
||||
hiddenRewriteChunkEndedWithCarriageReturn = decision.nextChunkEndsWithCarriageReturn
|
||||
hiddenRewriteCsiScanTail = decision.nextRewriteCsiScanTail
|
||||
return decision.prefersRenderRefresh || containsCursorPositionSequence(scanData)
|
||||
}
|
||||
|
||||
function hiddenOutputNeedsAtlasRecoveryAfterParse(data: string): boolean {
|
||||
if (!data) {
|
||||
return false
|
||||
}
|
||||
ensureHiddenRendererRiskStateForCurrentPty()
|
||||
const synchronizedOutputTouchesParsedFrame = hiddenSynchronizedOutputTouchesParsedFrame(data)
|
||||
const tuiRedrawOutputPrefersAtlasRecovery = hiddenTuiRedrawOutputPrefersAtlasRecovery(data)
|
||||
return synchronizedOutputTouchesParsedFrame || tuiRedrawOutputPrefersAtlasRecovery
|
||||
}
|
||||
|
||||
// The replay path uses the guard so xterm auto-replies to embedded query
|
||||
// sequences don't leak into the shell. xterm.write() buffers internally
|
||||
// regardless of DOM visibility and the guard stays engaged via the
|
||||
@@ -3345,6 +3438,11 @@ export function connectPanePty(
|
||||
const shouldSnapshotHiddenCodexOutput = shouldKeepHiddenStartupRendererQueriesLive(paneStartup)
|
||||
let hiddenStartupRendererQueryPending = ''
|
||||
let hiddenRendererStateDirty = false
|
||||
let hiddenRiskPtyId: string | null = null
|
||||
let hiddenSynchronizedOutputActive = false
|
||||
let hiddenSynchronizedOutputMarkerTail = ''
|
||||
let hiddenRewriteChunkEndedWithCarriageReturn = false
|
||||
let hiddenRewriteCsiScanTail = ''
|
||||
|
||||
function canUseMainBufferSnapshot(ptyId: string | null): ptyId is string {
|
||||
return Boolean(ptyId) && !isRemoteRuntimePtyId(ptyId)
|
||||
@@ -3519,6 +3617,7 @@ export function connectPanePty(
|
||||
): void {
|
||||
if (foreground) {
|
||||
resetHiddenOutputRestoreIfPtyChanged()
|
||||
resetHiddenRendererRiskState()
|
||||
}
|
||||
const parseHiddenStartupOutput =
|
||||
!foreground &&
|
||||
@@ -3552,6 +3651,10 @@ export function connectPanePty(
|
||||
const renderRefreshDecision = foregroundOutput
|
||||
? shouldForceForegroundRenderRefresh(data)
|
||||
: { refresh: false, inPlaceRewrite: false, recoverWebglAtlasAfterParse: false }
|
||||
const recoverHiddenWebglAtlasAfterParse =
|
||||
!foregroundOutput && hiddenOutputNeedsAtlasRecoveryAfterParse(data)
|
||||
const recoverWebglAtlasAfterParse =
|
||||
renderRefreshDecision.recoverWebglAtlasAfterParse || recoverHiddenWebglAtlasAfterParse
|
||||
const foregroundRenderRefreshNeeded = renderRefreshDecision.refresh
|
||||
// Why: see nativeWindowsRewriteNeedsFollowupRenderRefresh — Claude Code's
|
||||
// in-place prompt redraws on Windows ConPTY can paint one frame late, so a
|
||||
@@ -3600,9 +3703,7 @@ export function connectPanePty(
|
||||
nativeWindowsCursorRestore || nativeWindowsInPlaceRewriteFollowup,
|
||||
// Why: atlas recovery must repaint from the parsed xterm buffer, not
|
||||
// a pre-write snapshot that a late TUI redraw can immediately stale.
|
||||
onForegroundParsed: renderRefreshDecision.recoverWebglAtlasAfterParse
|
||||
? scheduleTerminalWebglAtlasRecovery
|
||||
: undefined,
|
||||
onParsed: recoverWebglAtlasAfterParse ? scheduleTerminalWebglAtlasRecovery : undefined,
|
||||
stripTransientCursorShows: shouldProtectNativeWindowsSynchronizedOutput && foreground,
|
||||
coalesceForeground: synchronizedForegroundOutput && synchronizedOutputEnded,
|
||||
holdForeground: synchronizedForegroundOutput && nextSynchronizedForegroundOutputActive
|
||||
@@ -3620,6 +3721,7 @@ export function connectPanePty(
|
||||
}
|
||||
|
||||
function markHiddenOutputRestoreNeeded(): void {
|
||||
resetSkippedHiddenRendererRiskState()
|
||||
const ptyId = transport.getPtyId()
|
||||
if (!canUseHiddenOutputSnapshot(ptyId)) {
|
||||
return
|
||||
@@ -3932,6 +4034,7 @@ export function connectPanePty(
|
||||
hiddenOutputRestoreScheduled = false
|
||||
hiddenStartupRendererQueryPending = ''
|
||||
hiddenRendererStateDirty = false
|
||||
resetHiddenRendererRiskState()
|
||||
cancelScheduledHiddenOutputRestore(pane.terminal)
|
||||
clearHiddenOutputRestoreDeferredRetryTimer()
|
||||
clearHiddenOutputRestoreForegroundDeadlineTimer()
|
||||
@@ -3982,6 +4085,7 @@ export function connectPanePty(
|
||||
clearPendingLiveChunksDuringRestore()
|
||||
hiddenStartupRendererQueryPending = ''
|
||||
hiddenRendererStateDirty = false
|
||||
resetHiddenRendererRiskState()
|
||||
hiddenOutputRestoreNeeded = false
|
||||
hiddenOutputRestorePtyId = null
|
||||
hiddenOutputRestoreGeneration += 1
|
||||
@@ -4014,6 +4118,7 @@ export function connectPanePty(
|
||||
function skipBackgroundAlternateScreenOutput(data: string): void {
|
||||
writeHiddenStartupRendererQueries(data)
|
||||
respondToSkippedMode2031Subscribe(data)
|
||||
resetSkippedHiddenRendererRiskState()
|
||||
hiddenRendererStateDirty = true
|
||||
recordHiddenRendererSkip(data.length)
|
||||
const ptyId = transport.getPtyId()
|
||||
@@ -4089,6 +4194,7 @@ export function connectPanePty(
|
||||
writeReplayData(snapshot.data)
|
||||
writeReplayData(POST_REPLAY_LIVE_SNAPSHOT_RESET)
|
||||
hiddenRendererStateDirty = false
|
||||
resetHiddenRendererRiskState()
|
||||
recordTerminalOutput(pane.terminal)
|
||||
const currentPtyId = transport.getPtyId()
|
||||
if (currentPtyId && !getFitOverrideForPty(currentPtyId)) {
|
||||
@@ -4350,6 +4456,7 @@ export function connectPanePty(
|
||||
queueLiveChunkDuringRestore(rendererData, rendererMeta)
|
||||
requestHiddenOutputRestoreIfNeeded()
|
||||
} else if (hiddenOutputRestoreInFlight) {
|
||||
resetSkippedHiddenRendererRiskState()
|
||||
hiddenOutputRestoreNeeded = true
|
||||
hiddenOutputRestoreFreshSnapshotNeeded = true
|
||||
}
|
||||
|
||||
@@ -69,26 +69,26 @@ describe('pane terminal output scheduler', () => {
|
||||
expect(terminal.write).toHaveBeenCalledWith('foreground', expect.any(Function))
|
||||
})
|
||||
|
||||
it('runs foreground parsed callbacks after immediate foreground output parses', async () => {
|
||||
it('runs parsed callbacks after immediate foreground output parses', async () => {
|
||||
const { writeTerminalOutput } = await loadScheduler()
|
||||
const terminal = createTerminal()
|
||||
let parseCallback: (() => void) | undefined
|
||||
terminal.write.mockImplementation((_data: string, callback?: () => void) => {
|
||||
parseCallback = callback
|
||||
})
|
||||
const onForegroundParsed = vi.fn()
|
||||
const onParsed = vi.fn()
|
||||
|
||||
writeTerminalOutput(terminal, 'foreground', {
|
||||
foreground: true,
|
||||
onForegroundParsed
|
||||
onParsed
|
||||
})
|
||||
|
||||
expect(onForegroundParsed).not.toHaveBeenCalled()
|
||||
expect(onParsed).not.toHaveBeenCalled()
|
||||
parseCallback?.()
|
||||
expect(onForegroundParsed).toHaveBeenCalledTimes(1)
|
||||
expect(onParsed).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('runs foreground parsed callbacks after queued foreground output parses', async () => {
|
||||
it('runs parsed callbacks after queued foreground output parses', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { writeTerminalOutput } = await loadScheduler()
|
||||
const terminal = createTerminal()
|
||||
@@ -96,20 +96,20 @@ describe('pane terminal output scheduler', () => {
|
||||
terminal.write.mockImplementation((_data: string, callback?: () => void) => {
|
||||
parseCallback = callback
|
||||
})
|
||||
const onForegroundParsed = vi.fn()
|
||||
const onParsed = vi.fn()
|
||||
|
||||
writeTerminalOutput(terminal, 'queued', {
|
||||
foreground: true,
|
||||
latencySensitive: false,
|
||||
onForegroundParsed
|
||||
onParsed
|
||||
})
|
||||
|
||||
vi.advanceTimersByTime(0)
|
||||
|
||||
expect(terminal.write).toHaveBeenCalledWith('queued', expect.any(Function))
|
||||
expect(onForegroundParsed).not.toHaveBeenCalled()
|
||||
expect(onParsed).not.toHaveBeenCalled()
|
||||
parseCallback?.()
|
||||
expect(onForegroundParsed).toHaveBeenCalledTimes(1)
|
||||
expect(onParsed).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('synchronously refreshes visible rows after foreground output parses', async () => {
|
||||
@@ -244,6 +244,71 @@ describe('pane terminal output scheduler', () => {
|
||||
expect(terminal.write).toHaveBeenCalledWith('ab')
|
||||
})
|
||||
|
||||
it('runs parsed callbacks after background output parses without foreground refresh', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { writeTerminalOutput } = await loadScheduler()
|
||||
const terminal = createForegroundTerminal()
|
||||
const writes: string[] = []
|
||||
const parseCallbacks: (() => void)[] = []
|
||||
terminal.write = function write(data: string, callback?: () => void): void {
|
||||
writes.push(data)
|
||||
if (callback) {
|
||||
parseCallbacks.push(callback)
|
||||
}
|
||||
} as typeof terminal.write
|
||||
const onParsed = vi.fn()
|
||||
|
||||
writeTerminalOutput(terminal, 'hidden redraw', {
|
||||
foreground: false,
|
||||
forceForegroundRefresh: true,
|
||||
followupForegroundRefresh: true,
|
||||
onParsed
|
||||
})
|
||||
|
||||
vi.advanceTimersByTime(50)
|
||||
|
||||
expect(writes).toEqual(['hidden redraw'])
|
||||
expect(onParsed).not.toHaveBeenCalled()
|
||||
expect(terminal._core.refresh).not.toHaveBeenCalled()
|
||||
|
||||
parseCallbacks[0]?.()
|
||||
|
||||
expect(onParsed).toHaveBeenCalledTimes(1)
|
||||
expect(terminal._core.refresh).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps parsed callbacks on large background chunks split by the scheduler', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { writeTerminalOutput } = await loadScheduler()
|
||||
const terminal = createTerminal()
|
||||
const writes: string[] = []
|
||||
const parseCallbacks: (() => void)[] = []
|
||||
terminal.write = function write(data: string, callback?: () => void): void {
|
||||
writes.push(data)
|
||||
if (callback) {
|
||||
parseCallbacks.push(callback)
|
||||
}
|
||||
} as typeof terminal.write
|
||||
const onParsed = vi.fn()
|
||||
|
||||
writeTerminalOutput(terminal, 'x'.repeat(20 * 1024), {
|
||||
foreground: false,
|
||||
onParsed
|
||||
})
|
||||
|
||||
vi.advanceTimersByTime(50)
|
||||
|
||||
expect(writes.map((data) => data.length)).toEqual([16 * 1024, 4 * 1024])
|
||||
expect(parseCallbacks).toHaveLength(2)
|
||||
expect(onParsed).not.toHaveBeenCalled()
|
||||
|
||||
parseCallbacks[0]?.()
|
||||
|
||||
expect(onParsed).toHaveBeenCalledTimes(1)
|
||||
parseCallbacks[1]?.()
|
||||
expect(onParsed).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('defers throughput foreground output to the shared high-priority drain', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { writeTerminalOutput } = await loadScheduler()
|
||||
|
||||
@@ -21,7 +21,7 @@ type TerminalOutputParsedCallback = () => void
|
||||
type WriteTerminalOutputOptions = {
|
||||
foreground: boolean
|
||||
beforeWrite?: TerminalOutputBeforeWrite
|
||||
onForegroundParsed?: TerminalOutputParsedCallback
|
||||
onParsed?: TerminalOutputParsedCallback
|
||||
onBackgroundBacklogDropped?: () => void
|
||||
latencySensitive?: boolean
|
||||
forceForegroundRefresh?: boolean
|
||||
@@ -37,7 +37,7 @@ type QueueChunk = {
|
||||
forceForegroundRefresh: boolean
|
||||
followupForegroundRefresh: boolean
|
||||
stripTransientCursorShows: boolean
|
||||
onForegroundParsed?: TerminalOutputParsedCallback
|
||||
onParsed?: TerminalOutputParsedCallback
|
||||
}
|
||||
|
||||
type QueuedWrite = {
|
||||
@@ -46,7 +46,7 @@ type QueuedWrite = {
|
||||
forceForegroundRefresh: boolean
|
||||
followupForegroundRefresh: boolean
|
||||
stripTransientCursorShows: boolean
|
||||
onForegroundParsed?: TerminalOutputParsedCallback
|
||||
onParsed?: TerminalOutputParsedCallback
|
||||
}
|
||||
|
||||
type QueueEntry = {
|
||||
@@ -499,13 +499,16 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null {
|
||||
remaining -= chunk.data.length
|
||||
entry.queuedChars -= chunk.data.length
|
||||
entry.chunkIndex += 1
|
||||
if (chunk.foreground && chunk.onForegroundParsed) {
|
||||
parsedCallbacks.push(chunk.onForegroundParsed)
|
||||
if (chunk.onParsed) {
|
||||
parsedCallbacks.push(chunk.onParsed)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
data += chunk.data.slice(0, remaining)
|
||||
if (chunk.onParsed) {
|
||||
parsedCallbacks.push(chunk.onParsed)
|
||||
}
|
||||
entry.chunks[entry.chunkIndex] = {
|
||||
...chunk,
|
||||
data: chunk.data.slice(remaining)
|
||||
@@ -526,7 +529,7 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null {
|
||||
forceForegroundRefresh,
|
||||
followupForegroundRefresh,
|
||||
stripTransientCursorShows,
|
||||
onForegroundParsed:
|
||||
onParsed:
|
||||
parsedCallbacks.length > 0
|
||||
? () => {
|
||||
for (const callback of parsedCallbacks) {
|
||||
@@ -561,7 +564,7 @@ function enqueueChunk(
|
||||
forceForegroundRefresh?: boolean
|
||||
followupForegroundRefresh?: boolean
|
||||
stripTransientCursorShows?: boolean
|
||||
onForegroundParsed?: TerminalOutputParsedCallback
|
||||
onParsed?: TerminalOutputParsedCallback
|
||||
}
|
||||
): void {
|
||||
entry.chunks.push({
|
||||
@@ -570,7 +573,7 @@ function enqueueChunk(
|
||||
forceForegroundRefresh: options?.forceForegroundRefresh === true,
|
||||
followupForegroundRefresh: options?.followupForegroundRefresh === true,
|
||||
stripTransientCursorShows: options?.stripTransientCursorShows === true,
|
||||
onForegroundParsed: options?.onForegroundParsed
|
||||
onParsed: options?.onParsed
|
||||
})
|
||||
entry.queuedChars += data.length
|
||||
recordQueueDebugPressure()
|
||||
@@ -628,19 +631,30 @@ function hasDrainableBacklog(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
function writeBackgroundTerminalChunk(terminal: TerminalOutputTarget, data: string): void {
|
||||
function writeBackgroundTerminalChunk(
|
||||
terminal: TerminalOutputTarget,
|
||||
data: string,
|
||||
onParsed?: TerminalOutputParsedCallback
|
||||
): void {
|
||||
const scrollIntent = captureTerminalWriteScrollIntent(terminal)
|
||||
if (!scrollIntent) {
|
||||
terminal.write(data)
|
||||
if (!onParsed || terminal.write.length < 2) {
|
||||
terminal.write(data)
|
||||
onParsed?.()
|
||||
return
|
||||
}
|
||||
terminal.write(data, onParsed)
|
||||
return
|
||||
}
|
||||
if (terminal.write.length < 2) {
|
||||
terminal.write(data)
|
||||
enforceTerminalWriteScrollIntent(terminal, scrollIntent)
|
||||
onParsed?.()
|
||||
return
|
||||
}
|
||||
terminal.write(data, () => {
|
||||
enforceTerminalWriteScrollIntent(terminal, scrollIntent)
|
||||
onParsed?.()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -693,11 +707,11 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null
|
||||
{
|
||||
forceViewportRefresh: queuedWrite.forceForegroundRefresh,
|
||||
followupViewportRefresh: queuedWrite.followupForegroundRefresh,
|
||||
onParsed: queuedWrite.onForegroundParsed
|
||||
onParsed: queuedWrite.onParsed
|
||||
}
|
||||
)
|
||||
} else {
|
||||
writeBackgroundTerminalChunk(entry.terminal, queuedWrite.data)
|
||||
writeBackgroundTerminalChunk(entry.terminal, queuedWrite.data, queuedWrite.onParsed)
|
||||
}
|
||||
} catch {
|
||||
// Why: pane.terminal.dispose() can race with a queued late-arriving PTY ping;
|
||||
@@ -782,7 +796,7 @@ export function writeTerminalOutput(
|
||||
forceForegroundRefresh: options.forceForegroundRefresh,
|
||||
followupForegroundRefresh: options.followupForegroundRefresh,
|
||||
stripTransientCursorShows: options.stripTransientCursorShows,
|
||||
onForegroundParsed: options.onForegroundParsed
|
||||
onParsed: options.onParsed
|
||||
})
|
||||
if (debugEnabled) {
|
||||
debugState.foregroundWriteCount++
|
||||
@@ -849,7 +863,7 @@ export function writeTerminalOutput(
|
||||
forceForegroundRefresh: options.forceForegroundRefresh,
|
||||
followupForegroundRefresh: options.followupForegroundRefresh,
|
||||
stripTransientCursorShows: options.stripTransientCursorShows,
|
||||
onForegroundParsed: options.onForegroundParsed
|
||||
onParsed: options.onParsed
|
||||
})
|
||||
if (debugEnabled) {
|
||||
debugState.foregroundWriteCount++
|
||||
@@ -876,7 +890,7 @@ export function writeTerminalOutput(
|
||||
forceForegroundRefresh: options.forceForegroundRefresh,
|
||||
followupForegroundRefresh: options.followupForegroundRefresh,
|
||||
stripTransientCursorShows: options.stripTransientCursorShows,
|
||||
onForegroundParsed: options.onForegroundParsed
|
||||
onParsed: options.onParsed
|
||||
})
|
||||
if (debugEnabled) {
|
||||
debugState.foregroundWriteCount++
|
||||
@@ -899,7 +913,7 @@ export function writeTerminalOutput(
|
||||
{
|
||||
forceViewportRefresh: options.forceForegroundRefresh === true,
|
||||
followupViewportRefresh: options.followupForegroundRefresh === true,
|
||||
onParsed: options.onForegroundParsed
|
||||
onParsed: options.onParsed
|
||||
}
|
||||
)
|
||||
return
|
||||
@@ -914,7 +928,9 @@ export function writeTerminalOutput(
|
||||
entry.beforeWrite = options.beforeWrite
|
||||
entry.onBackgroundBacklogDropped = options.onBackgroundBacklogDropped
|
||||
}
|
||||
enqueueChunk(entry, data)
|
||||
enqueueChunk(entry, data, {
|
||||
onParsed: options.onParsed
|
||||
})
|
||||
if (
|
||||
entry.queuedChars > MAX_BACKGROUND_QUEUE_CHARS ||
|
||||
entry.chunks.length - entry.chunkIndex > MAX_BACKGROUND_QUEUE_CHUNKS
|
||||
@@ -975,11 +991,11 @@ export function flushTerminalOutput(
|
||||
{
|
||||
forceViewportRefresh: queuedWrite.forceForegroundRefresh,
|
||||
followupViewportRefresh: queuedWrite.followupForegroundRefresh,
|
||||
onParsed: queuedWrite.onForegroundParsed
|
||||
onParsed: queuedWrite.onParsed
|
||||
}
|
||||
)
|
||||
} else {
|
||||
writeBackgroundTerminalChunk(terminal, queuedWrite.data)
|
||||
writeBackgroundTerminalChunk(terminal, queuedWrite.data, queuedWrite.onParsed)
|
||||
}
|
||||
} catch {
|
||||
// Why: pane.terminal.dispose() can race with a queued late-arriving PTY ping;
|
||||
|
||||
Reference in New Issue
Block a user