Stabilize OpenCode terminal rendering before release (#4850)

This commit is contained in:
Neil 2026-06-08 01:00:29 -07:00 committed by GitHub
parent 9867305465
commit f15dcd928c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 1549 additions and 455 deletions

View File

@ -13,6 +13,16 @@ function createTestDir(): string {
return mkdtempSync(join(tmpdir(), 'daemon-client-test-'))
}
function splitInsideUtf8Sequence(payload: string, needle: string): [Buffer, Buffer] {
const encoded = Buffer.from(payload, 'utf8')
const encodedNeedle = Buffer.from(needle, 'utf8')
const offset = encoded.indexOf(encodedNeedle)
if (offset === -1 || encodedNeedle.length < 2) {
throw new Error(`Unable to split payload inside ${needle}`)
}
return [encoded.subarray(0, offset + 1), encoded.subarray(offset + 1)]
}
async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
const start = Date.now()
while (!predicate()) {
@ -332,6 +342,48 @@ describe('DaemonClient', () => {
sessionId: 'session-1'
})
})
it('preserves UTF-8 stream events split inside multibyte characters', async () => {
let streamSocket: Socket | null = null
await startMockDaemon()
const origListener = server.listeners('connection')[0] as (s: Socket) => void
server.removeAllListeners('connection')
let socketCount = 0
server.on('connection', (socket) => {
socketCount++
if (socketCount === 2) {
streamSocket = socket
}
origListener(socket)
})
const events: DaemonEvent[] = []
client = new DaemonClient({ socketPath, tokenPath })
client.onEvent((event) => events.push(event as DaemonEvent))
await client.ensureConnected()
await waitFor(() => streamSocket !== null)
const tableRow = '│OpenCode│🧩│┼────────┤'
const event: DaemonEvent = {
type: 'event',
event: 'data',
sessionId: 'session-1',
payload: { data: tableRow }
}
const [first, second] = splitInsideUtf8Sequence(encodeNdjson(event), '🧩')
streamSocket!.write(first)
streamSocket!.write(second)
await waitFor(() => events.length > 0)
expect(events[0]).toMatchObject({
type: 'event',
event: 'data',
sessionId: 'session-1',
payload: { data: tableRow }
})
expect(JSON.stringify(events[0])).not.toContain('\ufffd')
})
})
describe('disconnect', () => {

View File

@ -2,6 +2,7 @@
import { connect, type Socket } from 'net'
import { readFileSync } from 'fs'
import { randomUUID } from 'crypto'
import { StringDecoder } from 'string_decoder'
import { encodeNdjson, createNdjsonParser } from './ndjson'
import { PROTOCOL_VERSION, NOTIFY_PREFIX, DaemonProtocolError } from './types'
import type { HelloMessage, HelloResponse, RpcResponse, DaemonEvent } from './types'
@ -256,8 +257,11 @@ export class DaemonClient {
}
resolve()
}
// Why: daemon socket chunks can split emoji/box-drawing UTF-8 bytes.
// Decoding each Buffer independently would permanently inject U+FFFD.
const decoder = new StringDecoder('utf8')
const onData = (chunk: Buffer): void => {
buffer += chunk.toString()
buffer += decoder.write(chunk)
const newlineIdx = buffer.indexOf('\n')
if (newlineIdx === -1) {
return
@ -295,6 +299,9 @@ export class DaemonClient {
}
private setupControlParser(socket: Socket): () => void {
// Why: control responses may contain terminal/startup data with multibyte
// text; keep incomplete UTF-8 bytes until the next socket chunk.
const decoder = new StringDecoder('utf8')
const parser = createNdjsonParser(
(msg) => {
const response = msg as RpcResponse
@ -314,12 +321,15 @@ export class DaemonClient {
() => {} // Ignore parse errors on control socket
)
const onData = (chunk: Buffer) => parser.feed(chunk.toString())
const onData = (chunk: Buffer) => parser.feed(decoder.write(chunk))
socket.on('data', onData)
return () => socket.off('data', onData)
}
private setupStreamParser(socket: Socket): () => void {
// Why: PTY output streams include emoji/box-drawing tables; socket chunks
// can split those UTF-8 sequences across packets.
const decoder = new StringDecoder('utf8')
const parser = createNdjsonParser(
(msg) => {
const event = msg as DaemonEvent
@ -332,7 +342,7 @@ export class DaemonClient {
() => {} // Ignore parse errors on stream socket
)
const onData = (chunk: Buffer) => parser.feed(chunk.toString())
const onData = (chunk: Buffer) => parser.feed(decoder.write(chunk))
socket.on('data', onData)
return () => socket.off('data', onData)
}

View File

@ -5,6 +5,7 @@ import { createServer, type Server, type Socket } from 'net'
import { randomUUID } from 'crypto'
import { performance } from 'perf_hooks'
import { writeFileSync, chmodSync, unlinkSync } from 'fs'
import { StringDecoder } from 'string_decoder'
import { encodeNdjson, createNdjsonParser } from './ndjson'
import { TerminalHost } from './terminal-host'
import { DaemonStreamDataBatcher } from './daemon-stream-data-batcher'
@ -113,6 +114,9 @@ export class DaemonServer {
}
private handleConnection(socket: Socket): void {
// Why: clients can send multibyte prompt/input text split across socket
// chunks; keep UTF-8 sequences intact before NDJSON parsing.
const decoder = new StringDecoder('utf8')
const parser = createNdjsonParser(
(msg) => this.handleFirstMessage(socket, msg, parser),
() => {
@ -120,7 +124,7 @@ export class DaemonServer {
}
)
socket.on('data', (chunk) => parser.feed(chunk.toString()))
socket.on('data', (chunk) => parser.feed(decoder.write(chunk)))
socket.on('error', () => socket.destroy())
}
@ -179,6 +183,9 @@ export class DaemonServer {
}
private setupControlSocket(socket: Socket, clientId: string): void {
// Why: terminal writes and startup commands can contain emoji/Unicode.
// Decoding per Buffer would corrupt split multibyte sequences.
const decoder = new StringDecoder('utf8')
const parser = createNdjsonParser(
(msg) => this.handleRequest(socket, clientId, msg as DaemonRequest),
() => {} // Ignore parse errors
@ -186,7 +193,7 @@ export class DaemonServer {
// Remove the initial data listener and replace with the RPC parser
socket.removeAllListeners('data')
socket.on('data', (chunk) => parser.feed(chunk.toString()))
socket.on('data', (chunk) => parser.feed(decoder.write(chunk)))
socket.on('close', () => {
const client = this.clients.get(clientId)

View File

@ -31,6 +31,16 @@ describe('HeadlessEmulator', () => {
expect(snapshot.snapshotAnsi).toContain('hello world')
})
it('captures PTY output in immediate snapshots without waiting for queued parsing', () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
void emulator.write('rendered before hidden restore snapshot')
expect(emulator.getSnapshot().snapshotAnsi).toContain(
'rendered before hidden restore snapshot'
)
})
it('captures colored text', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b[31mred text\x1b[0m')
@ -328,17 +338,17 @@ describe('HeadlessEmulator', () => {
expect(snapshot.rehydrateSequences).not.toContain('\x1b[?1006h')
})
it('does not expose mouse modes before xterm applies the same write', async () => {
it('keeps mode snapshots in sync with immediate headless parsing', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
const writePromise = emulator.write('\x1b[?1049h\x1b[?1002;1006h')
const snapshot = emulator.getSnapshot()
await writePromise
expect(snapshot.modes.alternateScreen).toBe(false)
expect(snapshot.modes.mouseTracking).toBe(false)
expect(snapshot.modes.sgrMouseMode).toBe(false)
expect(snapshot.rehydrateSequences).toBe('')
expect(snapshot.modes.alternateScreen).toBe(true)
expect(snapshot.modes.mouseTrackingMode).toBe('drag')
expect(snapshot.modes.sgrMouseMode).toBe(true)
expect(snapshot.rehydrateSequences).toContain('\x1b[?1049h')
const after = emulator.getSnapshot()
expect(after.modes.alternateScreen).toBe(true)

View File

@ -14,6 +14,12 @@ export type HeadlessSnapshotOptions = {
scrollbackRows?: number
}
type TerminalWithSynchronousWrite = Terminal & {
_core?: {
writeSync?: (data: string) => void
}
}
const DEFAULT_SCROLLBACK = 5000
const OSC_SCAN_TAIL_LIMIT = 4096
// Why: PTY/SSH chunks can split a long combined DECSET before the final h/l.
@ -66,7 +72,8 @@ export class HeadlessEmulator {
cols: opts.cols,
rows: opts.rows,
scrollback: opts.scrollback ?? DEFAULT_SCROLLBACK,
allowProposedApi: true
allowProposedApi: true,
logLevel: 'off'
})
this.serializer = new SerializeAddon()
@ -97,6 +104,14 @@ export class HeadlessEmulator {
if (lastTitle !== null) {
this.lastTitle = lastTitle
}
const writeSync = (this.terminal as TerminalWithSynchronousWrite)._core?.writeSync
if (typeof writeSync === 'function') {
// Why: hidden renderer restore snapshots are requested immediately after
// PTY bursts; queued headless writes can snapshot half-cleared TUI rows.
writeSync.call((this.terminal as TerminalWithSynchronousWrite)._core, data)
this.scanPrivateModes(data)
return Promise.resolve()
}
return new Promise<void>((resolve) => {
this.terminal.write(data, () => {
// Why: snapshots combine serialized xterm state with mirrored mouse

View File

@ -3135,6 +3135,36 @@ describe('Store', () => {
expect(store.getSettings().terminalMacOptionAsAltMigrated).toBe(true)
})
it('migrates inherited terminal bar cursor defaults to block on first load', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: { terminalCursorStyle: 'bar' },
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
})
const store = await createStore()
expect(store.getSettings().terminalCursorStyle).toBe('block')
expect(store.getSettings().terminalCursorStyleDefaultedToBlock).toBe(true)
})
it('preserves terminal cursor choices after the block-default migration', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: { terminalCursorStyle: 'bar', terminalCursorStyleDefaultedToBlock: true },
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
})
const store = await createStore()
expect(store.getSettings().terminalCursorStyle).toBe('bar')
expect(store.getSettings().terminalCursorStyleDefaultedToBlock).toBe(true)
})
it('preserves explicit "false" terminalMacOptionAsAlt through migration', async () => {
// 'false' never matched the old default — it was an explicit choice.
writeDataFile({

View File

@ -124,6 +124,7 @@ import {
sourceControlAiSettingsFromLegacy
} from '../shared/source-control-ai'
import { normalizeDisabledTuiAgents } from '../shared/tui-agent-selection'
import { normalizeTerminalCursorStyleDefault } from '../shared/terminal-cursor-style-settings'
import { normalizeBrowserPageZoomLevel } from '../shared/browser-page-zoom'
import {
collectTerminalScrollbackSnapshotRefs,
@ -1832,6 +1833,7 @@ export class Store {
const migratedAutoRenameBranchFromWork = normalizeAutoRenameBranchFromWorkDefaultOn(
parsed.settings
)
const migratedTerminalCursorStyle = normalizeTerminalCursorStyleDefault(parsed.settings)
const rawTaskProviderSettings = normalizeTaskProviderSettings({
visibleTaskProviders: parsed.settings?.visibleTaskProviders,
defaultTaskSource: parsed.settings?.defaultTaskSource
@ -1901,6 +1903,7 @@ export class Store {
primarySelectionMiddleClickPasteDefaultedForTerminalDefaults:
primarySelectionDefaultedForTerminalDefaults || stampPrimarySelectionTerminalDefaults,
...migratedAutoRenameBranchFromWork,
...migratedTerminalCursorStyle,
experimentalActivity: migratedExperimentalActivity,
experimentalActivityDefaultedOffForAllUsers: true,
terminalMacOptionAsAlt: migratedOptionAsAlt,

View File

@ -5250,11 +5250,22 @@ describe('OrcaRuntimeService', () => {
const [terminal] = (await runtime.listTerminals()).terminals
const lines = Array.from({ length: 120 }, (_, index) => `line-${index}-${'x'.repeat(400)}`)
runtime.onPtyData('pty-1', `${lines.join('\n')}\n`, 100)
// Why: xterm-headless uses Array.shift internally while draining writes;
// this test guards read-preview trimming, not emulator parsing.
await runtime.serializeMainTerminalBuffer('pty-1')
const shiftSpy = vi.spyOn(Array.prototype, 'shift')
const preview = await runtime.readTerminal(terminal.handle)
const shiftCallCount = shiftSpy.mock.calls.length
shiftSpy.mockRestore()
const originalShift = Array.prototype.shift
let shiftCallCount = 0
Array.prototype.shift = function (...args) {
shiftCallCount += 1
return originalShift.apply(this, args)
}
let preview: Awaited<ReturnType<typeof runtime.readTerminal>>
try {
preview = await runtime.readTerminal(terminal.handle)
} finally {
Array.prototype.shift = originalShift
}
expect(preview.limited).toBe(true)
expect(preview.tail.at(-1)).toBe(lines.at(-1))

View File

@ -12345,11 +12345,8 @@ export class OrcaRuntimeService {
const retained = [...this.ptysById.values()]
.filter((pty) => !pty.connected && !this.leafExistsForPty(pty.ptyId))
.sort((a, b) => (a.disconnectedAt ?? 0) - (b.disconnectedAt ?? 0))
while (retained.length > DISCONNECTED_PTY_RECORD_MAX) {
const stale = retained.shift()
if (!stale) {
return
}
const staleCount = Math.max(0, retained.length - DISCONNECTED_PTY_RECORD_MAX)
for (const stale of retained.slice(0, staleCount)) {
// Why: exited runtime-owned PTYs stay readable after exit, but long-lived
// runtimes can churn through many background sessions. Bound the archive.
this.dropDisconnectedPtyRecord(stale.ptyId)

View File

@ -145,7 +145,7 @@ function makeSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings {
terminalFontSize: 14,
terminalFontWeight: 400,
terminalLineHeight: 1,
terminalCursorStyle: 'bar',
terminalCursorStyle: 'block',
terminalCursorBlink: true,
terminalLigatures: 'off',
terminalThemeDark: 'Dark',
@ -202,8 +202,8 @@ describe('TerminalSettingsPreview terminal lifecycle', () => {
allowTransparency: false,
cols: 36,
cursorBlink: true,
cursorInactiveStyle: 'bar',
cursorStyle: 'bar',
cursorInactiveStyle: 'block',
cursorStyle: 'block',
disableStdin: true,
fontFamily: 'built:SF Mono',
fontSize: 14,

View File

@ -2848,10 +2848,7 @@ describe('connectPanePty', () => {
expect(window.api.pty.ackColdRestore).toHaveBeenCalledWith('tab-pty')
})
// Regression for foreground input lag with many background terminals:
// hidden local panes keep reading PTY bytes, but avoid xterm parse/write
// work until the pane returns and can hydrate from main-owned terminal state.
it('skips non-visible local PTY bytes instead of writing them into xterm', async () => {
it('keeps non-visible local PTY bytes on the live xterm path for release', async () => {
const pendingTimeouts: (() => void)[] = []
const originalSetTimeout = globalThis.setTimeout
globalThis.setTimeout = vi.fn((fn: () => void) => {
@ -2888,12 +2885,85 @@ describe('connectPanePty', () => {
fn()
}
expect(pane.terminal.write).not.toHaveBeenCalledWith('hello\r\n')
expect(pane.terminal.write).toHaveBeenCalledWith('hello\r\n')
} finally {
globalThis.setTimeout = originalSetTimeout
}
})
it('keeps visually rich hidden PTY bytes on the live xterm path', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
capturedDataCallback.current = callbacks.onData ?? null
return 'pty-id'
})
transportFactoryQueue.push(transport)
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps({
isVisibleRef: { current: false }
})
connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(6)
expect(capturedDataCallback.current).not.toBeNull()
vi.useFakeTimers()
try {
const hiddenTuiChunk = '\x1b[2J\x1b[H╭ table 😀 ╮\r\n'
capturedDataCallback.current?.(hiddenTuiChunk)
expect(pane.terminal.write).not.toHaveBeenCalledWith(hiddenTuiChunk)
vi.advanceTimersByTime(50)
expect(pane.terminal.write).toHaveBeenCalledWith(hiddenTuiChunk)
expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('keeps split hidden synchronized output frames on the live xterm path', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
capturedDataCallback.current = callbacks.onData ?? null
return 'pty-id'
})
transportFactoryQueue.push(transport)
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps({
isVisibleRef: { current: false }
})
connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(6)
expect(capturedDataCallback.current).not.toBeNull()
vi.useFakeTimers()
try {
const startChunk = '\x1b[?2026h'
const plainRowChunk = '| Sam Syntax | Compiler | Online |\r\n'
const endChunk = 'LONG_TABLE_SCROLL_RESTORE_marker\r\n\x1b[?2026l'
capturedDataCallback.current?.(startChunk)
capturedDataCallback.current?.(plainRowChunk)
capturedDataCallback.current?.(endChunk)
expect(pane.terminal.write).not.toHaveBeenCalledWith(plainRowChunk)
vi.advanceTimersByTime(50)
expect(pane.terminal.write).toHaveBeenCalledWith(`${startChunk}${plainRowChunk}${endChunk}`)
expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('queues visible split-pane PTY bytes when the pane is not active', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
@ -3102,7 +3172,7 @@ describe('connectPanePty', () => {
binding.dispose()
})
it('answers mode 2031 while hidden without xterm parsing', async () => {
it('writes mode 2031 through hidden xterm instead of side-channel answering it', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
@ -3125,15 +3195,21 @@ describe('connectPanePty', () => {
)
await flushAsyncTicks(6)
capturedDataCallback.current?.('\x1b[?2031h')
vi.useFakeTimers()
try {
capturedDataCallback.current?.('\x1b[?2031h')
vi.advanceTimersByTime(50)
expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;2n')
expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1b[?2031h')
expect(transport.sendInput).not.toHaveBeenCalled()
expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[?2031h')
} finally {
vi.useRealTimers()
}
binding.dispose()
})
it('restores proactively skipped hidden output from the main terminal snapshot', async () => {
it('writes ordinary hidden output live instead of proactively restoring a snapshot', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
const capturedDataCallback: {
@ -3174,16 +3250,13 @@ describe('connectPanePty', () => {
})
await flushAsyncTicks(20)
expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 })
expect(pane.terminal.write).toHaveBeenCalledWith(
`snapshot-with-${hidden}`,
expect.any(Function)
)
expect(pane.terminal.write).not.toHaveBeenCalledWith(live, expect.any(Function))
expect(getMainBufferSnapshot).not.toHaveBeenCalled()
expect(pane.terminal.write).toHaveBeenCalledWith(hidden)
expect(pane.terminal.write).toHaveBeenCalledWith(live, expect.any(Function))
disposable.dispose()
})
it('restores skipped hidden remote runtime output from the transport snapshot', async () => {
it('writes ordinary hidden remote runtime output live instead of restoring a snapshot', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('remote:env-1@@terminal-1')
const capturedDataCallback: {
@ -3224,13 +3297,13 @@ describe('connectPanePty', () => {
await flushAsyncTicks(20)
expect(getMainBufferSnapshot).not.toHaveBeenCalled()
expect(transport.serializeBuffer).toHaveBeenCalledWith({ scrollbackRows: 5000 })
expect(pane.terminal.write).toHaveBeenCalledWith('remote snapshot\r\n', expect.any(Function))
expect(transport.serializeBuffer).not.toHaveBeenCalled()
expect(pane.terminal.write).toHaveBeenCalledWith(hidden)
expect(pane.terminal.write).toHaveBeenCalledWith(live, expect.any(Function))
disposable.dispose()
})
it('defers inactive split-pane hidden restores', async () => {
it('keeps inactive split-pane hidden output live instead of deferring snapshot restore', async () => {
const { resetHiddenOutputRestoreSchedulerForTests } =
await import('./hidden-output-restore-scheduler')
let disposable: { dispose: () => void } | null = null
@ -3282,11 +3355,9 @@ describe('connectPanePty', () => {
await new Promise((resolve) => setTimeout(resolve, 30))
await flushAsyncTicks(20)
expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 })
expect(pane.terminal.write).toHaveBeenCalledWith(
'inactive snapshot\r\n',
expect.any(Function)
)
expect(getMainBufferSnapshot).not.toHaveBeenCalled()
expect(pane.terminal.write).toHaveBeenCalledWith(hidden)
expect(pane.terminal.write).toHaveBeenCalledWith(live, expect.any(Function))
} finally {
disposable?.dispose()
resetHiddenOutputRestoreSchedulerForTests()
@ -3351,7 +3422,7 @@ describe('connectPanePty', () => {
}
})
it('retries hidden remote runtime restore after a null transport snapshot', async () => {
it('does not retry remote snapshots for ordinary hidden runtime output', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('remote:env-1@@terminal-1')
const capturedDataCallback: {
@ -3387,7 +3458,7 @@ describe('connectPanePty', () => {
})
await flushAsyncTicks(20)
expect(transport.serializeBuffer).toHaveBeenCalledTimes(1)
expect(transport.serializeBuffer).not.toHaveBeenCalled()
expect(pane.terminal.write).not.toHaveBeenCalledWith(
expect.stringContaining('Orca skipped hidden terminal output'),
expect.any(Function)
@ -3400,11 +3471,8 @@ describe('connectPanePty', () => {
await new Promise((resolve) => setTimeout(resolve, 80))
await flushAsyncTicks(20)
expect(transport.serializeBuffer).toHaveBeenCalledTimes(2)
expect(pane.terminal.write).toHaveBeenCalledWith(
'remote recovered snapshot\r\n',
expect.any(Function)
)
expect(transport.serializeBuffer).not.toHaveBeenCalled()
expect(pane.terminal.write).toHaveBeenCalledWith(firstLive, expect.any(Function))
disposable.dispose()
})
@ -3905,6 +3973,60 @@ describe('connectPanePty', () => {
disposable.dispose()
})
it('refreshes visible rows after replaying a hidden TUI snapshot', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
const capturedDataCallback: {
current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null
} = { current: null }
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
capturedDataCallback.current = callbacks.onData ?? null
return 'pty-id'
})
transportFactoryQueue.push(transport)
const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType<
typeof vi.fn
>
const hidden = 'x'.repeat(2 * 1024 * 1024 + 1)
const live = '\x1b[?2026h\x1b[2J\x1b[H╭────╮\r\n│ ok │\r\n╰────╯\x1b[?2026l'
getMainBufferSnapshot.mockResolvedValue({
data: live,
cols: 120,
rows: 40,
seq: hidden.length + live.length
})
const pane = createPane(1)
const refresh = vi.fn()
const terminal = pane.terminal as typeof pane.terminal & {
_core?: { refresh: typeof refresh }
}
terminal._core = { refresh }
terminal.write = vi.fn((_data: string, callback?: () => void) => {
callback?.()
})
const manager = createManager(1)
const deps = createDeps({
isVisibleRef: { current: false }
})
const disposable = connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(6)
capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length })
;(deps.isVisibleRef as { current: boolean }).current = true
capturedDataCallback.current?.(live, {
seq: hidden.length + live.length,
rawLength: live.length
})
await flushAsyncTicks(20)
// Why: hidden restore replays bypass live foreground output; force a paint
// after xterm parses the snapshot so stale WebGL cells cannot survive.
expect(refresh).toHaveBeenCalledWith(0, 39, true)
expect(deps.replayingPanesRef.current.size).toBe(0)
disposable.dispose()
})
it('marks panes that receive Arabic output for DOM rendering', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
@ -4077,7 +4199,7 @@ describe('connectPanePty', () => {
expect(refresh).not.toHaveBeenCalled()
})
it('keeps panes on WebGL for terminal UI drawing glyphs', async () => {
it('switches terminal UI drawing glyphs to the DOM renderer for release safety', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
@ -4096,7 +4218,7 @@ describe('connectPanePty', () => {
capturedDataCallback.current?.('⠋ Working ├─ file.ts █ progress \uE0B0 prompt\r\n')
expect(manager.markPaneHasComplexScriptOutput).not.toHaveBeenCalled()
expect(manager.markPaneHasComplexScriptOutput).toHaveBeenCalledWith(1)
expect(pane.terminal.write).toHaveBeenCalledWith(
'⠋ Working ├─ file.ts █ progress \uE0B0 prompt\r\n',
expect.any(Function)

View File

@ -1826,9 +1826,9 @@ export function connectPanePty(
// Why: drain any queued background bytes BEFORE the replay paint, so the
// scheduler's deferred drain cannot land older bytes on top of the replay.
flushTerminalOutput(pane.terminal)
if (terminalOutputChunkPrefersDomRenderer(data)) {
manager.markPaneHasComplexScriptOutput(pane.id)
}
// Why: replay rebuilds terminal pixels from serialized bytes. Keep it off
// WebGL so stale atlas/canvas cells cannot survive restore + scroll.
manager.markPaneHasComplexScriptOutput(pane.id)
replayIntoTerminal(pane, deps.replayingPanesRef, data)
}
@ -1925,12 +1925,18 @@ export function connectPanePty(
// Why: hidden tab output is coalesced by the scheduler. Run per-byte
// renderer checks at the xterm write boundary so background PTY bursts
// do not spend foreground event-loop time scanning bytes we will delay.
if (terminalOutputChunkPrefersDomRenderer(chunk)) {
if (terminalOutputChunkPrefersDomRenderer(chunk) || containsNonAsciiOutput(chunk)) {
manager.markPaneHasComplexScriptOutput(pane.id)
}
recordTerminalOutput(pane.terminal)
}
function markRendererRiskForSkippedOutput(): void {
// Why: hidden-output recovery skips xterm.write entirely, so WebGL does
// not see the bytes it would later need to repaint during restore/scroll.
manager.markPaneHasComplexScriptOutput(pane.id)
}
function consumeForegroundImmediateBudget(dataLength: number): boolean {
const now = performance.now()
if (now - foregroundImmediateBudgetWindowStart > FOREGROUND_BUDGET_WINDOW_MS) {
@ -2098,16 +2104,18 @@ export function connectPanePty(
}
}
function shouldSkipHiddenRendererOutput(foreground: boolean): boolean {
return (
!foreground &&
!deps.isVisibleRef.current &&
canUseHiddenOutputSnapshot(transport.getPtyId()) &&
!isHiddenStartupRendererQueryWindowActive()
)
function shouldSkipHiddenRendererOutput(foreground: boolean, data: string): boolean {
void foreground
void data
// Why: release correctness beats the hidden-output perf optimization.
// Real OpenCode tables still corrupt after workspace switching when PTY
// bytes bypass the renderer, so keep hidden panes on the live xterm path
// and leave snapshot skipping for a later perf branch.
return false
}
function skipHiddenRendererOutput(data: string): void {
markRendererRiskForSkippedOutput()
respondToSkippedMode2031Subscribe(data)
markHiddenOutputRestoreNeeded()
if (hiddenOutputRestoreInFlight) {
@ -2511,7 +2519,7 @@ export function connectPanePty(
const foreground = shouldWritePtyOutputForeground(deps.isVisibleRef.current)
const restoreAppliesToCurrentPty =
hiddenOutputRestorePtyId !== null && transport.getPtyId() === hiddenOutputRestorePtyId
if (shouldSkipHiddenRendererOutput(foreground)) {
if (shouldSkipHiddenRendererOutput(foreground, data)) {
skipHiddenRendererOutput(data)
} else if (
(hiddenOutputRestoreNeeded || hiddenOutputRestoreInFlight) &&

View File

@ -10,6 +10,16 @@ type FakeTerminal = {
write: (data: string, cb?: () => void) => void
lastData: string[]
pendingCallbacks: (() => void)[]
rows: number
buffer: {
active: {
baseY: number
viewportY: number
}
}
_core: {
refresh: (start: number, end: number, sync?: boolean) => void
}
/** Flush all pending xterm write callbacks, simulating parse completion. */
flush: () => void
}
@ -19,6 +29,16 @@ function makeFakePane(paneId: number): { pane: ManagedPane; terminal: FakeTermin
const terminal: FakeTerminal = {
lastData: [],
pendingCallbacks,
rows: 24,
buffer: {
active: {
baseY: 0,
viewportY: 0
}
},
_core: {
refresh() {}
},
write(data: string, cb?: () => void) {
terminal.lastData.push(data)
if (cb) {
@ -115,4 +135,37 @@ describe('replay-guard', () => {
terminal.flush()
expect(ref.current.has(1)).toBe(false)
})
it('schedules a follow-up repaint for replayed cursor restores', () => {
const scheduledFrames: FrameRequestCallback[] = []
const originalRequestAnimationFrame = globalThis.requestAnimationFrame
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame
globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
scheduledFrames.push(callback)
return scheduledFrames.length
}) as typeof requestAnimationFrame
globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame
try {
const ref = makeRef()
const { pane, terminal } = makeFakePane(1)
let refreshCount = 0
terminal._core.refresh = () => {
refreshCount += 1
}
replayIntoTerminal(pane, ref, '\x1b[?25h')
terminal.flush()
expect(refreshCount).toBe(1)
expect(scheduledFrames).toHaveLength(1)
scheduledFrames[0]?.(16)
expect(refreshCount).toBe(2)
} finally {
globalThis.requestAnimationFrame = originalRequestAnimationFrame
globalThis.cancelAnimationFrame = originalCancelAnimationFrame
}
})
})

View File

@ -1,4 +1,5 @@
import type { ManagedPane } from '@/lib/pane-manager/pane-manager'
import { writeForegroundTerminalChunk } from '@/lib/pane-manager/pane-terminal-foreground-render-settle'
// Why: xterm.js auto-responds to terminal query sequences (DA1 `CSI c`,
// DECRQM `CSI ? Ps $ p`, OSC 10/11 color queries, focus events, CPR) by
@ -44,13 +45,20 @@ export function replayIntoTerminal(
}
const map = replayingPanesRef.current
map.set(pane.id, (map.get(pane.id) ?? 0) + 1)
pane.terminal.write(data, () => {
const onParsed = (): void => {
const remaining = (map.get(pane.id) ?? 1) - 1
if (remaining <= 0) {
map.delete(pane.id)
} else {
map.set(pane.id, remaining)
}
}
// Why: hidden/snapshot replay bypasses the live foreground write path, but
// WebGL/canvas renderers still need a post-parse repaint to drop stale cells.
writeForegroundTerminalChunk(pane.terminal, data, {
forceViewportRefresh: true,
followupViewportRefresh: true,
onParsed
})
}
@ -65,14 +73,18 @@ export function replayIntoTerminalAsync(
const map = replayingPanesRef.current
map.set(pane.id, (map.get(pane.id) ?? 0) + 1)
return new Promise((resolve) => {
pane.terminal.write(data, () => {
const remaining = (map.get(pane.id) ?? 1) - 1
if (remaining <= 0) {
map.delete(pane.id)
} else {
map.set(pane.id, remaining)
writeForegroundTerminalChunk(pane.terminal, data, {
forceViewportRefresh: true,
followupViewportRefresh: true,
onParsed: () => {
const remaining = (map.get(pane.id) ?? 1) - 1
if (remaining <= 0) {
map.delete(pane.id)
} else {
map.set(pane.id, remaining)
}
resolve()
}
resolve()
})
})
}

View File

@ -226,7 +226,7 @@ export function applyTerminalAppearance(
// bleeding in from a prior opacity setting that has since been reset.
pane.terminal.options.allowTransparency =
settings.terminalBackgroundOpacity !== undefined && settings.terminalBackgroundOpacity < 1
const cursorStyle = settings.terminalCursorStyle ?? 'bar'
const cursorStyle = settings.terminalCursorStyle ?? 'block'
pane.terminal.options.cursorStyle = cursorStyle
pane.terminal.options.cursorInactiveStyle = resolveTerminalCursorInactiveStyle(cursorStyle)
pane.terminal.options.cursorBlink = settings.terminalCursorBlink

View File

@ -12,7 +12,6 @@ const mocks = vi.hoisted(() => ({
getTerminalOutputEpoch: vi.fn(() => 0),
handleTerminalFileDrop: vi.fn(),
requestTerminalBacklogRecovery: vi.fn(),
setActiveTerminalOutputTarget: vi.fn(),
restoreScrollState: vi.fn(),
restoreScrollStateAfterLayout: vi.fn()
}))
@ -57,8 +56,7 @@ vi.mock('./pane-helpers', () => ({
vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({
flushTerminalOutput: mocks.flushTerminalOutput,
requestTerminalBacklogRecovery: mocks.requestTerminalBacklogRecovery,
setActiveTerminalOutputTarget: mocks.setActiveTerminalOutputTarget
requestTerminalBacklogRecovery: mocks.requestTerminalBacklogRecovery
}))
vi.mock('@/lib/pane-manager/pane-scroll', () => ({
@ -230,12 +228,11 @@ describe('useTerminalPaneGlobalEffects', () => {
})
it('reports the active local PTY to the main output scheduler', () => {
const terminal = { name: 'terminal-a' }
const manager = {
getPanes: vi.fn(() => [{ id: 1, terminal }]),
getPanes: vi.fn(() => [{ id: 1, terminal: { name: 'terminal-a' } }]),
resumeRendering: vi.fn(),
suspendRendering: vi.fn(),
getActivePane: vi.fn(() => ({ id: 1, terminal }))
getActivePane: vi.fn(() => ({ id: 1, terminal: { name: 'terminal-a' } }))
}
const transport = { getPtyId: vi.fn(() => 'pty-active') }
const paneTransports = new Map([[1, transport]])
@ -257,7 +254,6 @@ describe('useTerminalPaneGlobalEffects', () => {
})
expect(window.api.pty.setActiveRendererPty).toHaveBeenCalledWith('pty-active', true)
expect(mocks.setActiveTerminalOutputTarget).toHaveBeenCalledWith(terminal, true)
})
it('restores from the pre-hide scroll state when hidden layout changes the viewport', () => {

View File

@ -12,8 +12,7 @@ import type { PtyTransport } from './pty-transport'
import { handleTerminalFileDrop } from './terminal-drop-handler'
import {
flushTerminalOutput,
requestTerminalBacklogRecovery,
setActiveTerminalOutputTarget
requestTerminalBacklogRecovery
} from '@/lib/pane-manager/pane-terminal-output-scheduler'
import { handleFocusTerminalPaneDetail } from './focus-terminal-pane-event'
import { surfaceStaleAgentRow } from './stale-agent-row'
@ -138,32 +137,17 @@ export function useTerminalPaneGlobalEffects({
useEffect(() => {
const manager = managerRef.current
const syncActiveOutputTargets = (activePaneId: number | null): void => {
for (const pane of manager?.getPanes() ?? []) {
setActiveTerminalOutputTarget(pane.terminal, pane.id === activePaneId)
}
for (const [paneId, transport] of paneTransportsRef.current) {
const ptyId = transport.getPtyId()
if (!ptyId || ptyId.startsWith('remote:')) {
continue
}
window.api.pty.setActiveRendererPty?.(ptyId, paneId === activePaneId)
}
}
if (!isActive || !isVisible || !manager) {
syncActiveOutputTargets(null)
const activePane = isActive && isVisible ? manager?.getActivePane() : null
const ptyId = activePane
? (paneTransportsRef.current.get(activePane.id)?.getPtyId() ?? null)
: null
if (!ptyId || ptyId.startsWith('remote:')) {
return
}
const activePane = manager.getActivePane()
const activePaneId = activePane?.id ?? null
// Why: active output hints must clear every pane when a tab hides; split
// focus can change after this effect's active-pane snapshot.
syncActiveOutputTargets(activePaneId)
return () => {
syncActiveOutputTargets(null)
}
// Why: main uses this as a scheduler hint only, so the foreground pane's
// renderer output gets first chance at the bounded ACK reserve.
window.api.pty.setActiveRendererPty?.(ptyId, true)
return () => window.api.pty.setActiveRendererPty?.(ptyId, false)
}, [isActive, isVisible, managerRef, paneTransportsRef])
useEffect(() => {

View File

@ -1,31 +1,10 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
setActiveTerminalOutputTarget: vi.fn()
}))
vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({
setActiveTerminalOutputTarget: mocks.setActiveTerminalOutputTarget
}))
import { describe, expect, it, vi } from 'vitest'
import {
reportActiveRendererPtyForPane,
shouldDetachPaneTransportOnUnmount,
splitPaneWithOneShotStartup,
suppressIntentionalPaneCloseExit
} from './use-terminal-pane-lifecycle'
beforeEach(() => {
vi.clearAllMocks()
;(globalThis as unknown as { window: unknown }).window = {
api: {
pty: {
setActiveRendererPty: vi.fn()
}
}
}
})
describe('splitPaneWithOneShotStartup', () => {
it('only exposes startup to the intentional split and clears it afterwards', () => {
const deps: { startup?: { command: string; env?: Record<string, string> } | null } = {
@ -167,52 +146,3 @@ describe('suppressIntentionalPaneCloseExit', () => {
expect(suppressPtyExit).not.toHaveBeenCalled()
})
})
describe('reportActiveRendererPtyForPane', () => {
it('marks the active visible pane as the renderer output target', () => {
const terminalA = { name: 'terminal-a' }
const terminalB = { name: 'terminal-b' }
const manager = {
getPanes: vi.fn(() => [
{ id: 1, terminal: terminalA },
{ id: 2, terminal: terminalB }
])
}
const paneTransports = new Map([
[1, { getPtyId: vi.fn(() => 'pty-1') }],
[2, { getPtyId: vi.fn(() => 'remote:env@@pty-2') }]
])
reportActiveRendererPtyForPane(paneTransports as never, manager as never, 2, true)
expect(mocks.setActiveTerminalOutputTarget).toHaveBeenCalledWith(terminalA, false)
expect(mocks.setActiveTerminalOutputTarget).toHaveBeenCalledWith(terminalB, true)
expect(window.api.pty.setActiveRendererPty).toHaveBeenCalledWith('pty-1', false)
expect(window.api.pty.setActiveRendererPty).not.toHaveBeenCalledWith(
'remote:env@@pty-2',
expect.anything()
)
})
it('clears every renderer output target while hidden or inactive', () => {
const terminalA = { name: 'terminal-a' }
const terminalB = { name: 'terminal-b' }
const manager = {
getPanes: vi.fn(() => [
{ id: 1, terminal: terminalA },
{ id: 2, terminal: terminalB }
])
}
const paneTransports = new Map([
[1, { getPtyId: vi.fn(() => 'pty-1') }],
[2, { getPtyId: vi.fn(() => 'pty-2') }]
])
reportActiveRendererPtyForPane(paneTransports as never, manager as never, 2, false)
expect(mocks.setActiveTerminalOutputTarget).toHaveBeenCalledWith(terminalA, false)
expect(mocks.setActiveTerminalOutputTarget).toHaveBeenCalledWith(terminalB, false)
expect(window.api.pty.setActiveRendererPty).toHaveBeenCalledWith('pty-1', false)
expect(window.api.pty.setActiveRendererPty).toHaveBeenCalledWith('pty-2', false)
})
})

View File

@ -61,7 +61,6 @@ import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-str
import { getConnectionId } from '@/lib/connection-context'
import { isPaneReplaying, type ReplayingPanesRef } from './replay-guard'
import { fitAndFocusPanes, fitPanes } from './pane-helpers'
import { setActiveTerminalOutputTarget } from '@/lib/pane-manager/pane-terminal-output-scheduler'
import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
import { e2eConfig } from '@/lib/e2e-config'
import {
@ -94,22 +93,16 @@ function extractUncHost(value: string | undefined): string | null {
return match?.[1] || null
}
export function reportActiveRendererPtyForPane(
function reportActiveRendererPtyForPane(
paneTransports: Map<number, PtyTransport>,
manager: PaneManager | null,
activePaneId: number | null,
activeAllowed: boolean
activePaneId: number | null
): void {
const activeTargetPaneId = activeAllowed ? activePaneId : null
for (const pane of manager?.getPanes() ?? []) {
setActiveTerminalOutputTarget(pane.terminal, activeTargetPaneId === pane.id)
}
for (const [paneId, transport] of paneTransports) {
const ptyId = transport.getPtyId()
if (!ptyId || ptyId.startsWith('remote:')) {
continue
}
window.api.pty.setActiveRendererPty?.(ptyId, activeTargetPaneId === paneId)
window.api.pty.setActiveRendererPty?.(ptyId, activePaneId === paneId)
}
}
@ -829,9 +822,6 @@ export function useTerminalPaneLifecycle({
queueResizeAll(true)
},
onPaneClosed: (paneId, closedPane) => {
if (closedPane?.terminal) {
setActiveTerminalOutputTarget(closedPane.terminal, false)
}
const linkProviderDisposable = linkProviderDisposablesRef.current.get(paneId)
if (linkProviderDisposable) {
linkProviderDisposable.dispose()
@ -891,7 +881,6 @@ export function useTerminalPaneLifecycle({
mouseHideDisposablesRef.current.delete(paneId)
}
const transport = paneTransportsRef.current.get(paneId)
const closingPtyId = transport?.getPtyId() ?? null
const panePtyBinding = panePtyBindings.get(paneId)
if (panePtyBinding) {
panePtyBinding.dispose()
@ -914,9 +903,6 @@ export function useTerminalPaneLifecycle({
transport,
useAppStore.getState().suppressPtyExit
)
if (closingPtyId && !closingPtyId.startsWith('remote:')) {
window.api.pty.setActiveRendererPty?.(closingPtyId, false)
}
if (ptyId) {
// Why: user/CLI pane closes intentionally tear down this PTY after
// PaneManager has already promoted the sibling. Suppress that exit
@ -957,19 +943,12 @@ export function useTerminalPaneLifecycle({
// stay stuck on the closed pane's last title.
const newActivePane = managerRef.current?.getActivePane()
if (newActivePane) {
reportActiveRendererPtyForPane(
paneTransportsRef.current,
managerRef.current,
newActivePane.id,
isActiveRef.current && isVisibleRef.current
)
reportActiveRendererPtyForPane(paneTransportsRef.current, newActivePane.id)
const paneTitles = useAppStore.getState().runtimePaneTitlesByTabId[tabId] ?? {}
const activeTitle = paneTitles[newActivePane.id]
if (activeTitle) {
updateTabTitle(tabId, activeTitle)
}
} else {
reportActiveRendererPtyForPane(paneTransportsRef.current, managerRef.current, null, false)
}
scheduleRuntimeGraphSync()
},
@ -978,12 +957,7 @@ export function useTerminalPaneLifecycle({
if (shouldPersistLayout) {
persistLayoutSnapshot()
}
reportActiveRendererPtyForPane(
paneTransportsRef.current,
managerRef.current,
pane.id,
isActiveRef.current && isVisibleRef.current
)
reportActiveRendererPtyForPane(paneTransportsRef.current, pane.id)
// Why: when the user switches focus between split panes, update the
// tab title to the newly active pane's last-known title so the tab
// label reflects the focused agent — not a stale title from the
@ -1016,7 +990,7 @@ export function useTerminalPaneLifecycle({
terminalOptions: () => {
const currentSettings = settingsRef.current
const terminalFontWeights = resolveTerminalFontWeights(currentSettings?.terminalFontWeight)
const cursorStyle = currentSettings?.terminalCursorStyle ?? 'bar'
const cursorStyle = currentSettings?.terminalCursorStyle ?? 'block'
const storeState = useAppStore.getState()
const currentTab = storeState.tabsByWorktree[worktreeId]?.find(
(candidate) => candidate.id === tabId

View File

@ -0,0 +1,34 @@
export function attachDomRendererFocusClassSync(
terminalElement: HTMLElement | undefined
): () => void {
if (!terminalElement) {
return () => undefined
}
const sync = (): void => {
const rows = terminalElement.querySelector<HTMLElement>('.xterm-rows')
if (!rows) {
return
}
// Why: xterm 6 can leave the root focused while the DOM renderer rows miss
// xterm-focus; its cursor blink CSS keys off the rows class.
rows.classList.toggle('xterm-focus', terminalElement.classList.contains('focus'))
}
const scheduleSync = (): void => {
sync()
requestAnimationFrame(sync)
}
const observer = new MutationObserver(scheduleSync)
observer.observe(terminalElement, { attributes: true, attributeFilter: ['class'] })
terminalElement.addEventListener('focusin', scheduleSync)
terminalElement.addEventListener('focusout', scheduleSync)
scheduleSync()
return () => {
observer.disconnect()
terminalElement.removeEventListener('focusin', scheduleSync)
terminalElement.removeEventListener('focusout', scheduleSync)
}
}

View File

@ -69,8 +69,9 @@ describe('buildDefaultTerminalOptions', () => {
expect(buildDefaultTerminalOptions().macOptionIsMeta).toBe(false)
})
it('keeps the default inactive cursor as a single bar', () => {
expect(buildDefaultTerminalOptions().cursorInactiveStyle).toBe('bar')
it('uses the default inactive outline only for the block cursor', () => {
expect(buildDefaultTerminalOptions().cursorStyle).toBe('block')
expect(buildDefaultTerminalOptions().cursorInactiveStyle).toBe('outline')
})
it('only uses inactive outline for block cursors', () => {

View File

@ -24,6 +24,7 @@ import {
} from './pane-fit-resize-observer'
import { clearPendingSplitScrollRestore } from './pane-split-scroll'
import { buildDefaultTerminalOptions } from './pane-terminal-options'
import { attachDomRendererFocusClassSync } from './pane-dom-focus-class-sync'
import {
ENABLE_WEBGL_RENDERER,
attachWebgl,
@ -148,6 +149,7 @@ export function createPaneDOM(
paneMouseEnterHandler,
paneDragCleanup,
compositionHandler: null,
focusClassSyncCleanup: null,
pendingSplitScrollState: null,
pendingSplitScrollRafIds: [],
pendingSplitScrollTimerId: null,
@ -240,6 +242,8 @@ export function openTerminal(pane: ManagedPaneInternal): void {
pane.compositionHandler = handler
}
pane.focusClassSyncCleanup = attachDomRendererFocusClassSync(terminal.element)
if (pane.gpuRenderingEnabled) {
attachWebgl(pane)
}
@ -327,6 +331,8 @@ export function disposePane(
}
pane.paneDragCleanup?.()
pane.paneDragCleanup = null
pane.focusClassSyncCleanup?.()
pane.focusClassSyncCleanup = null
if (pane.compositionHandler) {
pane.terminal.element?.removeEventListener('compositionstart', pane.compositionHandler, true)
pane.compositionHandler = null

View File

@ -26,7 +26,6 @@ export type PaneSpawnHints = {
export type ClosedPaneInfo = {
paneId: number
leafId: TerminalLeafId
terminal?: Terminal
}
export type PaneManagerOptions = {
@ -120,6 +119,8 @@ export type ManagedPaneInternal = {
paneDragCleanup?: (() => void) | null
// Stored so disposePane() can remove it and avoid a memory leak.
compositionHandler: (() => void) | null
// Stored so disposePane() can remove DOM-renderer focus synchronization.
focusClassSyncCleanup?: (() => void) | null
// Why: splitPane reparents DOM; its delayed restore owns scroll until the
// browser settles, so intermediate fits must not compete with it.
pendingSplitScrollState: ScrollState | null

View File

@ -5,7 +5,6 @@ import type { TerminalLeafId } from '../../../../shared/stable-pane-id'
const captureScrollState = vi.hoisted(() => vi.fn())
const wrapInSplit = vi.hoisted(() => vi.fn())
const openTerminal = vi.hoisted(() => vi.fn())
const disposePane = vi.hoisted(() => vi.fn())
const disposeWebgl = vi.hoisted(() => vi.fn())
const clearPendingSplitScrollRestore = vi.hoisted(() => vi.fn())
const scheduleSplitScrollRestore = vi.hoisted(() => vi.fn())
@ -23,7 +22,7 @@ vi.mock('./pane-tree-ops', () => ({
}))
vi.mock('./pane-lifecycle', () => ({
disposePane,
disposePane: vi.fn(),
openTerminal
}))
@ -46,7 +45,6 @@ vi.mock('./pane-divider', () => ({
}))
import { splitManagedPane } from './pane-split-close'
import { closeManagedPane } from './pane-split-close'
const TEST_LEAF_ID = '11111111-1111-4111-8111-111111111111' as TerminalLeafId
@ -55,7 +53,6 @@ class MockElement {
dataset: Record<string, string> = {}
parentElement: MockElement | null = null
style: Record<string, string> = {}
remove = vi.fn()
private descendants: MockElement[] = []
constructor(private readonly classNames: string[]) {
@ -119,11 +116,6 @@ function createPane(id: number, webglAddon: unknown): ManagedPaneInternal {
describe('splitManagedPane', () => {
beforeEach(() => {
vi.clearAllMocks()
disposePane.mockImplementation(
(pane: ManagedPaneInternal, panes: Map<number, ManagedPaneInternal>) => {
panes.delete(pane.id)
}
)
})
it('prepares every pane under a moved mounted subtree for split reparenting', () => {
@ -201,44 +193,3 @@ describe('splitManagedPane', () => {
)
})
})
describe('closeManagedPane', () => {
beforeEach(() => {
vi.clearAllMocks()
disposePane.mockImplementation(
(pane: ManagedPaneInternal, panes: Map<number, ManagedPaneInternal>) => {
panes.delete(pane.id)
}
)
})
it('reports the closed terminal in the close callback', () => {
const closedPane = createPane(1, null)
const siblingPane = createPane(2, null)
const root = new MockElement(['root'])
;(closedPane.container as unknown as MockElement).parentElement = root
const panes = new Map<number, ManagedPaneInternal>([
[closedPane.id, closedPane],
[siblingPane.id, siblingPane]
])
const onPaneClosed = vi.fn()
closeManagedPane({
paneId: closedPane.id,
activePaneId: closedPane.id,
panes,
root: root as unknown as HTMLElement,
styleOptions: {},
managerOptions: { onPaneClosed },
getDragCallbacks: () => ({}) as never,
releasePaneIdentity: vi.fn(),
setActivePaneId: vi.fn()
})
expect(onPaneClosed).toHaveBeenCalledWith(closedPane.id, {
paneId: closedPane.id,
leafId: closedPane.leafId,
terminal: closedPane.terminal
})
})
})

View File

@ -180,11 +180,7 @@ export function closeManagedPane(args: CloseManagedPaneArgs): void {
safeFit(p)
}
updateMultiPaneState(args.getDragCallbacks())
args.managerOptions.onPaneClosed?.(args.paneId, {
paneId: args.paneId,
leafId: closedLeafId,
terminal: pane.terminal
})
args.managerOptions.onPaneClosed?.(args.paneId, { paneId: args.paneId, leafId: closedLeafId })
args.managerOptions.onLayoutChanged?.()
}

View File

@ -17,6 +17,7 @@ export type ForegroundTerminalOutputTarget = {
type ForegroundTerminalWriteOptions = {
forceViewportRefresh?: boolean
followupViewportRefresh?: boolean
onParsed?: () => void
}
const pendingViewportSettleRefreshByTerminal = new WeakMap<
@ -135,11 +136,13 @@ export function writeForegroundTerminalChunk(
if (beforeWriteViewport) {
settleForegroundRender(terminal, beforeWriteViewport, options)
}
options.onParsed?.()
})
} catch {
if (beforeWriteViewport) {
settleForegroundRender(terminal, beforeWriteViewport, options)
}
options.onParsed?.()
}
}

View File

@ -8,11 +8,11 @@ export function resolveTerminalCursorInactiveStyle(
): TerminalCursorInactiveStyle {
// Why: xterm's default inactive outline turns a bar/underline cursor into
// extra strokes in blurred panes; only block cursors benefit from outline.
return (cursorStyle ?? 'bar') === 'block' ? 'outline' : (cursorStyle ?? 'bar')
return (cursorStyle ?? 'block') === 'block' ? 'outline' : (cursorStyle ?? 'block')
}
export function buildDefaultTerminalOptions(): ITerminalOptions {
const cursorStyle: TerminalCursorStyle = 'bar'
const cursorStyle: TerminalCursorStyle = 'block'
return {
allowProposedApi: true,

View File

@ -612,45 +612,6 @@ describe('pane terminal output scheduler', () => {
expect(terminals[2].write).toHaveBeenCalledWith('pane-2')
})
it('drains the active terminal before older queued background terminals', async () => {
vi.useFakeTimers()
const { setActiveTerminalOutputTarget, writeTerminalOutput } = await loadScheduler()
const terminals = [createTerminal(), createTerminal(), createTerminal()]
terminals.forEach((terminal, index) => {
writeTerminalOutput(terminal, `pane-${index}`, { foreground: false })
})
setActiveTerminalOutputTarget(terminals[2], true)
vi.advanceTimersByTime(50)
expect(terminals[2].write).toHaveBeenCalledWith('pane-2')
expect(terminals[0].write).toHaveBeenCalledWith('pane-0')
expect(terminals[1].write).not.toHaveBeenCalled()
setActiveTerminalOutputTarget(terminals[2], false)
})
it('still drains the active terminal first with one hundred queued terminals', async () => {
vi.useFakeTimers()
const { setActiveTerminalOutputTarget, writeTerminalOutput } = await loadScheduler()
const terminals = Array.from({ length: 100 }, () => createTerminal())
const activeTerminal = terminals[99]
terminals.forEach((terminal, index) => {
writeTerminalOutput(terminal, `pane-${index}`, { foreground: false })
})
setActiveTerminalOutputTarget(activeTerminal, true)
vi.advanceTimersByTime(50)
expect(activeTerminal.write).toHaveBeenCalledWith('pane-99')
expect(terminals[0].write).toHaveBeenCalledWith('pane-0')
expect(activeTerminal.write.mock.invocationCallOrder[0]).toBeLessThan(
terminals[0].write.mock.invocationCallOrder[0]
)
})
it('rotates terminals with remaining backlog behind untouched queued terminals', async () => {
vi.useFakeTimers()
const { writeTerminalOutput } = await loadScheduler()
@ -879,26 +840,6 @@ describe('pane terminal output scheduler', () => {
expect(terminal.write).not.toHaveBeenCalled()
})
it('clears active priority when terminal output is discarded', async () => {
vi.useFakeTimers()
const { discardTerminalOutput, setActiveTerminalOutputTarget, writeTerminalOutput } =
await loadScheduler()
const terminalA = createTerminal()
const terminalB = createTerminal()
setActiveTerminalOutputTarget(terminalB, true)
discardTerminalOutput(terminalB)
writeTerminalOutput(terminalA, 'new-a', { foreground: false })
writeTerminalOutput(terminalB, 'new-b', { foreground: false })
vi.advanceTimersByTime(50)
expect(terminalB.write).toHaveBeenCalledWith('new-b')
expect(terminalA.write).toHaveBeenCalledWith('new-a')
expect(terminalA.write.mock.invocationCallOrder[0]).toBeLessThan(
terminalB.write.mock.invocationCallOrder[0]
)
})
it('survives a write to a disposed terminal during background drain', async () => {
vi.useFakeTimers()
const { writeTerminalOutput } = await loadScheduler()

View File

@ -84,7 +84,6 @@ const BACKGROUND_BACKLOG_WARNING =
'\x18\x1b[0m\r\n[Orca skipped hidden terminal output because the backlog exceeded 2 MB.]\r\n'
const queuedByTerminal = new Map<TerminalOutputTarget, QueueEntry>()
const activeOutputTargets = new WeakSet<TerminalOutputTarget>()
const backlogRecoveryByTerminal = new WeakMap<
TerminalOutputTarget,
TerminalBacklogRecoveryRequest
@ -526,13 +525,6 @@ function hasDrainableBacklog(): boolean {
}
function takeNextDrainableEntry(): QueueEntry | null {
for (const entry of queuedByTerminal.values()) {
if (!activeOutputTargets.has(entry.terminal) || !isEntryDrainable(entry)) {
continue
}
queuedByTerminal.delete(entry.terminal)
return entry
}
for (const entry of queuedByTerminal.values()) {
if (!isEntryDrainable(entry)) {
continue
@ -624,17 +616,6 @@ function drainQueuedOutput(): void {
}
}
export function setActiveTerminalOutputTarget(
terminal: TerminalOutputTarget,
active: boolean
): void {
if (active) {
activeOutputTargets.add(terminal)
} else {
activeOutputTargets.delete(terminal)
}
}
export function writeTerminalOutput(
terminal: TerminalOutputTarget,
data: string,
@ -931,7 +912,6 @@ export function waitForTerminalOutputParsed(terminal: TerminalOutputTarget): Pro
export function discardTerminalOutput(terminal: TerminalOutputTarget): void {
exposeDebugApi()
queuedByTerminal.delete(terminal)
activeOutputTargets.delete(terminal)
discardForegroundRenderSettle(terminal)
recordQueueDebugPressure()
}

View File

@ -231,6 +231,41 @@ describe('web settings preload API', () => {
expect(stored.autoRenameBranchFromWorkDefaultedOn).toBe(true)
})
it('migrates inherited terminal bar cursor defaults for stored web settings once', async () => {
const globals = installBrowserGlobals('Linux')
globals.storage.setItem('orca.web.settings.v1', JSON.stringify({ terminalCursorStyle: 'bar' }))
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
const settings = await globals.window.api.settings.get()
const stored = JSON.parse(globals.storage.getItem('orca.web.settings.v1') ?? '{}') as {
terminalCursorStyle?: string
terminalCursorStyleDefaultedToBlock?: boolean
}
expect(settings.terminalCursorStyle).toBe('block')
expect(settings.terminalCursorStyleDefaultedToBlock).toBe(true)
expect(stored.terminalCursorStyle).toBe('block')
expect(stored.terminalCursorStyleDefaultedToBlock).toBe(true)
})
it('preserves terminal cursor choices after the web block-default migration', async () => {
const globals = installBrowserGlobals('Linux')
globals.storage.setItem(
'orca.web.settings.v1',
JSON.stringify({
terminalCursorStyle: 'bar',
terminalCursorStyleDefaultedToBlock: true
})
)
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
const settings = await globals.window.api.settings.get()
expect(settings.terminalCursorStyle).toBe('bar')
expect(settings.terminalCursorStyleDefaultedToBlock).toBe(true)
})
it('preserves first-work branch auto-rename web opt-outs after migration', async () => {
const globals = installBrowserGlobals('Linux')
globals.storage.setItem(

View File

@ -39,6 +39,7 @@ import { relativePathInsideRoot } from '../../../shared/cross-platform-path'
import { toRuntimeWorktreeSelector } from '../runtime/runtime-worktree-selector'
import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection'
import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../shared/auto-rename-branch-from-work-settings'
import { normalizeTerminalCursorStyleDefault } from '../../../shared/terminal-cursor-style-settings'
import type { RateLimitState } from '../../../shared/rate-limit-types'
import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../../../shared/runtime-types'
import {
@ -2413,13 +2414,17 @@ function getStoredSettings(): GlobalSettings {
const stored = readJson<Partial<GlobalSettings>>(SETTINGS_STORAGE_KEY, {})
const migratedStored = {
...stored,
...normalizeAutoRenameBranchFromWorkDefaultOn(stored)
...normalizeAutoRenameBranchFromWorkDefaultOn(stored),
...normalizeTerminalCursorStyleDefault(stored)
}
if (
rawStoredSettings &&
(stored.autoRenameBranchFromWork !== migratedStored.autoRenameBranchFromWork ||
stored.autoRenameBranchFromWorkDefaultedOn !==
migratedStored.autoRenameBranchFromWorkDefaultedOn)
migratedStored.autoRenameBranchFromWorkDefaultedOn ||
stored.terminalCursorStyle !== migratedStored.terminalCursorStyle ||
stored.terminalCursorStyleDefaultedToBlock !==
migratedStored.terminalCursorStyleDefaultedToBlock)
) {
try {
const parsed = JSON.parse(rawStoredSettings) as unknown

View File

@ -22,6 +22,11 @@ describe('getDefaultSettings', () => {
expect(getDefaultSettings('/tmp').autoRenameBranchFromWorkDefaultedOn).toBe(true)
})
it('uses a block terminal cursor by default for new settings', () => {
expect(getDefaultSettings('/tmp').terminalCursorStyle).toBe('block')
expect(getDefaultSettings('/tmp').terminalCursorStyleDefaultedToBlock).toBe(true)
})
it('enables separate light terminal theme by default', () => {
expect(getDefaultSettings('/tmp').terminalUseSeparateLightTheme).toBe(true)
})

View File

@ -200,7 +200,8 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
// that lacks ligatures or if they've explicitly opted out. The resolver
// is in shared/terminal-ligatures.ts.
terminalLigatures: 'auto',
terminalCursorStyle: 'bar',
terminalCursorStyle: 'block',
terminalCursorStyleDefaultedToBlock: true,
terminalCursorBlink: true,
terminalThemeDark: 'Ghostty Default Style Dark',
terminalDividerColorDark: '#3f3f46',

View File

@ -0,0 +1,21 @@
import type { GlobalSettings } from './types'
type TerminalCursorStyleSettings = Pick<
GlobalSettings,
'terminalCursorStyle' | 'terminalCursorStyleDefaultedToBlock'
>
export function normalizeTerminalCursorStyleDefault(
settings: Partial<TerminalCursorStyleSettings> | undefined,
options: { preserveExplicitValue?: boolean } = {}
): TerminalCursorStyleSettings {
const defaultedToBlock =
settings?.terminalCursorStyleDefaultedToBlock === true || options.preserveExplicitValue === true
return {
// Why: prior builds persisted the old bar default into profiles; migrate
// those inherited values once while preserving later explicit choices.
terminalCursorStyle: defaultedToBlock ? (settings?.terminalCursorStyle ?? 'block') : 'block',
terminalCursorStyleDefaultedToBlock: true
}
}

View File

@ -2008,6 +2008,8 @@ export type GlobalSettings = {
* switches fonts, so "off" always stays off. */
terminalLigatures: 'auto' | 'on' | 'off'
terminalCursorStyle: 'bar' | 'block' | 'underline'
/** One-shot migration guard for moving inherited cursor defaults to block. */
terminalCursorStyleDefaultedToBlock?: boolean
terminalCursorBlink: boolean
terminalThemeDark: string
terminalDividerColorDark: string

View File

@ -192,8 +192,8 @@ export async function runHiddenRealPtyPressureScenario<
ackGate
)
expect(debug?.hiddenRendererSkipCount ?? 0).toBeGreaterThan(0)
expect(debug?.hiddenRendererSkippedChars ?? 0).toBeGreaterThan(0)
expect(debug?.hiddenRendererSkipCount ?? 0).toBe(0)
expect(debug?.hiddenRendererSkippedChars ?? 0).toBe(0)
expect(pressureBeforeTyping.peakPendingChars).toBeGreaterThan(0)
expect(pressureBeforeTyping.ackGatedFlushSkipCount).toBeGreaterThan(0)
expect(mainPressure?.peakRendererInFlightChars ?? 0).toBeGreaterThanOrEqual(8 * 1024 * 1024)

View File

@ -95,6 +95,7 @@ export async function startSyntheticOpenCodeInjection({
`\x1b[1;2H\x1b[38;2;255;138;0m${spinner} OpenCode synthetic agent ${paneIndex}\x1b[0m`,
`\x1b[${row};4H\x1b[38;2;231;237;247m${body.padEnd(118, '#')}\x1b[0m`,
`\x1b[23;2H\x1b[38;2;106;169;255mstream ${String(frame).padStart(4, '0')} ${'#'.repeat(96)}\x1b[0m`,
'\x1b[?25h',
'\x1b[?2026l'
].join('')
}

View File

@ -491,8 +491,8 @@ async function measureCrossWorkspaceTypingDuringHiddenLoad({
scheduler,
mainPressure
)
expect(debug?.hiddenRendererSkipCount ?? 0).toBeGreaterThan(0)
expect(debug?.hiddenRendererSkippedChars ?? 0).toBeGreaterThan(0)
expect(debug?.hiddenRendererSkipCount ?? 0).toBe(0)
expect(debug?.hiddenRendererSkippedChars ?? 0).toBe(0)
expect(measurement.medianLatencyMs).toBeLessThan(MAX_MEDIAN_KEY_LATENCY_MS)
expect(measurement.worstLatencyMs).toBeLessThan(MAX_WORST_KEY_LATENCY_MS)
expect(measurement.maxTimerDriftMs).toBeLessThan(MAX_TIMER_DRIFT_MS)

View File

@ -0,0 +1,232 @@
import type { Page } from '@stablyai/playwright-test'
import { randomUUID } from 'node:crypto'
import path from 'node:path'
import { test, expect } from './helpers/orca-app'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import {
getTerminalContent,
sendToTerminal,
waitForActivePanePtyId,
waitForActiveTerminalManager
} from './helpers/terminal'
import {
analyzeRasterCursorCells,
type TerminalRasterProbeTarget
} from './terminal-cursor-raster-probe'
const CODEX_READY_RE = /Ask Codex|OpenAI/i
const CODEX_TRUST_PROMPT_RE = /Do you trust|trust this folder|Trust this/i
const CODEX_UPDATE_PROMPT_RE = /update available|install update|Skip for now/i
const MAX_MEDIAN_KEY_LATENCY_MS = 150
const MAX_WORST_KEY_LATENCY_MS = 500
type CodexCursorBlinkSample = {
elapsedMs: number
paintedCursorCellCount: number
}
async function focusActiveTerminalInput(page: Page): Promise<void> {
await page.evaluate(() => {
const state = window.__store?.getState()
const worktreeId = state?.activeWorktreeId
const tabId =
state?.activeTabType === 'terminal'
? state.activeTabId
: worktreeId
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
: null
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
const textarea = pane?.container.querySelector<HTMLTextAreaElement>('.xterm-helper-textarea')
if (!pane || !textarea) {
throw new Error('Active terminal input is unavailable')
}
pane.terminal.focus()
textarea.focus()
})
}
async function forceCursorProbeTheme(page: Page): Promise<void> {
await page.evaluate(() => {
const state = window.__store?.getState()
const worktreeId = state?.activeWorktreeId
const tabId =
state?.activeTabType === 'terminal'
? state.activeTabId
: worktreeId
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
: null
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
if (!pane) {
throw new Error('Active terminal pane is unavailable')
}
pane.terminal.options.cursorStyle = 'block'
pane.terminal.options.cursorBlink = true
pane.terminal.options.theme = {
...pane.terminal.options.theme,
cursor: '#23ff45',
cursorAccent: '#001000'
}
pane.terminal.focus()
pane.terminal.refresh(0, pane.terminal.rows - 1)
})
}
async function readActiveTerminalRasterTarget(page: Page): Promise<TerminalRasterProbeTarget> {
return page.evaluate(() => {
const state = window.__store?.getState()
const worktreeId = state?.activeWorktreeId
const tabId =
state?.activeTabType === 'terminal'
? state.activeTabId
: worktreeId
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
: null
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
const screen = pane?.container.querySelector<HTMLElement>('.xterm-screen')
const dimensions = pane?.terminal._core?._renderService?.dimensions?.css?.cell
if (!pane || !screen || !dimensions) {
throw new Error('Active terminal screen is unavailable')
}
const rect = screen.getBoundingClientRect()
return {
clip: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
cellWidth: dimensions.width,
cellHeight: dimensions.height,
rows: pane.terminal.rows,
cols: pane.terminal.cols
}
})
}
async function sampleCursorBlink(page: Page): Promise<CodexCursorBlinkSample[]> {
const samples: CodexCursorBlinkSample[] = []
const target = await readActiveTerminalRasterTarget(page)
const viewport = page.viewportSize() ?? undefined
const start = performance.now()
for (let index = 0; index < 9; index += 1) {
if (index > 0) {
await page.waitForTimeout(200)
}
const screenshot = await page.screenshot()
const cells = analyzeRasterCursorCells(Buffer.from(screenshot), target, viewport)
samples.push({
elapsedMs: performance.now() - start,
paintedCursorCellCount: cells.length
})
}
return samples
}
async function dismissCodexPromptsIfPresent(page: Page): Promise<void> {
const deadline = Date.now() + 15_000
while (Date.now() < deadline) {
const content = await getTerminalContent(page, 12_000)
if (CODEX_READY_RE.test(content) && !CODEX_TRUST_PROMPT_RE.test(content)) {
return
}
if (CODEX_TRUST_PROMPT_RE.test(content)) {
await page.keyboard.press('Enter')
await page.waitForTimeout(300)
continue
}
if (CODEX_UPDATE_PROMPT_RE.test(content)) {
await page.keyboard.type('3')
await page.keyboard.press('Enter')
await page.waitForTimeout(300)
continue
}
await page.waitForTimeout(250)
}
}
async function waitForCodexReady(page: Page): Promise<void> {
await expect
.poll(async () => CODEX_READY_RE.test(await getTerminalContent(page, 12_000)), {
timeout: 45_000,
message: 'Codex TUI did not render'
})
.toBe(true)
}
async function waitForPromptText(page: Page, text: string): Promise<number> {
const start = performance.now()
while (performance.now() - start < MAX_WORST_KEY_LATENCY_MS) {
if ((await getTerminalContent(page, 12_000)).includes(text)) {
return performance.now() - start
}
await page.waitForTimeout(5)
}
throw new Error(`Codex prompt did not show ${text}`)
}
function median(values: number[]): number {
const sorted = [...values].sort((a, b) => a - b)
return sorted[Math.floor(sorted.length / 2)] ?? 0
}
test.describe('local Codex terminal typing latency', () => {
test('keeps Codex prompt typing responsive @local-real-codex', async ({ orcaPage }, testInfo) => {
test.skip(
process.env.ORCA_E2E_REAL_CODEX !== '1',
'Set ORCA_E2E_REAL_CODEX=1 to exercise the locally installed Codex TUI'
)
test.skip(process.platform === 'win32', 'local Codex command is POSIX-shell oriented')
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const ptyId = await waitForActivePanePtyId(orcaPage)
const codexSource = path.join(process.env.HOME ?? '', 'projects', 'codex')
const launchCommand =
`cd ${JSON.stringify(codexSource)} && ` +
'codex --dangerously-bypass-approvals-and-sandbox --dangerously-bypass-hook-trust\r'
try {
await sendToTerminal(orcaPage, ptyId, launchCommand)
await dismissCodexPromptsIfPresent(orcaPage)
await waitForCodexReady(orcaPage)
await focusActiveTerminalInput(orcaPage)
await forceCursorProbeTheme(orcaPage)
const blinkSamples = await sampleCursorBlink(orcaPage)
const runId = randomUUID().replaceAll('-', '').slice(0, 8)
const prompt = `orca_codex_latency_${runId}`
const latencies: number[] = []
let typed = ''
for (const char of prompt) {
typed += char
const start = performance.now()
await orcaPage.keyboard.type(char)
await waitForPromptText(orcaPage, typed)
latencies.push(performance.now() - start)
}
const medianLatency = median(latencies)
const worstLatency = Math.max(...latencies)
testInfo.annotations.push({
type: 'codex-local-typing-latency',
description: `median=${medianLatency.toFixed(1)}ms worst=${worstLatency.toFixed(
1
)}ms samples=${latencies.map((value) => value.toFixed(1)).join(',')}`
})
testInfo.annotations.push({
type: 'codex-local-cursor-blink',
description: blinkSamples
.map((sample) => `${sample.elapsedMs.toFixed(0)}ms:${sample.paintedCursorCellCount}`)
.join(',')
})
expect(blinkSamples.some((sample) => sample.paintedCursorCellCount > 0)).toBe(true)
expect(blinkSamples.some((sample) => sample.paintedCursorCellCount === 0)).toBe(true)
expect(medianLatency).toBeLessThan(MAX_MEDIAN_KEY_LATENCY_MS)
expect(worstLatency).toBeLessThan(MAX_WORST_KEY_LATENCY_MS)
} finally {
await sendToTerminal(orcaPage, ptyId, '\x03').catch(() => undefined)
}
})
})

View File

@ -108,21 +108,20 @@ test.describe('Terminal inactive cursor rendering', () => {
await waitForPaneCount(orcaPage, 1, 30_000)
})
test('keeps an unfocused prompt cursor rendered as one bar', async ({ orcaPage }) => {
test('keeps an unfocused prompt cursor rendered as one block outline', async ({ orcaPage }) => {
await splitActiveTerminalPane(orcaPage, 'vertical')
await waitForPaneCount(orcaPage, 2)
await placeInactiveCursorAtPrompt(orcaPage)
const fixedBehavior = await renderInactiveCursor(orcaPage)
expect(fixedBehavior.terminalFocused).toBe(false)
expect(fixedBehavior.cursorStyle).toBe('bar')
expect(fixedBehavior.cursorInactiveStyle).toBe('bar')
expect(fixedBehavior.cursorClassName).toContain('bar')
expect(fixedBehavior.cursorClassName).not.toContain('xterm-cursor-outline')
expect(fixedBehavior.cursorStyle).toBe('block')
expect(fixedBehavior.cursorInactiveStyle).toBe('outline')
expect(fixedBehavior.cursorClassName).toContain('xterm-cursor-outline')
const oldBehavior = await renderInactiveCursor(orcaPage, 'outline')
expect(oldBehavior.terminalFocused).toBe(false)
expect(oldBehavior.cursorStyle).toBe('bar')
expect(oldBehavior.cursorStyle).toBe('block')
expect(oldBehavior.cursorInactiveStyle).toBe('outline')
})
})

View File

@ -30,14 +30,6 @@ type HiddenTuiWindow = Window & {
hiddenRendererMode2031ReplyCount: number
}
}
__terminalHiddenSnapshotOverride?: {
setPending: (
ptyId: string,
snapshot: { data: string; cols: number; rows: number; seq: number; source: 'headless' }
) => void
resolve: (ptyId: string) => void
clear: (ptyId: string) => void
}
}
type HiddenTuiDebugSnapshot = {
@ -46,24 +38,46 @@ type HiddenTuiDebugSnapshot = {
hiddenRendererMode2031ReplyCount: number
}
type TuiCursorState = {
hidden: boolean | null
initialized: boolean | null
cursorElementVisible: boolean
cursorCanvasPresent: boolean
}
function tuiFrame(runId: string, frame: number): string {
const progress = `${'█'.repeat((frame % 8) + 1)}${'░'.repeat(8 - ((frame % 8) + 1))}`
const rows = [
`OpenCode visual restore ${runId}`,
`Frame ${String(frame).padStart(3, '0')}`,
`Status ${frame % 2 === 0 ? 'thinking' : 'streaming'}`,
`Input echo ${'#'.repeat((frame % 18) + 1)}`,
`Diff +${frame * 3} -${frame}`,
'╭────────────────────────────────────────────────────────────────────╮',
`│ OpenCode visual restore Frame ${String(frame).padStart(3, '0')} ${frame % 2 === 0 ? '🟢' : '🟡'} ${progress}`,
'├──────────────┬──────────────────────┬──────────────────────────────┤',
`│ model │ codex/opencode │ ${runId.slice(0, 28).padEnd(28)}`,
`│ status │ ${frame % 2 === 0 ? 'thinking' : 'streaming'} │ input ${'#'.repeat((frame % 18) + 1).padEnd(22)}`,
`│ diff │ +${String(frame * 3).padEnd(19)} │ -${String(frame).padEnd(27)}`,
'╰──────────────┴──────────────────────┴──────────────────────────────╯',
`VISUAL_RESTORE_FINAL_${runId}_${frame}`
]
return [
'\x1b[?2026h',
'\x1b[?1049h',
'\x1b[2J\x1b[H',
'\x1b[?25l',
rows.map((row) => `\x1b[2;36m${row}\x1b[0m`).join('\r\n'),
'\x1b[10;18H\x1b[?25h',
'\x1b[?2026l'
].join('')
}
function lowRiskRestoreFrame(runId: string, frame: number): string {
const rows = [
`LOW_RISK_RESTORE_FRAME_${runId}_${frame}`,
`status=${frame % 2 === 0 ? 'thinking' : 'streaming'}`,
`progress=${String(frame).padStart(3, '0')}`,
`VISUAL_RESTORE_FINAL_${runId}_${frame}`
]
return `${rows.join('\r\n')}\r\n`
}
async function resetHiddenDebug(page: Page): Promise<void> {
await page.evaluate(() => {
;(window as HiddenTuiWindow).__terminalPtyOutputDebug?.reset()
@ -85,6 +99,48 @@ async function readHiddenDebug(page: Page): Promise<HiddenTuiDebugSnapshot | nul
})
}
async function readTuiCursorState(page: Page): Promise<TuiCursorState> {
return page.evaluate(() => {
const store = window.__store
const state = store?.getState()
const worktreeId = state?.activeWorktreeId
const tabId =
state?.activeTabType === 'terminal'
? state.activeTabId
: worktreeId
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
: null
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
if (!pane) {
throw new Error('Active terminal pane is unavailable')
}
const terminalCore = (
pane.terminal as unknown as {
_core?: { coreService?: { isCursorHidden?: boolean; isCursorInitialized?: boolean } }
}
)._core
const cursorElement = pane.container.querySelector<HTMLElement>('.xterm-cursor')
const cursorRect = cursorElement?.getBoundingClientRect()
const cursorStyle = cursorElement ? window.getComputedStyle(cursorElement) : null
return {
hidden: terminalCore?.coreService?.isCursorHidden ?? null,
initialized: terminalCore?.coreService?.isCursorInitialized ?? null,
// Why: a blinking DOM cursor may be transparent during the sampled frame;
// disappearance regressions remove the laid-out cursor element/layer.
cursorElementVisible:
!!cursorElement &&
!!cursorRect &&
cursorRect.width > 0 &&
cursorRect.height > 0 &&
cursorStyle?.display !== 'none' &&
cursorStyle?.visibility !== 'hidden',
cursorCanvasPresent:
pane.container.querySelector<HTMLCanvasElement>('.xterm-cursor-layer canvas') !== null
}
})
}
async function injectPaneData(
page: Page,
paneKey: string,
@ -102,31 +158,6 @@ async function injectPaneData(
}
}
async function installDelayedMainSnapshot(
page: Page,
ptyId: string,
snapshot: { data: string; cols: number; rows: number; seq: number; source: 'headless' }
): Promise<void> {
await page.evaluate(
({ ptyId, snapshot }) => {
;(window as HiddenTuiWindow).__terminalHiddenSnapshotOverride?.setPending(ptyId, snapshot)
},
{ ptyId, snapshot }
)
}
async function resolveDelayedMainSnapshot(page: Page, ptyId: string): Promise<void> {
await page.evaluate((ptyId) => {
;(window as HiddenTuiWindow).__terminalHiddenSnapshotOverride?.resolve(ptyId)
}, ptyId)
}
async function clearDelayedMainSnapshot(page: Page, ptyId: string): Promise<void> {
await page.evaluate((ptyId) => {
;(window as HiddenTuiWindow).__terminalHiddenSnapshotOverride?.clear(ptyId)
}, ptyId)
}
async function readMainSnapshotSource(
page: Page,
ptyId: string
@ -178,7 +209,7 @@ async function writeHiddenSideEffectBurst(
}
test.describe('Hidden terminal TUI visual restore', () => {
test('restores skipped hidden full-screen TUI output without visible corruption', async ({
test('restores hidden full-screen TUI output without visible corruption', async ({
orcaPage,
testRepoPath
}, testInfo: TestInfo) => {
@ -218,15 +249,9 @@ test.describe('Hidden terminal TUI visual restore', () => {
await expect
.poll(async () => (await readHiddenDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, {
timeout: 10_000,
message: 'hidden TUI output did not exercise the skipped-renderer path'
message: 'visually rich hidden TUI output should stay on the live xterm path'
})
.toBeGreaterThan(0)
await expect
.poll(() => readMainSnapshotSource(orcaPage, hiddenPane.ptyId!), {
timeout: 10_000,
message: 'hidden TUI restore did not use the runtime headless snapshot'
})
.toBe('headless')
.toBe(0)
await switchToWorktree(orcaPage, secondWorktreeId)
await ensureTerminalVisible(orcaPage)
@ -241,7 +266,21 @@ test.describe('Hidden terminal TUI visual restore', () => {
const content = await getTerminalContent(orcaPage, 12_000)
expect(content).toContain(`Frame 024`)
expect(content).toContain('╭')
expect(content).toContain('├')
expect(content).toContain('█')
expect(content).not.toContain('Orca skipped hidden terminal output')
await expect
.poll(() => readTuiCursorState(orcaPage), {
timeout: 5_000,
message: 'restored TUI cursor stayed hidden after final frame'
})
.toMatchObject({
hidden: false,
initialized: true
})
const cursorState = await readTuiCursorState(orcaPage)
expect(cursorState.cursorElementVisible || cursorState.cursorCanvasPresent).toBe(true)
const screenshotPath = testInfo.outputPath('hidden-tui-restore-final.png')
await orcaPage.screenshot({ path: screenshotPath, fullPage: true })
@ -252,7 +291,7 @@ test.describe('Hidden terminal TUI visual restore', () => {
rmSync(scriptPath, { force: true })
})
test('keeps newer live TUI output visually correct while hidden restore is in flight', async ({
test('keeps newer live output correct after hidden output stayed live', async ({
orcaPage
}, testInfo: TestInfo) => {
await waitForSessionReady(orcaPage)
@ -284,8 +323,8 @@ test.describe('Hidden terminal TUI visual restore', () => {
.toBe(firstWorktreeId)
const runId = randomUUID()
const hiddenFrame = tuiFrame(runId, 40)
const liveFrame = tuiFrame(runId, 41)
const hiddenFrame = lowRiskRestoreFrame(runId, 40)
const liveFrame = lowRiskRestoreFrame(runId, 41)
const finalMarker = `VISUAL_RESTORE_FINAL_${runId}_41`
await resetHiddenDebug(orcaPage)
await injectPaneData(orcaPage, paneKey, hiddenFrame, {
@ -296,52 +335,50 @@ test.describe('Hidden terminal TUI visual restore', () => {
await expect
.poll(async () => (await readHiddenDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, {
timeout: 10_000,
message: 'hidden injected TUI output did not skip renderer parsing'
message: 'hidden injected output should stay on the live xterm path for release'
})
.toBeGreaterThan(0)
.toBe(0)
await installDelayedMainSnapshot(orcaPage, hiddenPane.ptyId, {
data: hiddenFrame,
cols: 120,
rows: 40,
seq: hiddenFrame.length,
source: 'headless'
await switchToWorktree(orcaPage, secondWorktreeId)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
await injectPaneData(orcaPage, paneKey, liveFrame, {
seq: hiddenFrame.length + liveFrame.length,
rawLength: liveFrame.length
})
try {
await switchToWorktree(orcaPage, secondWorktreeId)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
await injectPaneData(orcaPage, paneKey, liveFrame, {
seq: hiddenFrame.length + liveFrame.length,
rawLength: liveFrame.length
await expect
.poll(() => getTerminalContent(orcaPage, 12_000), {
timeout: 10_000,
message: 'newer live TUI frame did not render after hidden output stayed live'
})
await resolveDelayedMainSnapshot(orcaPage, hiddenPane.ptyId)
.toContain(finalMarker)
await expect
.poll(() => getTerminalContent(orcaPage, 12_000), {
timeout: 10_000,
message: 'newer live TUI frame did not render after delayed hidden snapshot'
})
.toContain(finalMarker)
const content = await getTerminalContent(orcaPage, 12_000)
expect(content).toContain('Frame 041')
expect(content).not.toContain('Frame 040')
expect(content).not.toContain('Orca skipped hidden terminal output')
const screenshotPath = testInfo.outputPath('hidden-tui-delayed-restore-final.png')
await orcaPage.screenshot({ path: screenshotPath, fullPage: true })
await testInfo.attach('hidden-tui-delayed-restore-final.png', {
path: screenshotPath,
contentType: 'image/png'
const content = await getTerminalContent(orcaPage, 12_000)
expect(content).toContain(`LOW_RISK_RESTORE_FRAME_${runId}_41`)
expect(content).toContain('progress=041')
expect(content.indexOf(`LOW_RISK_RESTORE_FRAME_${runId}_41`)).toBeGreaterThan(
content.indexOf(`LOW_RISK_RESTORE_FRAME_${runId}_40`)
)
expect(content).not.toContain('Orca skipped hidden terminal output')
await expect
.poll(() => readTuiCursorState(orcaPage), {
timeout: 5_000,
message: 'live TUI cursor stayed hidden after hidden output stayed live'
})
} finally {
await clearDelayedMainSnapshot(orcaPage, hiddenPane.ptyId)
}
.toMatchObject({
hidden: false,
initialized: true
})
const screenshotPath = testInfo.outputPath('hidden-tui-live-output-final.png')
await orcaPage.screenshot({ path: screenshotPath, fullPage: true })
await testInfo.attach('hidden-tui-live-output-final.png', {
path: screenshotPath,
contentType: 'image/png'
})
})
test('keeps hidden terminal side effects live while renderer output is skipped', async ({
test('keeps hidden terminal side effects live while hidden output stays live', async ({
orcaPage
}) => {
await waitForSessionReady(orcaPage)
@ -380,9 +417,9 @@ test.describe('Hidden terminal TUI visual restore', () => {
await expect
.poll(async () => (await readHiddenDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, {
timeout: 10_000,
message: 'hidden side-effect output did not exercise the skipped-renderer path'
message: 'hidden side-effect output should stay on the live xterm path for release'
})
.toBeGreaterThan(0)
.toBe(0)
await expect
.poll(() => getRuntimePaneTitle(orcaPage, hiddenSnapshot.tabId, hiddenPane.numericPaneId), {
timeout: 10_000,

View File

@ -0,0 +1,305 @@
import { randomUUID } from 'node:crypto'
import { rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import type { Page, TestInfo } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import {
ensureTerminalVisible,
getAllWorktreeIds,
switchToWorktree,
waitForActiveWorktree,
waitForSessionReady
} from './helpers/store'
import {
getTerminalContent,
sendToTerminal,
waitForActivePanePtyId,
waitForActiveTerminalManager
} from './helpers/terminal'
type TerminalRenderDiagnostics = {
cols: number
rows: number
viewportY: number
baseY: number
hasComplexScriptOutput: boolean
hasWebgl: boolean
canvasCount: number
cursorHidden: boolean | null
visibleLineTails: string[]
allPaneStates: {
tabId: string
paneId: number
hasComplexScriptOutput: boolean
hasMarker: boolean
hasWebgl: boolean
}[]
}
type LongTableDebugWindow = Window & {
__terminalPtyOutputDebug?: {
reset: () => void
snapshot: () => {
hiddenRendererSkipCount: number
hiddenRendererSkippedChars: number
hiddenRendererMode2031ReplyCount: number
}
}
}
function longMarkdownTableScript(runId: string): string {
const names = [
['Sam Syntax', 'Compiler', 'Online', '😀', '9200', 'Semicolons are optional (rage ensues)'],
['Tori Token', 'Auth', 'Idle', '🚀', '4800', 'JWT expires during their standup'],
['Uma Unpin', 'Frontend', 'Online', '🔥', '3500', 'Absolute positioning enjoyer'],
['Vic Variable', 'Types', 'AFK', '💡', '6700', 'any is not a type, it is a cry for help'],
['Wally Watchdog', 'Security', 'Online', '📦', '8200', 'Found a vuln in your vuln scanner'],
['Xena XPath', 'DB', 'Idle', '🔐', '7300', 'Indexes everything, including the fridge'],
['Yuki Yank', 'CLI', 'Online', '🎯', '5900', 'rm -rf / is not a party trick'],
['Zane Zealot', 'OSS', 'Offline', '🤖', '10000', 'Contributor to 47 repos, sleeps never'],
['Artie ASCII', 'Docs', 'Online', '🧠', '2900', 'Wrote a novel in README comments'],
['Bianca Batch', 'ML', 'AFK', '💾', '9400', 'Training a model to write PR descriptions'],
['Carlos Cache', 'CDN', 'Idle', '⚙', '4900', 'Stale data is still data'],
['Diana Draft', 'Planning', 'Online', '📚', '1800', 'Needs 3 more sprints to estimate'],
['Edgar Exit', 'Ops', 'Online', '🔧', '7600', 'Graceful shutdown specialist'],
['Fiona Fallback', 'Resilience', 'Idle', '🧲', '5500', 'Circuit breaker connoisseur'],
['Gabe Garbage', 'GC', 'Offline', '🧹', '4100', 'Stop-the-world is my catchphrase'],
['Holly Hotfix', 'Release', 'Online', '🧪', '6300', 'Friday deploy champion'],
['Ira Idempotent', 'API', 'AFK', '🔁', '6900', 'PUT me in coach'],
['Jules Jitter', 'Mobile', 'Idle', '📱', '3200', 'Offline-first, coffee-second'],
['Ken Kafka', 'Streams', 'Online', '📡', '7100', 'Rebalancing is a lifestyle'],
['Luna Latency', 'Edge', 'Offline', '🧭', '4400', 'Response time measured in business days'],
['Max Marshal', 'Memory', 'Online', '🧩', '8700', "Leak-free since '24"],
['Nora Null', 'Safety', 'AFK', '❓', '3800', 'null is a person, not a value'],
['Otto Offset', 'Cursors', 'Idle', '👆', '2600', 'Infinite scroll for the infinite soul'],
['Pam Payload', 'Serialization', 'Online', '📦', '5800', 'JSON.stringify is my yoga'],
['Reed Regex', 'Matching', 'Offline', '🔍', '6800', 'Now I have two problems']
]
return `
const rows = ${JSON.stringify(names)}
const widths = [16, 14, 12, 6, 7, 42]
function isCombiningMark(codePoint) {
return (codePoint >= 0x0300 && codePoint <= 0x036f) ||
(codePoint >= 0xfe00 && codePoint <= 0xfe0f)
}
function isWideCodePoint(codePoint) {
return codePoint > 0xffff ||
(codePoint >= 0x1100 && codePoint <= 0x115f) ||
(codePoint >= 0x2e80 && codePoint <= 0xa4cf) ||
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
(codePoint >= 0xfe10 && codePoint <= 0xfe6f) ||
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
(codePoint >= 0xffe0 && codePoint <= 0xffe6)
}
function cellWidth(text) {
let width = 0
for (const char of String(text)) {
const codePoint = char.codePointAt(0)
if (codePoint === undefined || isCombiningMark(codePoint)) continue
width += isWideCodePoint(codePoint) ? 2 : 1
}
return width
}
function cell(value, width) {
const text = String(value)
return text + ' '.repeat(Math.max(1, width - cellWidth(text)))
}
function line(parts) {
return '| ' + parts.map((part, index) => cell(part, widths[index])).join(' | ') + ' |'
}
const outputRows = []
outputRows.push(line(['Name', 'Team', 'Status', 'Icon', 'Score', 'Notes']))
outputRows.push('|-' + widths.map((width) => '-'.repeat(width)).join('-|-') + '-|')
for (let repeat = 0; repeat < 4; repeat += 1) {
for (const row of rows) outputRows.push(line(row))
}
process.stdout.write('\\x1b[?2026h\\x1b[2J\\x1b[H')
let index = 0
const timer = setInterval(() => {
if (index < outputRows.length) {
process.stdout.write(outputRows[index] + '\\n')
index += 1
return
}
clearInterval(timer)
process.stdout.write('LONG_TABLE_SCROLL_RESTORE_${runId}\\n')
process.stdout.write('\\x1b[?2026l')
}, 8)
`
}
async function scrollActiveTerminalLikeUser(page: Page): Promise<void> {
const target = await page.evaluate(() => {
const store = window.__store
const state = store?.getState()
const worktreeId = state?.activeWorktreeId
const tabId =
state?.activeTabType === 'terminal'
? state.activeTabId
: worktreeId
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
: null
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
if (!pane) {
throw new Error('Active terminal pane unavailable')
}
pane.terminal.focus()
pane.terminal.scrollToBottom()
const viewport =
pane.container.querySelector<HTMLElement>('.xterm-viewport') ??
pane.container.querySelector<HTMLElement>('.xterm')
if (!viewport) {
throw new Error('Active terminal viewport unavailable')
}
const rect = viewport.getBoundingClientRect()
return {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2
}
})
await page.mouse.move(target.x, target.y)
await page.mouse.wheel(0, -1800)
await page.waitForTimeout(250)
}
async function closeFeatureTips(page: Page): Promise<void> {
await page.evaluate(() => {
const store = window.__store
store?.getState().markFeatureTipsSeen(['orca-cli', 'cmd-j-palette', 'voice-dictation'])
if (store?.getState().activeModal === 'feature-tips') {
store.getState().closeModal()
}
})
}
async function readTerminalRenderDiagnostics(page: Page): Promise<TerminalRenderDiagnostics> {
return page.evaluate(() => {
const store = window.__store
const state = store?.getState()
const worktreeId = state?.activeWorktreeId
const tabId =
state?.activeTabType === 'terminal'
? state.activeTabId
: worktreeId
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
: null
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
if (!pane) {
throw new Error('Active terminal pane unavailable')
}
const buffer = pane.terminal.buffer.active
const visibleLineTails: string[] = []
for (let row = 0; row < pane.terminal.rows; row += 1) {
const line = buffer.getLine(buffer.viewportY + row)
visibleLineTails.push(line?.translateToString(true).slice(-48) ?? '')
}
const terminalCore = (
pane.terminal as unknown as {
_core?: { coreService?: { isCursorHidden?: boolean } }
}
)._core
const allPaneStates = Array.from(window.__paneManagers?.entries?.() ?? []).flatMap(
([managerTabId, paneManager]) =>
(paneManager.getPanes?.() ?? []).map((managedPane) => {
const visibleText = Array.from({ length: managedPane.terminal.rows }, (_, row) => {
const line = managedPane.terminal.buffer.active.getLine(
managedPane.terminal.buffer.active.viewportY + row
)
return line?.translateToString(true) ?? ''
}).join('\n')
const serializedText = managedPane.serializeAddon?.serialize?.() ?? visibleText
return {
tabId: managerTabId,
paneId: managedPane.id,
hasComplexScriptOutput: managedPane.hasComplexScriptOutput === true,
hasMarker: serializedText.includes('LONG_TABLE_SCROLL_RESTORE_'),
hasWebgl: Boolean(managedPane.webglAddon)
}
})
)
return {
cols: pane.terminal.cols,
rows: pane.terminal.rows,
viewportY: buffer.viewportY,
baseY: buffer.baseY,
hasComplexScriptOutput: pane.hasComplexScriptOutput === true,
hasWebgl: Boolean(pane.webglAddon),
canvasCount: pane.container.querySelectorAll('canvas').length,
cursorHidden: terminalCore?.coreService?.isCursorHidden ?? null,
visibleLineTails,
allPaneStates
}
})
}
test.describe('Terminal long table scroll restore repro', () => {
test('reproduces long markdown table artifacts after workspace switch and scroll', async ({
orcaPage,
testRepoPath
}, testInfo: TestInfo) => {
await waitForSessionReady(orcaPage)
await orcaPage.evaluate(() => {
window.__store
?.getState()
.markFeatureTipsSeen(['orca-cli', 'cmd-j-palette', 'voice-dictation'])
;(window as LongTableDebugWindow).__terminalPtyOutputDebug?.reset()
})
const firstWorktreeId = await waitForActiveWorktree(orcaPage)
const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find(
(id) => id !== firstWorktreeId
)
test.skip(!secondWorktreeId, 'long table restore repro needs the seeded secondary worktree')
if (!secondWorktreeId) {
return
}
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const ptyId = await waitForActivePanePtyId(orcaPage)
const runId = randomUUID()
const marker = `LONG_TABLE_SCROLL_RESTORE_${runId}`
const scriptPath = path.join(testRepoPath, `.orca-long-table-${runId}.mjs`)
writeFileSync(scriptPath, longMarkdownTableScript(runId))
try {
await sendToTerminal(orcaPage, ptyId, `node ${JSON.stringify(scriptPath)}\r`)
await orcaPage.waitForTimeout(80)
await switchToWorktree(orcaPage, secondWorktreeId)
await waitForActiveTerminalManager(orcaPage, 30_000)
await orcaPage.waitForTimeout(1_500)
await switchToWorktree(orcaPage, firstWorktreeId)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
await expect
.poll(() => getTerminalContent(orcaPage, 30_000), {
timeout: 10_000,
message: 'long table marker did not survive workspace switch'
})
.toContain(marker)
await scrollActiveTerminalLikeUser(orcaPage)
await closeFeatureTips(orcaPage)
const diagnostics = await readTerminalRenderDiagnostics(orcaPage)
const hiddenDebug = await orcaPage.evaluate(() =>
(window as LongTableDebugWindow).__terminalPtyOutputDebug?.snapshot()
)
expect(hiddenDebug?.hiddenRendererSkipCount).toBe(0)
const restoredPane = diagnostics.allPaneStates.find((paneState) => paneState.hasMarker)
expect(restoredPane).toBeDefined()
expect(restoredPane?.hasWebgl).toBe(false)
expect(diagnostics.cursorHidden).toBe(false)
await orcaPage.waitForTimeout(100)
const screenshotPath = testInfo.outputPath('long-table-after-switch-scroll.png')
await orcaPage.screenshot({ path: screenshotPath, fullPage: true })
await testInfo.attach('long-table-after-switch-scroll.png', {
path: screenshotPath,
contentType: 'image/png'
})
} finally {
rmSync(scriptPath, { force: true })
}
})
})

View File

@ -0,0 +1,324 @@
import { randomUUID } from 'node:crypto'
import { rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import type { Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import {
sendToTerminal,
waitForActivePanePtyId,
waitForActiveTerminalManager,
waitForTerminalOutput
} from './helpers/terminal'
import {
analyzeRasterCursorCells,
type TerminalRasterProbeTarget
} from './terminal-cursor-raster-probe'
type TerminalRenderState = {
coreCursorHidden: boolean | null
cursorElementCount: number
cursorVisibleElementCount: number
cursorBlink: boolean | null
blinkIntervalDuration: number | null
cursorClassName: string
cursorAnimationName: string
cursorAnimationDuration: string
rowContainerClassName: string
xtermClassName: string
hasWebglCanvas: boolean
hasComplexScriptOutput: boolean
renderer: 'dom' | 'webgl'
}
type CursorBlinkSample = {
elapsedMs: number
paintedCursorCellCount: number
}
const EMOJI_TABLE_MARKER = 'ORCA_EMOJI_TABLE_RENDER_DONE'
function emojiTableScript(marker: string): string {
const table = [
'| Emoji | Name | Age | Occupation | City | Favorite Color | Pet | Hobby |',
'| --- | --- | ---: | --- | --- | --- | --- | --- |',
'| 😀 | Alice Johnson | 28 | Engineer | New York | Blue | 🐕 Dog | 🎸 Guitar |',
'| 😂 | Bob Smith | 34 | Designer | London | Green | 🐱 Cat | 📚 Reading |',
'| 🥰 | Carol Davis | 22 | Student | Paris | Pink | 🐰 Rabbit | 🎨 Painting |',
'| 😎 | Dave Wilson | 45 | Architect | Tokyo | Black | 🐢 Turtle | 🏃 Running |',
'| 🤩 | Eve Martinez | 31 | Writer | Berlin | Purple | 🐦 Bird | ✈️ Traveling |',
'| 😜 | Frank Brown | 27 | Developer | Sydney | Red | 🐹 Hamster | 🎮 Gaming |',
'| 🥳 | Grace Lee | 39 | Teacher | Seoul | Yellow | 🐟 Fish | 🌱 Gardening |',
'| 🤔 | Henry Taylor | 41 | Doctor | Toronto | White | 🐕 Dog | 🍳 Cooking |',
'| 😴 | Ivy Anderson | 26 | Nurse | Chicago | Orange | 🐱 Cat | 🧘 Yoga |',
'| 🤗 | Jack Thomas | 33 | Lawyer | Boston | Navy | 🐢 Turtle | 📸 Photography |',
'| 😈 | Karen White | 29 | Artist | Miami | Teal | 🐹 Hamster | 🧶 Knitting |',
'| 😮 | Leo Harris | 37 | Pilot | Dubai | Gold | 🐦 Bird | 🚁 Drones |',
'| 🤠 | Mia Clark | 24 | Barista | Seattle | Coral | 🐰 Rabbit | 🎤 Singing |',
'| 😍 | Olivia Hall | 30 | Marketer | Austin | Pink | 🐱 Cat | 🏄 Surfing |'
].join('\r\n')
return `
process.stdout.write('\\x1b[?2026h\\x1b[?25l')
process.stdout.write('\\x1b[2J\\x1b[H')
process.stdout.write(${JSON.stringify(table)})
process.stdout.write('\\r\\n${marker}\\r\\n')
process.stdout.write('\\x1b[?25h\\x1b[?2026l')
setTimeout(() => process.exit(0), 50)
`
}
async function readActiveTerminalRenderState(page: Page): Promise<TerminalRenderState> {
return page.evaluate(() => {
const state = window.__store?.getState()
const worktreeId = state?.activeWorktreeId
const tabId =
state?.activeTabType === 'terminal'
? state.activeTabId
: worktreeId
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
: null
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
if (!pane) {
throw new Error('No active terminal pane')
}
const cursorElements = Array.from(
pane.container.querySelectorAll<HTMLElement>('.xterm-cursor, .xterm-cursor-layer *')
)
const cursorVisibleElementCount = cursorElements.filter((element) => {
const style = window.getComputedStyle(element)
const rect = element.getBoundingClientRect()
return (
style.display !== 'none' &&
style.visibility !== 'hidden' &&
Number(style.opacity || '1') > 0 &&
rect.width > 0 &&
rect.height > 0
)
}).length
const terminal = pane.terminal as {
_core?: {
coreService?: { isCursorHidden?: boolean }
}
}
const cursorElement = pane.container.querySelector<HTMLElement>('.xterm-cursor')
const cursorStyle = cursorElement ? window.getComputedStyle(cursorElement) : null
const rowContainer = pane.container.querySelector<HTMLElement>('.xterm-rows')
const xterm = pane.container.querySelector<HTMLElement>('.xterm')
return {
coreCursorHidden:
typeof terminal._core?.coreService?.isCursorHidden === 'boolean'
? terminal._core.coreService.isCursorHidden
: null,
cursorElementCount: cursorElements.length,
cursorVisibleElementCount,
cursorBlink:
typeof pane.terminal.options.cursorBlink === 'boolean'
? pane.terminal.options.cursorBlink
: null,
blinkIntervalDuration:
typeof pane.terminal.options.blinkIntervalDuration === 'number'
? pane.terminal.options.blinkIntervalDuration
: null,
cursorClassName: cursorElement?.className ?? '',
cursorAnimationName: cursorStyle?.animationName ?? '',
cursorAnimationDuration: cursorStyle?.animationDuration ?? '',
rowContainerClassName: rowContainer?.className ?? '',
xtermClassName: xterm?.className ?? '',
hasWebglCanvas: pane.container.querySelector('.xterm-webgl canvas') !== null,
hasComplexScriptOutput: pane.hasComplexScriptOutput === true,
renderer: pane.webglAddon ? 'webgl' : 'dom'
}
})
}
async function forceCursorProbeTheme(page: Page): Promise<void> {
await page.evaluate(() => {
const state = window.__store?.getState()
const worktreeId = state?.activeWorktreeId
const tabId =
state?.activeTabType === 'terminal'
? state.activeTabId
: worktreeId
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
: null
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
if (!pane) {
throw new Error('No active terminal pane')
}
pane.terminal.options.theme = {
...pane.terminal.options.theme,
cursor: '#23ff45',
cursorAccent: '#001000'
}
pane.terminal.options.cursorStyle = 'block'
pane.terminal.options.cursorBlink = true
pane.terminal.focus()
pane.terminal.refresh(0, pane.terminal.rows - 1)
})
}
async function readActiveTerminalRasterTarget(page: Page): Promise<TerminalRasterProbeTarget> {
return page.evaluate(() => {
const state = window.__store?.getState()
const worktreeId = state?.activeWorktreeId
const tabId =
state?.activeTabType === 'terminal'
? state.activeTabId
: worktreeId
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
: null
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
if (!pane) {
throw new Error('No active terminal pane')
}
const screen = pane.container.querySelector<HTMLElement>('.xterm-screen')
const dimensions = pane.terminal._core?._renderService?.dimensions?.css?.cell
if (!screen || !dimensions) {
throw new Error('Active terminal has no measurable xterm screen')
}
const rect = screen.getBoundingClientRect()
return {
clip: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
cellWidth: dimensions.width,
cellHeight: dimensions.height,
rows: pane.terminal.rows,
cols: pane.terminal.cols
}
})
}
async function sampleCursorBlink(page: Page): Promise<CursorBlinkSample[]> {
const samples: CursorBlinkSample[] = []
const target = await readActiveTerminalRasterTarget(page)
const viewport = page.viewportSize() ?? undefined
const start = performance.now()
for (let index = 0; index < 9; index += 1) {
if (index > 0) {
await page.waitForTimeout(200)
}
const screenshot = await page.screenshot()
const cells = analyzeRasterCursorCells(Buffer.from(screenshot), target, viewport)
samples.push({
elapsedMs: performance.now() - start,
paintedCursorCellCount: cells.length
})
}
return samples
}
async function enableRiskyTerminalRendererPath(page: Page): Promise<void> {
await page.evaluate(() => {
const store = window.__store
if (!store) {
throw new Error('window.__store unavailable')
}
const state = store.getState()
store.setState({
settings: {
...state.settings!,
terminalGpuAcceleration: 'auto',
theme: 'dark'
}
})
})
}
test.describe('OpenCode emoji table terminal rendering', () => {
test('keeps emoji table output visually sane and restores the cursor', async ({
orcaPage,
testRepoPath
}, testInfo) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
await enableRiskyTerminalRendererPath(orcaPage)
const ptyId = await waitForActivePanePtyId(orcaPage)
const runId = randomUUID()
const marker = `${EMOJI_TABLE_MARKER}_${runId}`
const scriptPath = path.join(testRepoPath, `.orca-opencode-emoji-table-${runId}.mjs`)
writeFileSync(scriptPath, emojiTableScript(marker))
try {
await sendToTerminal(orcaPage, ptyId, `node ${JSON.stringify(scriptPath)}\r`)
await waitForTerminalOutput(orcaPage, marker, 10_000)
await orcaPage.waitForTimeout(250)
await forceCursorProbeTheme(orcaPage)
await orcaPage.waitForTimeout(50)
const renderState = await readActiveTerminalRenderState(orcaPage)
const blinkSamples = await sampleCursorBlink(orcaPage)
const screenshotPath = testInfo.outputPath('opencode-emoji-table-final.png')
await orcaPage.screenshot({ path: screenshotPath, fullPage: true })
await testInfo.attach('opencode-emoji-table-final.png', {
path: screenshotPath,
contentType: 'image/png'
})
testInfo.annotations.push({
type: 'opencode-emoji-table-rendering',
description: JSON.stringify({ renderState, blinkSamples })
})
expect(renderState.renderer).toBe('dom')
expect(renderState.hasWebglCanvas).toBe(false)
expect(renderState.coreCursorHidden).toBe(false)
expect(renderState.cursorVisibleElementCount).toBeGreaterThan(0)
expect(renderState.cursorBlink).toBe(true)
expect(renderState.cursorAnimationName).not.toBe('none')
expect(blinkSamples.some((sample) => sample.paintedCursorCellCount > 0)).toBe(true)
expect(blinkSamples.some((sample) => sample.paintedCursorCellCount === 0)).toBe(true)
} finally {
rmSync(scriptPath, { force: true })
}
})
test('local real OpenCode demo keeps table rendering and cursor visible', async ({
orcaPage
}, testInfo) => {
test.skip(
process.env.ORCA_E2E_REAL_OPENCODE !== '1',
'Set ORCA_E2E_REAL_OPENCODE=1 to exercise the locally installed OpenCode TUI'
)
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
await enableRiskyTerminalRendererPath(orcaPage)
const ptyId = await waitForActivePanePtyId(orcaPage)
await sendToTerminal(
orcaPage,
ptyId,
'opencode run --demo --interactive "Give me markdown table dummy data a long table with emojis in it"\r'
)
try {
await waitForTerminalOutput(orcaPage, 'Give me markdown table', 15_000)
await waitForTerminalOutput(orcaPage, 'Emoji', 60_000)
await waitForTerminalOutput(orcaPage, 'Alice', 60_000)
await orcaPage.waitForTimeout(1_500)
await testInfo.attach('real-opencode-demo-table', {
body: await orcaPage.screenshot({ fullPage: true }),
contentType: 'image/png'
})
const renderState = await readActiveTerminalRenderState(orcaPage)
testInfo.annotations.push({
type: 'real-opencode-demo-rendering',
description: JSON.stringify(renderState)
})
expect(renderState.coreCursorHidden).toBe(false)
expect(renderState.cursorVisibleElementCount).toBeGreaterThan(0)
} finally {
await sendToTerminal(orcaPage, ptyId, '\x03').catch(() => undefined)
}
})
})