mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 00:02:54 +00:00
fix(terminal): suppress cursor during foreground repaints
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user