Prevent hidden terminal TUI overlap (#7054)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson
2026-07-01 16:10:50 -07:00
committed by GitHub
co-authored by Orca
parent 126aae7734
commit e0de46ffc8
4 changed files with 536 additions and 37 deletions
@@ -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;