diff --git a/src/renderer/src/assets/terminal.css b/src/renderer/src/assets/terminal.css index 05931b02b59..8e44a2ec715 100644 --- a/src/renderer/src/assets/terminal.css +++ b/src/renderer/src/assets/terminal.css @@ -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. */ diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 5868b7d8334..4481a079ed5 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -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 } diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-cursor-suppression.ts b/src/renderer/src/lib/pane-manager/pane-terminal-cursor-suppression.ts new file mode 100644 index 00000000000..edcf087e111 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-terminal-cursor-suppression.ts @@ -0,0 +1,50 @@ +export type TerminalCursorSuppressionTarget = { + element?: { classList: Pick } | 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 +>() + +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) +} diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts index 21602b2fed4..490e9dc5242 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts @@ -1,7 +1,19 @@ import { afterEach, describe, expect, it, vi } from 'vitest' function createTerminal() { + const classes = new Set() 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 () => { diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts index a5beaf789c4..fe8b6ddf4e3 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts @@ -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()