diff --git a/src/renderer/src/assets/terminal.css b/src/renderer/src/assets/terminal.css index cb618ae83..149c236f5 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: Windows PowerShell / ConPTY repaint bursts can briefly expose xterm's + intermediate cursor position; hide it only while foreground output settles. */ + 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. */ @@ -39,6 +46,12 @@ left: 0; } +.pane-manager-root .xterm .xterm-helper-textarea { + /* Why: Chromium can still paint the native caret for xterm's hidden helper + textarea on Windows, which shows up as a stale cursor during redraws. */ + caret-color: transparent; +} + /* Divider: the element is a wide transparent hit area; the visible line is drawn by ::after so that setting `background` on the element never hides it. */ .pane-divider.is-vertical, 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 4e55bce29..686f63023 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -2253,7 +2253,10 @@ describe('connectPanePty', () => { expect(capturedDataCallback.current).not.toBeNull() capturedDataCallback.current?.('visible split output\r\n') - expect(pane.terminal.write).toHaveBeenCalledWith('visible split output\r\n') + expect(pane.terminal.write).toHaveBeenCalledWith( + 'visible split output\r\n', + expect.any(Function) + ) }) it('marks panes that receive Arabic output for DOM rendering', async () => { @@ -2276,7 +2279,10 @@ describe('connectPanePty', () => { capturedDataCallback.current?.('Arabic: السلام عليكم\r\n') expect(manager.markPaneHasComplexScriptOutput).toHaveBeenCalledWith(1) - expect(pane.terminal.write).toHaveBeenCalledWith('Arabic: السلام عليكم\r\n') + expect(pane.terminal.write).toHaveBeenCalledWith( + 'Arabic: السلام عليكم\r\n', + expect.any(Function) + ) }) it('keeps panes on WebGL for terminal UI drawing glyphs', async () => { @@ -2300,7 +2306,8 @@ describe('connectPanePty', () => { expect(manager.markPaneHasComplexScriptOutput).not.toHaveBeenCalled() expect(pane.terminal.write).toHaveBeenCalledWith( - '⠋ Working ├─ file.ts █ progress \uE0B0 prompt\r\n' + '⠋ Working ├─ file.ts █ progress \uE0B0 prompt\r\n', + expect.any(Function) ) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 6dbc10667..71cee35dd 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -26,9 +26,11 @@ import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspec import { discardTerminalOutput, flushTerminalOutput, + suppressTerminalCursorUntilOutputSettles, waitForTerminalOutputParsed, writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' +import { isLocalNativeWindowsPty } from '@/lib/pane-manager/windows-pty-compatibility' import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll' import { makePaneKey } from '../../../../shared/stable-pane-id' import { createTerminalCommandLifecycle } from './terminal-command-lifecycle' @@ -866,6 +868,12 @@ export function connectPanePty( const connectionId = repo?.connectionId ?? null const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find((t) => t.id === deps.tabId) const shellOverride = tab?.shellOverride + const shouldSuppressForegroundCursor = isLocalNativeWindowsPty({ + userAgent: navigator.userAgent, + connectionId, + cwd: deps.cwd, + shellOverride + }) const restoredPtyIdForTransport = deps.restoredLeafId && deps.restoredPtyIdByLeafId @@ -963,6 +971,12 @@ export function connectPanePty( // auto-replies never count as interaction. deps.clearTerminalTabUnread(deps.tabId) deps.clearWorktreeUnread(deps.worktreeId) + if (shouldSuppressForegroundCursor) { + // Why: native Windows ConPTY can leave the old visual cursor painted + // until the shell echoes the next frame; other PTY hosts should keep + // normal cursor visibility when commands intentionally produce no echo. + suppressTerminalCursorUntilOutputSettles(pane.terminal) + } const intent = pendingTerminalInputIntent // Why: real xterm can deliver the terminal byte even when our DOM keydown // listener missed the press. Exact Ctrl+C/Escape bytes are still safe to diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts index 1bfeae94e..882db26e7 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts @@ -11,6 +11,10 @@ const BEL = '\x07' const workingFrame = (frame: string): string => `${ESC}]0;${frame} π - cwd${BEL}` const idleTitle = (): string => `${ESC}]0;π - cwd${BEL}` +function flushPtySideEffects(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)) +} + describe('dispatcher → transport → onTitleChange for Pi spinner', () => { const originalWindow = (globalThis as { window?: typeof window }).window @@ -74,6 +78,7 @@ describe('dispatcher → transport → onTitleChange for Pi spinner', () => { dispatcherCallback?.({ id: 'pty-pi', data: workingFrame('⠋') }) dispatcherCallback?.({ id: 'pty-pi', data: workingFrame('⠙') }) dispatcherCallback?.({ id: 'pty-pi', data: idleTitle() }) + await flushPtySideEffects() const seenTitles = onTitleChange.mock.calls.map((c) => c[0]) expect(seenTitles).toContain('⠋ Pi') @@ -96,6 +101,7 @@ describe('dispatcher → transport → onTitleChange for Pi spinner', () => { id: 'pty-pi', data: `assistant output line 1\r\n${workingFrame('⠋')}more body text` }) + await flushPtySideEffects() const seenTitles = onTitleChange.mock.calls.map((c) => c[0]) expect(seenTitles).toContain('⠋ Pi') @@ -121,6 +127,7 @@ describe('dispatcher → transport → onTitleChange for Pi spinner', () => { onTitleChange.mockClear() dispatcherCallback?.({ id: 'pty-pi', data: workingFrame('⠋') }) + await flushPtySideEffects() const seenTitles = onTitleChange.mock.calls.map((c) => c[0]) expect(seenTitles).toContain('⠋ Pi') @@ -143,6 +150,7 @@ describe('dispatcher → transport → onTitleChange for Pi spinner', () => { dispatcherCallback?.({ id: 'pty-pi', data: workingFrame('⠋') }) dispatcherCallback?.({ id: 'pty-pi', data: workingFrame('⠙') }) dispatcherCallback?.({ id: 'pty-pi', data: idleTitle() }) + await flushPtySideEffects() const seenTitles = onTitleChange.mock.calls.map((c) => c[0]) const workingIdx = seenTitles.findIndex((t) => t === '⠋ Pi') @@ -177,6 +185,7 @@ describe('dispatcher → transport → onTitleChange for Pi spinner', () => { dispatcherCallback?.({ id: 'pty-pi', data: `${ESC}]0;Cursor Agent${BEL}` }) dispatcherCallback?.({ id: 'pty-pi', data: `${ESC}]0;⠙ Cursor Agent${BEL}` }) dispatcherCallback?.({ id: 'pty-pi', data: `${ESC}]0;Cursor Agent${BEL}` }) + await flushPtySideEffects() const seenTitles = onTitleChange.mock.calls.map((c) => c[0]) // The two bare "Cursor Agent" titles must NOT reach the title-change @@ -200,6 +209,7 @@ describe('dispatcher → transport → onTitleChange for Pi spinner', () => { dispatcherCallback?.({ id: 'pty-pi', data: `${ESC}]0;⠋ Cursor Agent${BEL}` }) dispatcherCallback?.({ id: 'pty-pi', data: `${ESC}]0;Cursor Agent${BEL}` }) dispatcherCallback?.({ id: 'pty-pi', data: `${ESC}]0;Cursor ready${BEL}${BEL}` }) + await flushPtySideEffects() const seenTitles = onTitleChange.mock.calls.map((c) => c[0]) expect(seenTitles).toContain('⠋ Cursor Agent') diff --git a/src/renderer/src/components/terminal-pane/pty-transport-pi-coalesce.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-pi-coalesce.test.ts index 398fa668e..808cedad5 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-pi-coalesce.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-pi-coalesce.test.ts @@ -14,6 +14,10 @@ const BEL = '\x07' const workingFrame = (frame: string): string => `${ESC}]0;${frame} π - cwd${BEL}` const idleTitle = (): string => `${ESC}]0;π - cwd${BEL}` +function flushPtySideEffects(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)) +} + describe('pty-transport — coalesced OSC titles from Pi', () => { const originalWindow = (globalThis as { window?: typeof window }).window let onData: ((payload: { id: string; data: string }) => void) | null = null @@ -67,6 +71,7 @@ describe('pty-transport — coalesced OSC titles from Pi', () => { // agent_end fires stopAnimation -> trailing idle title after working frames const chunk = `${workingFrame('⠋')}some response text\r\n${workingFrame('⠙')}more response text\r\n${idleTitle()}` onData?.({ id: 'pty-pi', data: chunk }) + await flushPtySideEffects() const seen = onTitleChange.mock.calls.map((c) => c[0]) // Users expect the working state to register SOMEWHERE in the sequence, @@ -93,6 +98,7 @@ describe('pty-transport — coalesced OSC titles from Pi', () => { const framesChars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] const chunk = framesChars.map(workingFrame).join('body\r\n') onData?.({ id: 'pty-pi', data: chunk }) + await flushPtySideEffects() const seen = onTitleChange.mock.calls.map((c) => c[0]) expect(seen).toContain('⠋ Pi') diff --git a/src/renderer/src/components/terminal-pane/pty-transport-pi-spinner.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-pi-spinner.test.ts index 23c55648f..c556993c8 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-pi-spinner.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-pi-spinner.test.ts @@ -13,6 +13,10 @@ const BEL = '\x07' const workingFrame = (frame: string): string => `${ESC}]0;${frame} π - cwd${BEL}` const idleTitle = (): string => `${ESC}]0;π - cwd${BEL}` +function flushPtySideEffects(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)) +} + describe('createIpcPtyTransport — Pi titlebar spinner signal', () => { const originalWindow = (globalThis as { window?: typeof window }).window let onData: ((payload: { id: string; data: string }) => void) | null = null @@ -64,6 +68,7 @@ describe('createIpcPtyTransport — Pi titlebar spinner signal', () => { onData?.({ id: 'pty-pi', data: workingFrame('⠙') }) // ⠙ onData?.({ id: 'pty-pi', data: workingFrame('⠹') }) // ⠹ onData?.({ id: 'pty-pi', data: idleTitle() }) + await flushPtySideEffects() const normalized = onTitleChange.mock.calls.map((c) => c[0]) // The store only stores the normalized label, which is what the worktree @@ -91,6 +96,7 @@ describe('createIpcPtyTransport — Pi titlebar spinner signal', () => { onData?.({ id: 'pty-pi', data: workingFrame('⠋') }) onData?.({ id: 'pty-pi', data: workingFrame('⠙') }) + await flushPtySideEffects() const calls = onTitleChange.mock.calls.map((c) => c[0]) expect(calls).toContain('⠋ Pi') @@ -115,6 +121,7 @@ describe('createIpcPtyTransport — Pi titlebar spinner signal', () => { const coalescedWorking = `${workingFrame('⠋')}output\r\n${workingFrame('⠙')}` onData?.({ id: 'pty-pi', data: coalescedWorking }) + await flushPtySideEffects() const calls = onTitleChange.mock.calls.map((c) => c[0]) expect(calls).toContain('⠋ Pi') diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index f10ca7196..b0e4d5320 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -15,6 +15,10 @@ describe('createIpcPtyTransport', () => { let onData: ((payload: { id: string; data: string }) => void) | null = null let onExit: ((payload: { id: string; code: number }) => void) | null = null + function flushPtySideEffects(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)) + } + beforeEach(() => { vi.resetModules() onData = null @@ -68,6 +72,27 @@ describe('createIpcPtyTransport', () => { transport.disconnect() }) + it('defers title side effects until after terminal data is delivered', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const onTitleChange = vi.fn() + const onDataCallback = vi.fn(() => { + expect(onTitleChange).not.toHaveBeenCalled() + }) + const transport = createIpcPtyTransport({ onTitleChange }) + + await transport.connect({ url: '', callbacks: { onData: onDataCallback } }) + + onData?.({ id: 'pty-1', data: '\u001b]0;title-one\u0007body' }) + + expect(onDataCallback).toHaveBeenCalledWith('\u001b]0;title-one\u0007body') + expect(onTitleChange).not.toHaveBeenCalled() + + await flushPtySideEffects() + + expect(onTitleChange).toHaveBeenCalledWith('title-one', 'title-one') + transport.disconnect() + }) + it('uses acknowledged writes only for local IPC PTYs', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const localTransport = createIpcPtyTransport({}) @@ -110,11 +135,38 @@ describe('createIpcPtyTransport', () => { }) expect(handle.flush()).toBe('') + await flushPtySideEffects() expect(onTitleChange).toHaveBeenCalledWith('* Claude done', '* Claude done') expect(onBell).not.toHaveBeenCalled() expect(onAgentBecameIdle).not.toHaveBeenCalled() }) + it('resets replay parser state after deferred side effects drain', async () => { + // Why: replay side effects run after xterm receives data. Attach cleanup + // still has to wait for them, or a replayed partial OSC can make the first + // live BEL look like an OSC terminator instead of an attention bell. + const { createIpcPtyTransport, registerEagerPtyBuffer } = await import('./pty-transport') + const onBell = vi.fn() + + registerEagerPtyBuffer('pty-restored', vi.fn()) + onData?.({ + id: 'pty-restored', + data: '\x1b]0;partial-title' + }) + + const transport = createIpcPtyTransport({ onBell }) + transport.attach({ + existingPtyId: 'pty-restored', + callbacks: {} + }) + + await flushPtySideEffects() + onData?.({ id: 'pty-restored', data: '\x07' }) + await flushPtySideEffects() + + expect(onBell).toHaveBeenCalledTimes(1) + }) + it('keeps exit sidecars after eager-buffered PTYs attach to a terminal', async () => { const { createIpcPtyTransport, registerEagerPtyBuffer, subscribeToPtyExit } = await import('./pty-transport') @@ -150,10 +202,12 @@ describe('createIpcPtyTransport', () => { onData?.({ id: 'pty-1', data: ']0;title-one' }) onData?.({ id: 'pty-1', data: ']0;title-two' }) onData?.({ id: 'pty-1', data: ']0;title-three' }) + await flushPtySideEffects() expect(onBell).not.toHaveBeenCalled() // Bare BEL outside any OSC: fires once. onData?.({ id: 'pty-1', data: '' }) + await flushPtySideEffects() expect(onBell).toHaveBeenCalledTimes(1) }) @@ -432,6 +486,7 @@ describe('createIpcPtyTransport', () => { // Agent starts working onData?.({ id: 'pty-1', data: ']0;. Claude working' }) + await flushPtySideEffects() expect(onAgentBecameWorking).toHaveBeenCalledTimes(1) // Simulate shutdownWorktreeTerminals: unregister data handlers before kill. @@ -467,10 +522,12 @@ describe('createIpcPtyTransport', () => { // Agent starts working — sets the title to a working indicator onData?.({ id: 'pty-1', data: ']0;. Claude working' }) + vi.advanceTimersByTime(0) expect(onAgentBecameWorking).toHaveBeenCalledTimes(1) // Data arrives without a title change — starts the 3 s staleTitleTimer onData?.({ id: 'pty-1', data: 'some output without title\r\n' }) + vi.advanceTimersByTime(0) // Simulate shutdownWorktreeTerminals: unregister handlers which should // cancel the pending staleTitleTimer AND reset the agent tracker so the diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 52c909abe..2c0d08c24 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -76,12 +76,19 @@ export function createPtyOutputProcessor({ ) => void clearAccumulatedState: () => void clearStaleTitleTimer: () => void + flushPendingSideEffects: () => void resetBellDetector: () => void } { const bellDetector = createBellDetector() const processAgentStatusChunk = createAgentStatusOscProcessor() let lastEmittedTitle: string | null = null let staleTitleTimer: ReturnType | null = null + let sideEffectDrainTimer: ReturnType | null = null + const pendingSideEffects: { + data: string + payloads: ReturnType['payloads'] + suppressAttentionEvents: boolean + }[] = [] const agentTracker = onAgentBecameIdle || onAgentBecameWorking || onAgentExited ? createAgentStatusTracker( @@ -93,7 +100,7 @@ export function createPtyOutputProcessor({ ) : null - function applyObservedTerminalTitle(title: string): void { + function applyObservedTerminalTitle(title: string, suppressAgentTracker = false): void { // Why: cursor-agent's native OSC title is the literal string "Cursor Agent" // and it re-emits that title many times per turn (on every internal redraw) // even while it's actively working. Orca drives the cursor spinner/unread @@ -111,7 +118,9 @@ export function createPtyOutputProcessor({ } lastEmittedTitle = normalizeTerminalTitle(title) onTitleChange?.(lastEmittedTitle, title) - agentTracker?.handleTitle(title) + if (!suppressAgentTracker) { + agentTracker?.handleTitle(title) + } } function clearStaleTitleTimer(): void { @@ -121,6 +130,83 @@ export function createPtyOutputProcessor({ } } + function schedulePtySideEffects( + data: string, + payloads: ReturnType['payloads'], + suppressAttentionEvents: boolean + ): void { + pendingSideEffects.push({ data, payloads, suppressAttentionEvents }) + if (sideEffectDrainTimer !== null) { + return + } + // Why: xterm.write() buffers parsing onto its own timer. Defer Orca's + // title/status/BEL store work so live terminal rendering gets the next turn. + sideEffectDrainTimer = setTimeout(drainPtySideEffects, 0) + } + + function clearSideEffectDrainTimer(): void { + if (sideEffectDrainTimer) { + clearTimeout(sideEffectDrainTimer) + sideEffectDrainTimer = null + } + } + + function drainPtySideEffects(): void { + sideEffectDrainTimer = null + while (pendingSideEffects.length > 0) { + const next = pendingSideEffects.shift() + if (!next) { + continue + } + if (onAgentStatus && !next.suppressAttentionEvents) { + for (const payload of next.payloads) { + onAgentStatus(payload) + } + } + processObservedTitles(next.data, next.suppressAttentionEvents) + if (onBell && bellDetector.chunkContainsBell(next.data) && !next.suppressAttentionEvents) { + onBell() + } + } + } + + function flushPendingSideEffects(): void { + clearSideEffectDrainTimer() + drainPtySideEffects() + } + + function processObservedTitles(data: string, suppressAgentTracker: boolean): void { + if (!onTitleChange) { + return + } + // Why: feed EVERY OSC title in the chunk through the observer, not just + // the last one. node-pty + the main-process 8ms batch window commonly + // coalesce multiple title updates into a single IPC payload; processing + // titles in order preserves working-to-idle transitions. + const titles = extractAllOscTitles(data) + if (titles.length > 0) { + clearStaleTitleTimer() + for (const title of titles) { + applyObservedTerminalTitle(title, suppressAgentTracker) + } + } else if ( + !suppressAgentTracker && + lastEmittedTitle && + detectAgentStatusFromTitle(lastEmittedTitle) === 'working' + ) { + clearStaleTitleTimer() + staleTitleTimer = setTimeout(() => { + staleTitleTimer = null + if (lastEmittedTitle && detectAgentStatusFromTitle(lastEmittedTitle) === 'working') { + const cleared = clearWorkingIndicators(lastEmittedTitle) + lastEmittedTitle = cleared + onTitleChange(cleared, cleared) + agentTracker?.handleTitle(cleared) + } + }, STALE_TITLE_TIMEOUT) + } + } + function processData( data: string, callbacks: PtyOutputCallbacks, @@ -136,55 +222,17 @@ export function createPtyOutputProcessor({ // replay we must not surface stale agent-status payloads from a prior app // session into the live store. The parser still consumes the bytes so they // do not leak into xterm, we just suppress the callback. - if (onAgentStatus && !suppressAttentionEvents) { - for (const payload of processed.payloads) { - onAgentStatus(payload) - } - } if (options.replayingBufferedData && callbacks.onReplayData) { callbacks.onReplayData(data) } else { callbacks.onData?.(data) } - if (onTitleChange) { - // Why: feed EVERY OSC title in the chunk through the observer, not just - // the last one. node-pty + the main-process 8ms batch window commonly - // coalesce multiple title updates into a single IPC payload — for Pi's - // 80ms spinner + agent_end idle cycle, the last title in the chunk is - // the idle one and the intermediate working frames were silently - // dropped, so the worktree card never observed the working state. - // Processing titles in order preserves the working→idle transition - // that detectAgentStatusFromTitle and agentTracker both key off. - const titles = extractAllOscTitles(data) - if (titles.length > 0) { - clearStaleTitleTimer() - for (const title of titles) { - applyObservedTerminalTitle(title) - } - } else if (lastEmittedTitle && detectAgentStatusFromTitle(lastEmittedTitle) === 'working') { - clearStaleTitleTimer() - staleTitleTimer = setTimeout(() => { - staleTitleTimer = null - if (lastEmittedTitle && detectAgentStatusFromTitle(lastEmittedTitle) === 'working') { - const cleared = clearWorkingIndicators(lastEmittedTitle) - lastEmittedTitle = cleared - onTitleChange(cleared, cleared) - agentTracker?.handleTitle(cleared) - } - }, STALE_TITLE_TIMEOUT) - } - } - // Why: BEL is the attention signal. The detector is stateful across - // chunks so a BEL sitting inside an OSC sequence (e.g. Claude's - // `\e]0;title\a`) is correctly ignored — only true terminal bells raise - // attention. suppressAttentionEvents gates this during eager-buffer replay - // so historical BELs do not produce fresh alerts on cold reattach. - if (onBell && bellDetector.chunkContainsBell(data) && !suppressAttentionEvents) { - onBell() - } + schedulePtySideEffects(data, processed.payloads, suppressAttentionEvents) } function clearAccumulatedState(): void { + clearSideEffectDrainTimer() + pendingSideEffects.length = 0 clearStaleTitleTimer() agentTracker?.reset() bellDetector.reset() @@ -194,6 +242,7 @@ export function createPtyOutputProcessor({ processData, clearAccumulatedState, clearStaleTitleTimer, + flushPendingSideEffects, resetBellDetector: () => bellDetector.reset() } } @@ -454,6 +503,10 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra try { ptyDataHandlers.get(id)?.(buffered) } finally { + // Why: replay side effects are intentionally deferred for live + // output, but replay cleanup must observe them before resetting + // parser state or a partial OSC can swallow the next live BEL. + outputProcessor.flushPendingSideEffects() replayingBufferedData = false suppressAttentionEvents = false // Why: replaying eager-buffered bytes may have observed a "working" title diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 42dbc0baa..37f7a3b76 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -3,6 +3,7 @@ import { useEffect, useRef } from 'react' import type { IDisposable, Terminal } from '@xterm/xterm' import { PaneManager } from '@/lib/pane-manager/pane-manager' import { resolveTerminalCursorInactiveStyle } from '@/lib/pane-manager/pane-terminal-options' +import { buildWindowsPtyCompatibilityOptions } from '@/lib/pane-manager/windows-pty-compatibility' import { useAppStore } from '@/store' import { createFilePathLinkProvider, @@ -42,6 +43,7 @@ import { resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' import { connectPanePty } from './pty-connection' import type { PtyTransport } from './pty-transport' import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' +import { getConnectionId } from '@/lib/connection-context' import { isPaneReplaying, type ReplayingPanesRef } from './replay-guard' import { fitAndFocusPanes, fitPanes } from './pane-helpers' import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' @@ -795,7 +797,18 @@ export function useTerminalPaneLifecycle({ const currentSettings = settingsRef.current const terminalFontWeights = resolveTerminalFontWeights(currentSettings?.terminalFontWeight) const cursorStyle = currentSettings?.terminalCursorStyle ?? 'bar' + const storeState = useAppStore.getState() + const currentTab = storeState.tabsByWorktree[worktreeId]?.find( + (candidate) => candidate.id === tabId + ) + const windowsPtyCompatibilityOptions = buildWindowsPtyCompatibilityOptions({ + userAgent: navigator.userAgent, + connectionId: getConnectionId(worktreeId), + cwd: startupCwd, + shellOverride: currentTab?.shellOverride + }) return { + ...windowsPtyCompatibilityOptions, fontSize: currentSettings?.terminalFontSize ?? 14, fontFamily: buildFontFamily(currentSettings?.terminalFontFamily ?? ''), fontWeight: terminalFontWeights.fontWeight, 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 000000000..0059b3745 --- /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 } | 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-foreground-render-settle.ts b/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts new file mode 100644 index 000000000..036f9b670 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts @@ -0,0 +1,154 @@ +import { + FOREGROUND_CURSOR_RESTORE_SAFETY_DELAY_MS, + restoreForegroundTerminalCursor, + scheduleForegroundTerminalCursorRestore, + suppressForegroundTerminalCursor, + type TerminalCursorSuppressionTarget +} from './pane-terminal-cursor-suppression' + +export type ForegroundTerminalOutputTarget = TerminalCursorSuppressionTarget & { + buffer?: { + active?: { + cursorY?: number + baseY?: number + viewportY?: number + } + } + rows?: number + _core?: { + refresh?(start: number, end: number, sync?: boolean): void + } + refresh?(start: number, end: number): void + write(data: string, callback?: () => void): void +} + +const pendingViewportSettleRefreshByTerminal = new WeakMap< + ForegroundTerminalOutputTarget, + { kind: 'raf'; id: number } | { kind: 'timeout'; id: ReturnType } +>() + +type ViewportSnapshot = { + baseY: number | null + viewportY: number | null +} + +function refreshVisibleRowsNow(terminal: ForegroundTerminalOutputTarget): void { + if (typeof terminal.rows !== 'number' || terminal.rows < 1) { + return + } + + const start = 0 + const end = Math.max(0, terminal.rows - 1) + try { + // Why: xterm's DOM renderer batches row paints; Windows ConPTY CR-style + // rewrites can leave stale CJK glyph cells until a resize unless we paint + // the parsed foreground state before Chromium's next frame. + if (typeof terminal._core?.refresh === 'function') { + terminal._core.refresh(start, end, true) + return + } + terminal.refresh?.(start, end) + } catch { + // Ignore disposed terminals; PTY output can race pane teardown. + } +} + +function captureViewportSnapshot(terminal: ForegroundTerminalOutputTarget): ViewportSnapshot { + return { + baseY: typeof terminal.buffer?.active?.baseY === 'number' ? terminal.buffer.active.baseY : null, + viewportY: + typeof terminal.buffer?.active?.viewportY === 'number' + ? terminal.buffer.active.viewportY + : null + } +} + +function viewportChangedDuringWrite( + terminal: ForegroundTerminalOutputTarget, + beforeWrite: ViewportSnapshot +): boolean { + const afterWrite = captureViewportSnapshot(terminal) + return ( + afterWrite.baseY !== null && + afterWrite.viewportY !== null && + (afterWrite.baseY !== beforeWrite.baseY || afterWrite.viewportY !== beforeWrite.viewportY) + ) +} + +function cancelScheduledViewportSettleRefresh(terminal: ForegroundTerminalOutputTarget): void { + const pending = pendingViewportSettleRefreshByTerminal.get(terminal) + if (!pending) { + return + } + pendingViewportSettleRefreshByTerminal.delete(terminal) + if (pending.kind === 'raf') { + if (typeof cancelAnimationFrame === 'function') { + cancelAnimationFrame(pending.id) + } + return + } + clearTimeout(pending.id) +} + +function scheduleViewportSettleRefresh(terminal: ForegroundTerminalOutputTarget): void { + cancelScheduledViewportSettleRefresh(terminal) + if (typeof requestAnimationFrame === 'function') { + const id = requestAnimationFrame(() => { + pendingViewportSettleRefreshByTerminal.delete(terminal) + refreshVisibleRowsNow(terminal) + }) + pendingViewportSettleRefreshByTerminal.set(terminal, { kind: 'raf', id }) + return + } + + const id = setTimeout(() => { + pendingViewportSettleRefreshByTerminal.delete(terminal) + refreshVisibleRowsNow(terminal) + }, 16) + pendingViewportSettleRefreshByTerminal.set(terminal, { kind: 'timeout', id }) +} + +function settleForegroundRender( + terminal: ForegroundTerminalOutputTarget, + beforeWriteViewport: ViewportSnapshot +): void { + refreshVisibleRowsNow(terminal) + // Why: when output advances the viewport, Chromium can paint the freshly + // scrolled top row one frame later than xterm finishes parsing. Repaint once + // more after the scroll settles so the user doesn't need to jiggle the window. + if (viewportChangedDuringWrite(terminal, beforeWriteViewport)) { + scheduleViewportSettleRefresh(terminal) + } +} + +export function writeForegroundTerminalChunk( + terminal: ForegroundTerminalOutputTarget, + data: string +): void { + const beforeWriteViewport = captureViewportSnapshot(terminal) + suppressForegroundTerminalCursor(terminal) + // Why: a disposed terminal may never fire xterm's write callback; keep a + // safety restore so the cursor cannot remain hidden after teardown races. + scheduleForegroundTerminalCursorRestore(terminal, FOREGROUND_CURSOR_RESTORE_SAFETY_DELAY_MS) + try { + terminal.write(data, () => { + settleForegroundRender(terminal, beforeWriteViewport) + scheduleForegroundTerminalCursorRestore(terminal) + }) + } catch { + settleForegroundRender(terminal, beforeWriteViewport) + restoreForegroundTerminalCursor(terminal) + } +} + +export function suppressTerminalCursorUntilOutputSettles( + terminal: ForegroundTerminalOutputTarget +): void { + suppressForegroundTerminalCursor(terminal) + scheduleForegroundTerminalCursorRestore(terminal, FOREGROUND_CURSOR_RESTORE_SAFETY_DELAY_MS) +} + +export function discardForegroundRenderSettle(terminal: ForegroundTerminalOutputTarget): void { + cancelScheduledViewportSettleRefresh(terminal) + restoreForegroundTerminalCursor(terminal) +} 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 fe71a2b9c..c18b21703 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,13 +1,43 @@ 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?.() }) } } +function createForegroundTerminal() { + return { + buffer: { + active: { + cursorY: 7, + baseY: 0, + viewportY: 0 + } + }, + rows: 24, + refresh: vi.fn(), + _core: { + refresh: vi.fn() + }, + write: vi.fn((_data: string, callback?: () => void) => callback?.()) + } +} + async function loadScheduler() { vi.resetModules() return import('./pane-terminal-output-scheduler') @@ -24,7 +54,83 @@ describe('pane terminal output scheduler', () => { writeTerminalOutput(terminal, 'foreground', { foreground: true }) - expect(terminal.write).toHaveBeenCalledWith('foreground') + expect(terminal.write).toHaveBeenCalledWith('foreground', expect.any(Function)) + }) + + it('synchronously refreshes visible rows after foreground output parses', async () => { + const { writeTerminalOutput } = await loadScheduler() + const terminal = createForegroundTerminal() + terminal.write.mockImplementation((_data: string, callback?: () => void) => { + terminal.buffer.active.cursorY = 3 + callback?.() + }) + + writeTerminalOutput(terminal, '中文 PowerShell repaint\r\n', { foreground: true }) + + expect(terminal._core.refresh).toHaveBeenCalledWith(0, 23, true) + expect(terminal.refresh).not.toHaveBeenCalled() + }) + + it('repaints the viewport again on the next frame when foreground output scrolls', async () => { + const scheduledFrames: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + scheduledFrames.push(callback) + return scheduledFrames.length + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + + const { writeTerminalOutput } = await loadScheduler() + const terminal = createForegroundTerminal() + terminal.buffer.active.baseY = 10 + terminal.buffer.active.viewportY = 10 + terminal.write.mockImplementation((_data: string, callback?: () => void) => { + terminal.buffer.active.baseY = 11 + terminal.buffer.active.viewportY = 11 + callback?.() + }) + + writeTerminalOutput(terminal, '顶部滚动中文复现\r\n', { foreground: true }) + + expect(terminal._core.refresh).toHaveBeenCalledTimes(1) + expect(scheduledFrames).toHaveLength(1) + + scheduledFrames[0]?.(16) + + expect(terminal._core.refresh).toHaveBeenCalledTimes(2) + expect(terminal._core.refresh).toHaveBeenLastCalledWith(0, 23, true) + }) + + 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) + expect(terminal.write).toHaveBeenCalledWith('frame', expect.any(Function)) + + 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('can hide the cursor immediately while input waits for echoed output', async () => { + vi.useFakeTimers() + const { suppressTerminalCursorUntilOutputSettles } = await loadScheduler() + const terminal = createTerminal() + + suppressTerminalCursorUntilOutputSettles(terminal) + + expect(terminal.classes.has('terminal-foreground-write-pending')).toBe(true) + + vi.advanceTimersByTime(499) + expect(terminal.classes.has('terminal-foreground-write-pending')).toBe(true) + + vi.advanceTimersByTime(1) + expect(terminal.classes.has('terminal-foreground-write-pending')).toBe(false) }) 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 e278b446c..47c8082d4 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,8 +1,12 @@ import { e2eConfig } from '@/lib/e2e-config' +import { + discardForegroundRenderSettle, + suppressTerminalCursorUntilOutputSettles, + writeForegroundTerminalChunk, + type ForegroundTerminalOutputTarget +} from './pane-terminal-foreground-render-settle' -type TerminalOutputTarget = { - write(data: string, callback?: () => void): void -} +type TerminalOutputTarget = ForegroundTerminalOutputTarget type TerminalOutputBeforeWrite = (data: string) => void @@ -172,7 +176,7 @@ export function writeTerminalOutput( debugState.foregroundWriteCount++ } options.beforeWrite?.(data) - terminal.write(data) + writeForegroundTerminalChunk(terminal, data) return } @@ -247,6 +251,8 @@ export function waitForTerminalOutputParsed(terminal: TerminalOutputTarget): Pro export function discardTerminalOutput(terminal: TerminalOutputTarget): void { exposeDebugApi() queuedByTerminal.delete(terminal) + discardForegroundRenderSettle(terminal) } exposeDebugApi() +export { suppressTerminalCursorUntilOutputSettles } diff --git a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts new file mode 100644 index 000000000..d620e20e8 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { + buildWindowsPtyCompatibilityOptions, + isLocalNativeWindowsPty +} from './windows-pty-compatibility' + +describe('buildWindowsPtyCompatibilityOptions', () => { + it('returns ConPTY compatibility options for local Windows terminals', () => { + expect( + buildWindowsPtyCompatibilityOptions({ + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + connectionId: null, + cwd: 'C:\\repo', + shellOverride: null + }) + ).toEqual({ + windowsPty: { backend: 'conpty' } + }) + }) + + it('skips compatibility options for SSH-backed Windows terminals', () => { + expect( + buildWindowsPtyCompatibilityOptions({ + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + connectionId: 'ssh-1', + cwd: 'C:\\repo', + shellOverride: null + }) + ).toEqual({}) + }) + + it('skips compatibility options for WSL cwd terminals', () => { + for (const cwd of [ + '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo', + '\\\\wsl$\\Debian\\home\\me\\repo', + '//wsl.localhost/Ubuntu/home/me/repo', + '//wsl$/Debian/home/me/repo' + ]) { + expect( + buildWindowsPtyCompatibilityOptions({ + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + connectionId: null, + cwd, + shellOverride: null + }) + ).toEqual({}) + } + }) + + it('skips compatibility options when the shell override launches WSL', () => { + expect( + buildWindowsPtyCompatibilityOptions({ + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + connectionId: null, + cwd: 'C:\\repo', + shellOverride: 'C:\\Windows\\System32\\wsl.exe' + }) + ).toEqual({}) + }) + + it('returns no options outside Windows', () => { + expect( + buildWindowsPtyCompatibilityOptions({ + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)', + connectionId: null, + cwd: '/repo', + shellOverride: null + }) + ).toEqual({}) + }) + + it('exposes the same local native Windows predicate for related renderer workarounds', () => { + expect( + isLocalNativeWindowsPty({ + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + connectionId: null, + cwd: 'C:\\repo', + shellOverride: 'powershell.exe' + }) + ).toBe(true) + expect( + isLocalNativeWindowsPty({ + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + connectionId: 'ssh-1', + cwd: 'C:\\repo', + shellOverride: 'powershell.exe' + }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts new file mode 100644 index 000000000..9e833f41b --- /dev/null +++ b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts @@ -0,0 +1,47 @@ +import type { ITerminalOptions } from '@xterm/xterm' +import { isWslUncPath } from '../../../../shared/wsl-paths' + +export type WindowsPtyCompatibilityContext = { + userAgent?: string + connectionId: string | null | undefined + cwd?: string | null + shellOverride?: string | null +} + +function isWindowsUserAgent(userAgent: string | undefined): boolean { + return userAgent?.includes('Windows') ?? false +} + +function isWslCwd(cwd: string | null | undefined): boolean { + return isWslUncPath(cwd ?? '') +} + +function isWslShellOverride(shellOverride: string | null | undefined): boolean { + return /(?:^|[/\\])wsl(?:\.exe)?$/i.test(shellOverride ?? '') +} + +export function buildWindowsPtyCompatibilityOptions( + context: WindowsPtyCompatibilityContext +): Partial { + if (!isLocalNativeWindowsPty(context)) { + return {} + } + return { + // Why: native Windows shells are backed by ConPTY, and xterm's dedicated + // compatibility heuristics prevent wrap/cursor assumptions from drifting. + windowsPty: { backend: 'conpty' } + } +} + +export function isLocalNativeWindowsPty(context: WindowsPtyCompatibilityContext): boolean { + if (!isWindowsUserAgent(context.userAgent)) { + return false + } + if (context.connectionId !== null) { + return false + } + if (isWslCwd(context.cwd) || isWslShellOverride(context.shellOverride)) { + return false + } + return true +}