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:
parent
9b9d1b7e15
commit
45a573e4f0
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue