P0 BLOCKER: Stop Windows PowerShell Chinese output from corrupting the top viewport while scrolling (#2669)

* fix(terminal): eliminate P0 Windows PowerShell CJK top-scroll duplication

* fix(terminal): address Windows PTY review findings

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
Go1c 2026-05-23 15:40:54 +08:00 committed by GitHub
parent 9b9d1b7e15
commit 45a573e4f0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 684 additions and 51 deletions

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: 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,

View File

@ -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)
)
})

View File

@ -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

View File

@ -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<void> {
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')

View File

@ -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<void> {
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')

View File

@ -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<void> {
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')

View File

@ -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<void> {
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

View File

@ -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<typeof setTimeout> | null = null
let sideEffectDrainTimer: ReturnType<typeof setTimeout> | null = null
const pendingSideEffects: {
data: string
payloads: ReturnType<typeof processAgentStatusChunk>['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<typeof processAgentStatusChunk>['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

View File

@ -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,

View File

@ -0,0 +1,50 @@
export type TerminalCursorSuppressionTarget = {
element?: { classList?: Pick<DOMTokenList, 'add' | 'remove'> | 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<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)
}

View File

@ -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<typeof setTimeout> }
>()
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)
}

View File

@ -1,13 +1,43 @@
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?.()
})
}
}
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 () => {

View File

@ -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 }

View File

@ -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)
})
})

View File

@ -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<ITerminalOptions> {
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
}