From f15dcd928cb36b5a7da820dd6111ec5cb33e0882 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 8 Jun 2026 01:00:29 -0700 Subject: [PATCH] Stabilize OpenCode terminal rendering before release (#4850) --- src/main/daemon/client.test.ts | 52 +++ src/main/daemon/client.ts | 16 +- src/main/daemon/daemon-server.ts | 11 +- src/main/daemon/headless-emulator.test.ts | 20 +- src/main/daemon/headless-emulator.ts | 17 +- src/main/persistence.test.ts | 30 ++ src/main/persistence.ts | 3 + src/main/runtime/orca-runtime.test.ts | 19 +- src/main/runtime/orca-runtime.ts | 7 +- ...TerminalSettingsPreview.lifecycle.test.tsx | 6 +- .../terminal-pane/pty-connection.test.ts | 190 ++++++++-- .../terminal-pane/pty-connection.ts | 32 +- .../terminal-pane/replay-guard.test.ts | 53 +++ .../components/terminal-pane/replay-guard.ts | 28 +- .../terminal-pane/terminal-appearance.ts | 2 +- .../use-terminal-pane-global-effects.test.ts | 10 +- .../use-terminal-pane-global-effects.ts | 36 +- .../use-terminal-pane-lifecycle.test.ts | 72 +--- .../use-terminal-pane-lifecycle.ts | 38 +- .../pane-manager/pane-dom-focus-class-sync.ts | 34 ++ .../lib/pane-manager/pane-lifecycle.test.ts | 5 +- .../src/lib/pane-manager/pane-lifecycle.ts | 6 + .../lib/pane-manager/pane-manager-types.ts | 3 +- .../lib/pane-manager/pane-split-close.test.ts | 51 +-- .../src/lib/pane-manager/pane-split-close.ts | 6 +- .../pane-terminal-foreground-render-settle.ts | 3 + .../lib/pane-manager/pane-terminal-options.ts | 4 +- .../pane-terminal-output-scheduler.test.ts | 59 ---- .../pane-terminal-output-scheduler.ts | 20 -- src/renderer/src/web/web-preload-api.test.ts | 35 ++ src/renderer/src/web/web-preload-api.ts | 9 +- src/shared/constants.test.ts | 5 + src/shared/constants.ts | 3 +- src/shared/terminal-cursor-style-settings.ts | 21 ++ src/shared/types.ts | 2 + ...icial-opencode-hidden-pressure-scenario.ts | 4 +- ...artificial-opencode-synthetic-injection.ts | 1 + .../artificial-opencode-terminal-load.spec.ts | 4 +- ...erminal-codex-local-typing-latency.spec.ts | 232 +++++++++++++ .../terminal-cursor-inactive-style.spec.ts | 11 +- ...terminal-hidden-tui-visual-restore.spec.ts | 215 +++++++----- ...terminal-long-table-scroll-restore.spec.ts | 305 +++++++++++++++++ ...nal-opencode-emoji-table-rendering.spec.ts | 324 ++++++++++++++++++ 43 files changed, 1549 insertions(+), 455 deletions(-) create mode 100644 src/renderer/src/lib/pane-manager/pane-dom-focus-class-sync.ts create mode 100644 src/shared/terminal-cursor-style-settings.ts create mode 100644 tests/e2e/terminal-codex-local-typing-latency.spec.ts create mode 100644 tests/e2e/terminal-long-table-scroll-restore.spec.ts create mode 100644 tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts diff --git a/src/main/daemon/client.test.ts b/src/main/daemon/client.test.ts index 728a0d758..462e95c72 100644 --- a/src/main/daemon/client.test.ts +++ b/src/main/daemon/client.test.ts @@ -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 { 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', () => { diff --git a/src/main/daemon/client.ts b/src/main/daemon/client.ts index 757acd182..f90a7d42d 100644 --- a/src/main/daemon/client.ts +++ b/src/main/daemon/client.ts @@ -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) } diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 381400e25..558a41b53 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -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) diff --git a/src/main/daemon/headless-emulator.test.ts b/src/main/daemon/headless-emulator.test.ts index c6b09d5c8..14e591d49 100644 --- a/src/main/daemon/headless-emulator.test.ts +++ b/src/main/daemon/headless-emulator.test.ts @@ -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) diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index 54ade4c65..db891419c 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -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((resolve) => { this.terminal.write(data, () => { // Why: snapshots combine serialized xterm state with mirrored mouse diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 5d618d8d0..a7ba01e8d 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -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({ diff --git a/src/main/persistence.ts b/src/main/persistence.ts index fdece6281..e0ba281e0 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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, diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 9976e422e..7cc17201d 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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> + 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)) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index f96a966d6..329f647de 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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) diff --git a/src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx b/src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx index 01dbca718..3422b3fbe 100644 --- a/src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx +++ b/src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx @@ -145,7 +145,7 @@ function makeSettings(overrides: Partial = {}): 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, diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 40b1acafa..2d475ed72 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -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) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index bc5a88273..4e600279a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -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) && diff --git a/src/renderer/src/components/terminal-pane/replay-guard.test.ts b/src/renderer/src/components/terminal-pane/replay-guard.test.ts index 976aab001..6431b773b 100644 --- a/src/renderer/src/components/terminal-pane/replay-guard.test.ts +++ b/src/renderer/src/components/terminal-pane/replay-guard.test.ts @@ -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 + } + }) }) diff --git a/src/renderer/src/components/terminal-pane/replay-guard.ts b/src/renderer/src/components/terminal-pane/replay-guard.ts index 904f2e532..0229bde31 100644 --- a/src/renderer/src/components/terminal-pane/replay-guard.ts +++ b/src/renderer/src/components/terminal-pane/replay-guard.ts @@ -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() }) }) } diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts index 12e3bace3..9f7dc5f23 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts index 44820e6fb..f264f3f6b 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts @@ -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', () => { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts index 5cdc77374..a3dd61a9b 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts @@ -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(() => { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts index 52a5f21b1..8c7400939 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts @@ -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 } | 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) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index af0f99f30..66679e18b 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -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, - 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 diff --git a/src/renderer/src/lib/pane-manager/pane-dom-focus-class-sync.ts b/src/renderer/src/lib/pane-manager/pane-dom-focus-class-sync.ts new file mode 100644 index 000000000..137b9346a --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-dom-focus-class-sync.ts @@ -0,0 +1,34 @@ +export function attachDomRendererFocusClassSync( + terminalElement: HTMLElement | undefined +): () => void { + if (!terminalElement) { + return () => undefined + } + + const sync = (): void => { + const rows = terminalElement.querySelector('.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) + } +} diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts index d926a7707..10be24464 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts @@ -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', () => { diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index 1b894f4a8..c06b14233 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -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 diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index b31ccdeec..83df98dfd 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -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 diff --git a/src/renderer/src/lib/pane-manager/pane-split-close.test.ts b/src/renderer/src/lib/pane-manager/pane-split-close.test.ts index 0b472feb2..9ea4aa0eb 100644 --- a/src/renderer/src/lib/pane-manager/pane-split-close.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-split-close.test.ts @@ -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 = {} parentElement: MockElement | null = null style: Record = {} - 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) => { - 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) => { - 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([ - [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 - }) - }) -}) diff --git a/src/renderer/src/lib/pane-manager/pane-split-close.ts b/src/renderer/src/lib/pane-manager/pane-split-close.ts index 28366841b..2d7c50a62 100644 --- a/src/renderer/src/lib/pane-manager/pane-split-close.ts +++ b/src/renderer/src/lib/pane-manager/pane-split-close.ts @@ -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?.() } diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts b/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts index 8d26f0a10..93b848e74 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts @@ -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?.() } } diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-options.ts b/src/renderer/src/lib/pane-manager/pane-terminal-options.ts index 814b73297..ce2d57eee 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-options.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-options.ts @@ -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, diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts index dbc9fef97..92ec8190f 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts @@ -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() diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts index 506f24300..7c2de61ef 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts @@ -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() -const activeOutputTargets = new WeakSet() 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() } diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index 26dcc0bd0..6ca748a9d 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -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( diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 33a531693..029127549 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -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>(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 diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts index 648349c2a..b7a7b6761 100644 --- a/src/shared/constants.test.ts +++ b/src/shared/constants.test.ts @@ -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) }) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 44b953fb8..8d120e373 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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', diff --git a/src/shared/terminal-cursor-style-settings.ts b/src/shared/terminal-cursor-style-settings.ts new file mode 100644 index 000000000..0de07a08e --- /dev/null +++ b/src/shared/terminal-cursor-style-settings.ts @@ -0,0 +1,21 @@ +import type { GlobalSettings } from './types' + +type TerminalCursorStyleSettings = Pick< + GlobalSettings, + 'terminalCursorStyle' | 'terminalCursorStyleDefaultedToBlock' +> + +export function normalizeTerminalCursorStyleDefault( + settings: Partial | 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 + } +} diff --git a/src/shared/types.ts b/src/shared/types.ts index ec8a4e983..cc6530b87 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 diff --git a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts index f04db559b..828946430 100644 --- a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts +++ b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts @@ -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) diff --git a/tests/e2e/artificial-opencode-synthetic-injection.ts b/tests/e2e/artificial-opencode-synthetic-injection.ts index 4a2f4d9cd..f6e329bfe 100644 --- a/tests/e2e/artificial-opencode-synthetic-injection.ts +++ b/tests/e2e/artificial-opencode-synthetic-injection.ts @@ -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('') } diff --git a/tests/e2e/artificial-opencode-terminal-load.spec.ts b/tests/e2e/artificial-opencode-terminal-load.spec.ts index 9b07bc495..3accb50ad 100644 --- a/tests/e2e/artificial-opencode-terminal-load.spec.ts +++ b/tests/e2e/artificial-opencode-terminal-load.spec.ts @@ -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) diff --git a/tests/e2e/terminal-codex-local-typing-latency.spec.ts b/tests/e2e/terminal-codex-local-typing-latency.spec.ts new file mode 100644 index 000000000..e0bb08eb3 --- /dev/null +++ b/tests/e2e/terminal-codex-local-typing-latency.spec.ts @@ -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 { + 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('.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 { + 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 { + 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('.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 { + 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 { + 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 { + 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 { + 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) + } + }) +}) diff --git a/tests/e2e/terminal-cursor-inactive-style.spec.ts b/tests/e2e/terminal-cursor-inactive-style.spec.ts index 9ccb32c5c..46cca167a 100644 --- a/tests/e2e/terminal-cursor-inactive-style.spec.ts +++ b/tests/e2e/terminal-cursor-inactive-style.spec.ts @@ -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') }) }) diff --git a/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts b/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts index ef480a4d8..992242527 100644 --- a/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts +++ b/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts @@ -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 { await page.evaluate(() => { ;(window as HiddenTuiWindow).__terminalPtyOutputDebug?.reset() @@ -85,6 +99,48 @@ async function readHiddenDebug(page: Page): Promise { + 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('.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('.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 { - await page.evaluate( - ({ ptyId, snapshot }) => { - ;(window as HiddenTuiWindow).__terminalHiddenSnapshotOverride?.setPending(ptyId, snapshot) - }, - { ptyId, snapshot } - ) -} - -async function resolveDelayedMainSnapshot(page: Page, ptyId: string): Promise { - await page.evaluate((ptyId) => { - ;(window as HiddenTuiWindow).__terminalHiddenSnapshotOverride?.resolve(ptyId) - }, ptyId) -} - -async function clearDelayedMainSnapshot(page: Page, ptyId: string): Promise { - 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, diff --git a/tests/e2e/terminal-long-table-scroll-restore.spec.ts b/tests/e2e/terminal-long-table-scroll-restore.spec.ts new file mode 100644 index 000000000..fc2d55e1e --- /dev/null +++ b/tests/e2e/terminal-long-table-scroll-restore.spec.ts @@ -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 { + 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('.xterm-viewport') ?? + pane.container.querySelector('.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 { + 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 { + 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 }) + } + }) +}) diff --git a/tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts b/tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts new file mode 100644 index 000000000..af4d1e19e --- /dev/null +++ b/tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts @@ -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 { + 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('.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('.xterm-cursor') + const cursorStyle = cursorElement ? window.getComputedStyle(cursorElement) : null + const rowContainer = pane.container.querySelector('.xterm-rows') + const xterm = pane.container.querySelector('.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 { + 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 { + 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('.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 { + 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 { + 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) + } + }) +})