fix(terminal): suppress cursor during foreground repaints

This commit is contained in:
Jinwoo Hong
2026-05-17 15:51:35 -04:00
parent 5d1c4e06e0
commit 0eb25eb825
5 changed files with 127 additions and 9 deletions
+7
View File
@@ -25,6 +25,13 @@
width: 100%;
}
.pane-manager-root .xterm.terminal-foreground-write-pending .xterm-cursor,
.pane-manager-root .xterm.terminal-foreground-write-pending .xterm-cursor-layer {
/* Why: active Windows TUI repaints can briefly expose xterm's intermediate
cursor positions; hide the visual cursor only while foreground bytes drain. */
visibility: hidden;
}
.pane-manager-root .xterm-viewport {
/* Why: xterm/VS Code keep the scrollbar gutter stable. `auto` lets Linux
DOM scrollbars appear/disappear, which can feed resize/reflow jitter. */
@@ -986,7 +986,10 @@ describe('connectPanePty', () => {
fn()
}
expect(pane.terminal.write).toHaveBeenCalledWith('visible split output\r\n')
expect(pane.terminal.write).toHaveBeenCalledWith(
'visible split output\r\n',
expect.any(Function)
)
} finally {
globalThis.setTimeout = originalSetTimeout
}
@@ -1029,7 +1032,10 @@ describe('connectPanePty', () => {
fn()
}
expect(pane.terminal.write).toHaveBeenCalledWith('Arabic: السلام عليكم\r\n')
expect(pane.terminal.write).toHaveBeenCalledWith(
'Arabic: السلام عليكم\r\n',
expect.any(Function)
)
} finally {
globalThis.setTimeout = originalSetTimeout
}
@@ -0,0 +1,50 @@
export type TerminalCursorSuppressionTarget = {
element?: { classList: Pick<DOMTokenList, 'add' | 'remove'> } | null
}
const FOREGROUND_CURSOR_RESTORE_DELAY_MS = 64
const FOREGROUND_CURSOR_RESTORE_SAFETY_MS = 500
export const FOREGROUND_CURSOR_SUPPRESSED_CLASS = 'terminal-foreground-write-pending'
export const FOREGROUND_CURSOR_RESTORE_SAFETY_DELAY_MS = FOREGROUND_CURSOR_RESTORE_SAFETY_MS
const restoreTimerByTerminal = new WeakMap<
TerminalCursorSuppressionTarget,
ReturnType<typeof setTimeout>
>()
function clearRestoreTimer(terminal: TerminalCursorSuppressionTarget): void {
const timer = restoreTimerByTerminal.get(terminal)
if (timer) {
clearTimeout(timer)
restoreTimerByTerminal.delete(terminal)
}
}
export function restoreForegroundTerminalCursor(terminal: TerminalCursorSuppressionTarget): void {
clearRestoreTimer(terminal)
terminal.element?.classList.remove(FOREGROUND_CURSOR_SUPPRESSED_CLASS)
}
export function scheduleForegroundTerminalCursorRestore(
terminal: TerminalCursorSuppressionTarget,
delayMs = FOREGROUND_CURSOR_RESTORE_DELAY_MS
): void {
if (!terminal.element) {
return
}
clearRestoreTimer(terminal)
const timer = setTimeout(() => {
restoreTimerByTerminal.delete(terminal)
terminal.element?.classList.remove(FOREGROUND_CURSOR_SUPPRESSED_CLASS)
}, delayMs)
restoreTimerByTerminal.set(terminal, timer)
}
export function suppressForegroundTerminalCursor(terminal: TerminalCursorSuppressionTarget): void {
if (!terminal.element) {
return
}
clearRestoreTimer(terminal)
terminal.element.classList.add(FOREGROUND_CURSOR_SUPPRESSED_CLASS)
}
@@ -1,7 +1,19 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
function createTerminal() {
const classes = new Set<string>()
return {
classes,
element: {
classList: {
add: vi.fn((className: string) => {
classes.add(className)
}),
remove: vi.fn((className: string) => {
classes.delete(className)
})
}
},
write: vi.fn((_data: string, callback?: () => void) => {
callback?.()
})
@@ -30,7 +42,28 @@ describe('pane terminal output scheduler', () => {
vi.advanceTimersByTime(16)
expect(terminal.write).toHaveBeenCalledTimes(1)
expect(terminal.write).toHaveBeenCalledWith('ab')
expect(terminal.write).toHaveBeenCalledWith('ab', expect.any(Function))
})
it('hides the foreground cursor until output parsing has gone quiet', async () => {
vi.useFakeTimers()
const { writeTerminalOutput } = await loadScheduler()
const terminal = createTerminal()
writeTerminalOutput(terminal, 'frame', { foreground: true })
expect(terminal.classes.has('terminal-foreground-write-pending')).toBe(true)
vi.advanceTimersByTime(16)
expect(terminal.write).toHaveBeenCalledWith('frame', expect.any(Function))
expect(terminal.classes.has('terminal-foreground-write-pending')).toBe(true)
vi.advanceTimersByTime(63)
expect(terminal.classes.has('terminal-foreground-write-pending')).toBe(true)
vi.advanceTimersByTime(1)
expect(terminal.classes.has('terminal-foreground-write-pending')).toBe(false)
})
it('flushes foreground output synchronously when requested', async () => {
@@ -41,7 +74,7 @@ describe('pane terminal output scheduler', () => {
writeTerminalOutput(terminal, 'foreground', { foreground: true })
flushTerminalOutput(terminal)
expect(terminal.write).toHaveBeenCalledWith('foreground')
expect(terminal.write).toHaveBeenCalledWith('foreground', expect.any(Function))
})
it('coalesces background output until the shared drain runs', async () => {
@@ -1,6 +1,13 @@
import { e2eConfig } from '@/lib/e2e-config'
import {
FOREGROUND_CURSOR_RESTORE_SAFETY_DELAY_MS,
restoreForegroundTerminalCursor,
scheduleForegroundTerminalCursorRestore,
suppressForegroundTerminalCursor,
type TerminalCursorSuppressionTarget
} from './pane-terminal-cursor-suppression'
type TerminalOutputTarget = {
type TerminalOutputTarget = TerminalCursorSuppressionTarget & {
write(data: string, callback?: () => void): void
}
@@ -127,13 +134,23 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): string {
return data
}
function writeAllQueuedChunks(entry: QueueEntry): void {
function writeForegroundQueuedChunks(entry: QueueEntry): void {
let data = takeQueuedChunk(entry, Number.POSITIVE_INFINITY)
while (data) {
suppressForegroundTerminalCursor(entry.terminal)
// Why: xterm's write callback marks parser completion, but a disposed
// terminal may never call it. The safety restore avoids a stuck hidden cursor.
scheduleForegroundTerminalCursorRestore(
entry.terminal,
FOREGROUND_CURSOR_RESTORE_SAFETY_DELAY_MS
)
try {
entry.terminal.write(data)
entry.terminal.write(data, () => {
scheduleForegroundTerminalCursorRestore(entry.terminal)
})
} catch {
entry.chunks.length = 0
restoreForegroundTerminalCursor(entry.terminal)
return
}
data = takeQueuedChunk(entry, Number.POSITIVE_INFINITY)
@@ -166,7 +183,7 @@ function drainForegroundOutput(): void {
if (debugEnabled) {
debugState.foregroundBatchedWriteCount++
}
writeAllQueuedChunks(entry)
writeForegroundQueuedChunks(entry)
}
}
@@ -221,6 +238,10 @@ export function writeTerminalOutput(
foregroundQueuedByTerminal.set(terminal, entry)
}
entry.chunks.push(data)
// Why: Windows can paint xterm's cursor at intermediate TUI repaint
// positions before the foreground batch drains. Hide only the cursor layer;
// terminal bytes still parse normally and the cursor returns after quiet.
suppressForegroundTerminalCursor(terminal)
scheduleForegroundDrain()
return
}
@@ -272,7 +293,7 @@ export function flushTerminalOutput(terminal: TerminalOutputTarget): void {
if (debugEnabled) {
debugState.flushWriteCount++
}
writeAllQueuedChunks(foregroundEntry)
writeForegroundQueuedChunks(foregroundEntry)
}
flushBackgroundTerminalOutput(terminal)
@@ -307,6 +328,7 @@ export function discardTerminalOutput(terminal: TerminalOutputTarget): void {
exposeDebugApi()
foregroundQueuedByTerminal.delete(terminal)
queuedByTerminal.delete(terminal)
restoreForegroundTerminalCursor(terminal)
}
exposeDebugApi()