Stabilize OpenCode terminal rendering before release (#4850)
This commit is contained in:
parent
9867305465
commit
f15dcd928c
|
|
@ -13,6 +13,16 @@ function createTestDir(): string {
|
|||
return mkdtempSync(join(tmpdir(), 'daemon-client-test-'))
|
||||
}
|
||||
|
||||
function splitInsideUtf8Sequence(payload: string, needle: string): [Buffer, Buffer] {
|
||||
const encoded = Buffer.from(payload, 'utf8')
|
||||
const encodedNeedle = Buffer.from(needle, 'utf8')
|
||||
const offset = encoded.indexOf(encodedNeedle)
|
||||
if (offset === -1 || encodedNeedle.length < 2) {
|
||||
throw new Error(`Unable to split payload inside ${needle}`)
|
||||
}
|
||||
return [encoded.subarray(0, offset + 1), encoded.subarray(offset + 1)]
|
||||
}
|
||||
|
||||
async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
|
||||
const start = Date.now()
|
||||
while (!predicate()) {
|
||||
|
|
@ -332,6 +342,48 @@ describe('DaemonClient', () => {
|
|||
sessionId: 'session-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves UTF-8 stream events split inside multibyte characters', async () => {
|
||||
let streamSocket: Socket | null = null
|
||||
await startMockDaemon()
|
||||
|
||||
const origListener = server.listeners('connection')[0] as (s: Socket) => void
|
||||
server.removeAllListeners('connection')
|
||||
let socketCount = 0
|
||||
server.on('connection', (socket) => {
|
||||
socketCount++
|
||||
if (socketCount === 2) {
|
||||
streamSocket = socket
|
||||
}
|
||||
origListener(socket)
|
||||
})
|
||||
|
||||
const events: DaemonEvent[] = []
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
client.onEvent((event) => events.push(event as DaemonEvent))
|
||||
await client.ensureConnected()
|
||||
await waitFor(() => streamSocket !== null)
|
||||
|
||||
const tableRow = '│OpenCode│🧩│┼────────┤'
|
||||
const event: DaemonEvent = {
|
||||
type: 'event',
|
||||
event: 'data',
|
||||
sessionId: 'session-1',
|
||||
payload: { data: tableRow }
|
||||
}
|
||||
const [first, second] = splitInsideUtf8Sequence(encodeNdjson(event), '🧩')
|
||||
streamSocket!.write(first)
|
||||
streamSocket!.write(second)
|
||||
|
||||
await waitFor(() => events.length > 0)
|
||||
expect(events[0]).toMatchObject({
|
||||
type: 'event',
|
||||
event: 'data',
|
||||
sessionId: 'session-1',
|
||||
payload: { data: tableRow }
|
||||
})
|
||||
expect(JSON.stringify(events[0])).not.toContain('\ufffd')
|
||||
})
|
||||
})
|
||||
|
||||
describe('disconnect', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,12 @@ export type HeadlessSnapshotOptions = {
|
|||
scrollbackRows?: number
|
||||
}
|
||||
|
||||
type TerminalWithSynchronousWrite = Terminal & {
|
||||
_core?: {
|
||||
writeSync?: (data: string) => void
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_SCROLLBACK = 5000
|
||||
const OSC_SCAN_TAIL_LIMIT = 4096
|
||||
// Why: PTY/SSH chunks can split a long combined DECSET before the final h/l.
|
||||
|
|
@ -66,7 +72,8 @@ export class HeadlessEmulator {
|
|||
cols: opts.cols,
|
||||
rows: opts.rows,
|
||||
scrollback: opts.scrollback ?? DEFAULT_SCROLLBACK,
|
||||
allowProposedApi: true
|
||||
allowProposedApi: true,
|
||||
logLevel: 'off'
|
||||
})
|
||||
|
||||
this.serializer = new SerializeAddon()
|
||||
|
|
@ -97,6 +104,14 @@ export class HeadlessEmulator {
|
|||
if (lastTitle !== null) {
|
||||
this.lastTitle = lastTitle
|
||||
}
|
||||
const writeSync = (this.terminal as TerminalWithSynchronousWrite)._core?.writeSync
|
||||
if (typeof writeSync === 'function') {
|
||||
// Why: hidden renderer restore snapshots are requested immediately after
|
||||
// PTY bursts; queued headless writes can snapshot half-cleared TUI rows.
|
||||
writeSync.call((this.terminal as TerminalWithSynchronousWrite)._core, data)
|
||||
this.scanPrivateModes(data)
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
this.terminal.write(data, () => {
|
||||
// Why: snapshots combine serialized xterm state with mirrored mouse
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -5250,11 +5250,22 @@ describe('OrcaRuntimeService', () => {
|
|||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
const lines = Array.from({ length: 120 }, (_, index) => `line-${index}-${'x'.repeat(400)}`)
|
||||
runtime.onPtyData('pty-1', `${lines.join('\n')}\n`, 100)
|
||||
// Why: xterm-headless uses Array.shift internally while draining writes;
|
||||
// this test guards read-preview trimming, not emulator parsing.
|
||||
await runtime.serializeMainTerminalBuffer('pty-1')
|
||||
|
||||
const shiftSpy = vi.spyOn(Array.prototype, 'shift')
|
||||
const preview = await runtime.readTerminal(terminal.handle)
|
||||
const shiftCallCount = shiftSpy.mock.calls.length
|
||||
shiftSpy.mockRestore()
|
||||
const originalShift = Array.prototype.shift
|
||||
let shiftCallCount = 0
|
||||
Array.prototype.shift = function (...args) {
|
||||
shiftCallCount += 1
|
||||
return originalShift.apply(this, args)
|
||||
}
|
||||
let preview: Awaited<ReturnType<typeof runtime.readTerminal>>
|
||||
try {
|
||||
preview = await runtime.readTerminal(terminal.handle)
|
||||
} finally {
|
||||
Array.prototype.shift = originalShift
|
||||
}
|
||||
|
||||
expect(preview.limited).toBe(true)
|
||||
expect(preview.tail.at(-1)).toBe(lines.at(-1))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ function makeSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings {
|
|||
terminalFontSize: 14,
|
||||
terminalFontWeight: 400,
|
||||
terminalLineHeight: 1,
|
||||
terminalCursorStyle: 'bar',
|
||||
terminalCursorStyle: 'block',
|
||||
terminalCursorBlink: true,
|
||||
terminalLigatures: 'off',
|
||||
terminalThemeDark: 'Dark',
|
||||
|
|
@ -202,8 +202,8 @@ describe('TerminalSettingsPreview terminal lifecycle', () => {
|
|||
allowTransparency: false,
|
||||
cols: 36,
|
||||
cursorBlink: true,
|
||||
cursorInactiveStyle: 'bar',
|
||||
cursorStyle: 'bar',
|
||||
cursorInactiveStyle: 'block',
|
||||
cursorStyle: 'block',
|
||||
disableStdin: true,
|
||||
fontFamily: 'built:SF Mono',
|
||||
fontSize: 14,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) &&
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -1,31 +1,10 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
setActiveTerminalOutputTarget: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({
|
||||
setActiveTerminalOutputTarget: mocks.setActiveTerminalOutputTarget
|
||||
}))
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
reportActiveRendererPtyForPane,
|
||||
shouldDetachPaneTransportOnUnmount,
|
||||
splitPaneWithOneShotStartup,
|
||||
suppressIntentionalPaneCloseExit
|
||||
} from './use-terminal-pane-lifecycle'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
;(globalThis as unknown as { window: unknown }).window = {
|
||||
api: {
|
||||
pty: {
|
||||
setActiveRendererPty: vi.fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('splitPaneWithOneShotStartup', () => {
|
||||
it('only exposes startup to the intentional split and clears it afterwards', () => {
|
||||
const deps: { startup?: { command: string; env?: Record<string, string> } | null } = {
|
||||
|
|
@ -167,52 +146,3 @@ describe('suppressIntentionalPaneCloseExit', () => {
|
|||
expect(suppressPtyExit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('reportActiveRendererPtyForPane', () => {
|
||||
it('marks the active visible pane as the renderer output target', () => {
|
||||
const terminalA = { name: 'terminal-a' }
|
||||
const terminalB = { name: 'terminal-b' }
|
||||
const manager = {
|
||||
getPanes: vi.fn(() => [
|
||||
{ id: 1, terminal: terminalA },
|
||||
{ id: 2, terminal: terminalB }
|
||||
])
|
||||
}
|
||||
const paneTransports = new Map([
|
||||
[1, { getPtyId: vi.fn(() => 'pty-1') }],
|
||||
[2, { getPtyId: vi.fn(() => 'remote:env@@pty-2') }]
|
||||
])
|
||||
|
||||
reportActiveRendererPtyForPane(paneTransports as never, manager as never, 2, true)
|
||||
|
||||
expect(mocks.setActiveTerminalOutputTarget).toHaveBeenCalledWith(terminalA, false)
|
||||
expect(mocks.setActiveTerminalOutputTarget).toHaveBeenCalledWith(terminalB, true)
|
||||
expect(window.api.pty.setActiveRendererPty).toHaveBeenCalledWith('pty-1', false)
|
||||
expect(window.api.pty.setActiveRendererPty).not.toHaveBeenCalledWith(
|
||||
'remote:env@@pty-2',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('clears every renderer output target while hidden or inactive', () => {
|
||||
const terminalA = { name: 'terminal-a' }
|
||||
const terminalB = { name: 'terminal-b' }
|
||||
const manager = {
|
||||
getPanes: vi.fn(() => [
|
||||
{ id: 1, terminal: terminalA },
|
||||
{ id: 2, terminal: terminalB }
|
||||
])
|
||||
}
|
||||
const paneTransports = new Map([
|
||||
[1, { getPtyId: vi.fn(() => 'pty-1') }],
|
||||
[2, { getPtyId: vi.fn(() => 'pty-2') }]
|
||||
])
|
||||
|
||||
reportActiveRendererPtyForPane(paneTransports as never, manager as never, 2, false)
|
||||
|
||||
expect(mocks.setActiveTerminalOutputTarget).toHaveBeenCalledWith(terminalA, false)
|
||||
expect(mocks.setActiveTerminalOutputTarget).toHaveBeenCalledWith(terminalB, false)
|
||||
expect(window.api.pty.setActiveRendererPty).toHaveBeenCalledWith('pty-1', false)
|
||||
expect(window.api.pty.setActiveRendererPty).toHaveBeenCalledWith('pty-2', false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -61,7 +61,6 @@ import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-str
|
|||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { isPaneReplaying, type ReplayingPanesRef } from './replay-guard'
|
||||
import { fitAndFocusPanes, fitPanes } from './pane-helpers'
|
||||
import { setActiveTerminalOutputTarget } from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
|
||||
import { e2eConfig } from '@/lib/e2e-config'
|
||||
import {
|
||||
|
|
@ -94,22 +93,16 @@ function extractUncHost(value: string | undefined): string | null {
|
|||
return match?.[1] || null
|
||||
}
|
||||
|
||||
export function reportActiveRendererPtyForPane(
|
||||
function reportActiveRendererPtyForPane(
|
||||
paneTransports: Map<number, PtyTransport>,
|
||||
manager: PaneManager | null,
|
||||
activePaneId: number | null,
|
||||
activeAllowed: boolean
|
||||
activePaneId: number | null
|
||||
): void {
|
||||
const activeTargetPaneId = activeAllowed ? activePaneId : null
|
||||
for (const pane of manager?.getPanes() ?? []) {
|
||||
setActiveTerminalOutputTarget(pane.terminal, activeTargetPaneId === pane.id)
|
||||
}
|
||||
for (const [paneId, transport] of paneTransports) {
|
||||
const ptyId = transport.getPtyId()
|
||||
if (!ptyId || ptyId.startsWith('remote:')) {
|
||||
continue
|
||||
}
|
||||
window.api.pty.setActiveRendererPty?.(ptyId, activeTargetPaneId === paneId)
|
||||
window.api.pty.setActiveRendererPty?.(ptyId, activePaneId === paneId)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -829,9 +822,6 @@ export function useTerminalPaneLifecycle({
|
|||
queueResizeAll(true)
|
||||
},
|
||||
onPaneClosed: (paneId, closedPane) => {
|
||||
if (closedPane?.terminal) {
|
||||
setActiveTerminalOutputTarget(closedPane.terminal, false)
|
||||
}
|
||||
const linkProviderDisposable = linkProviderDisposablesRef.current.get(paneId)
|
||||
if (linkProviderDisposable) {
|
||||
linkProviderDisposable.dispose()
|
||||
|
|
@ -891,7 +881,6 @@ export function useTerminalPaneLifecycle({
|
|||
mouseHideDisposablesRef.current.delete(paneId)
|
||||
}
|
||||
const transport = paneTransportsRef.current.get(paneId)
|
||||
const closingPtyId = transport?.getPtyId() ?? null
|
||||
const panePtyBinding = panePtyBindings.get(paneId)
|
||||
if (panePtyBinding) {
|
||||
panePtyBinding.dispose()
|
||||
|
|
@ -914,9 +903,6 @@ export function useTerminalPaneLifecycle({
|
|||
transport,
|
||||
useAppStore.getState().suppressPtyExit
|
||||
)
|
||||
if (closingPtyId && !closingPtyId.startsWith('remote:')) {
|
||||
window.api.pty.setActiveRendererPty?.(closingPtyId, false)
|
||||
}
|
||||
if (ptyId) {
|
||||
// Why: user/CLI pane closes intentionally tear down this PTY after
|
||||
// PaneManager has already promoted the sibling. Suppress that exit
|
||||
|
|
@ -957,19 +943,12 @@ export function useTerminalPaneLifecycle({
|
|||
// stay stuck on the closed pane's last title.
|
||||
const newActivePane = managerRef.current?.getActivePane()
|
||||
if (newActivePane) {
|
||||
reportActiveRendererPtyForPane(
|
||||
paneTransportsRef.current,
|
||||
managerRef.current,
|
||||
newActivePane.id,
|
||||
isActiveRef.current && isVisibleRef.current
|
||||
)
|
||||
reportActiveRendererPtyForPane(paneTransportsRef.current, newActivePane.id)
|
||||
const paneTitles = useAppStore.getState().runtimePaneTitlesByTabId[tabId] ?? {}
|
||||
const activeTitle = paneTitles[newActivePane.id]
|
||||
if (activeTitle) {
|
||||
updateTabTitle(tabId, activeTitle)
|
||||
}
|
||||
} else {
|
||||
reportActiveRendererPtyForPane(paneTransportsRef.current, managerRef.current, null, false)
|
||||
}
|
||||
scheduleRuntimeGraphSync()
|
||||
},
|
||||
|
|
@ -978,12 +957,7 @@ export function useTerminalPaneLifecycle({
|
|||
if (shouldPersistLayout) {
|
||||
persistLayoutSnapshot()
|
||||
}
|
||||
reportActiveRendererPtyForPane(
|
||||
paneTransportsRef.current,
|
||||
managerRef.current,
|
||||
pane.id,
|
||||
isActiveRef.current && isVisibleRef.current
|
||||
)
|
||||
reportActiveRendererPtyForPane(paneTransportsRef.current, pane.id)
|
||||
// Why: when the user switches focus between split panes, update the
|
||||
// tab title to the newly active pane's last-known title so the tab
|
||||
// label reflects the focused agent — not a stale title from the
|
||||
|
|
@ -1016,7 +990,7 @@ export function useTerminalPaneLifecycle({
|
|||
terminalOptions: () => {
|
||||
const currentSettings = settingsRef.current
|
||||
const terminalFontWeights = resolveTerminalFontWeights(currentSettings?.terminalFontWeight)
|
||||
const cursorStyle = currentSettings?.terminalCursorStyle ?? 'bar'
|
||||
const cursorStyle = currentSettings?.terminalCursorStyle ?? 'block'
|
||||
const storeState = useAppStore.getState()
|
||||
const currentTab = storeState.tabsByWorktree[worktreeId]?.find(
|
||||
(candidate) => candidate.id === tabId
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
export function attachDomRendererFocusClassSync(
|
||||
terminalElement: HTMLElement | undefined
|
||||
): () => void {
|
||||
if (!terminalElement) {
|
||||
return () => undefined
|
||||
}
|
||||
|
||||
const sync = (): void => {
|
||||
const rows = terminalElement.querySelector<HTMLElement>('.xterm-rows')
|
||||
if (!rows) {
|
||||
return
|
||||
}
|
||||
// Why: xterm 6 can leave the root focused while the DOM renderer rows miss
|
||||
// xterm-focus; its cursor blink CSS keys off the rows class.
|
||||
rows.classList.toggle('xterm-focus', terminalElement.classList.contains('focus'))
|
||||
}
|
||||
|
||||
const scheduleSync = (): void => {
|
||||
sync()
|
||||
requestAnimationFrame(sync)
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(scheduleSync)
|
||||
observer.observe(terminalElement, { attributes: true, attributeFilter: ['class'] })
|
||||
terminalElement.addEventListener('focusin', scheduleSync)
|
||||
terminalElement.addEventListener('focusout', scheduleSync)
|
||||
scheduleSync()
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
terminalElement.removeEventListener('focusin', scheduleSync)
|
||||
terminalElement.removeEventListener('focusout', scheduleSync)
|
||||
}
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import type { TerminalLeafId } from '../../../../shared/stable-pane-id'
|
|||
const captureScrollState = vi.hoisted(() => vi.fn())
|
||||
const wrapInSplit = vi.hoisted(() => vi.fn())
|
||||
const openTerminal = vi.hoisted(() => vi.fn())
|
||||
const disposePane = vi.hoisted(() => vi.fn())
|
||||
const disposeWebgl = vi.hoisted(() => vi.fn())
|
||||
const clearPendingSplitScrollRestore = vi.hoisted(() => vi.fn())
|
||||
const scheduleSplitScrollRestore = vi.hoisted(() => vi.fn())
|
||||
|
|
@ -23,7 +22,7 @@ vi.mock('./pane-tree-ops', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('./pane-lifecycle', () => ({
|
||||
disposePane,
|
||||
disposePane: vi.fn(),
|
||||
openTerminal
|
||||
}))
|
||||
|
||||
|
|
@ -46,7 +45,6 @@ vi.mock('./pane-divider', () => ({
|
|||
}))
|
||||
|
||||
import { splitManagedPane } from './pane-split-close'
|
||||
import { closeManagedPane } from './pane-split-close'
|
||||
|
||||
const TEST_LEAF_ID = '11111111-1111-4111-8111-111111111111' as TerminalLeafId
|
||||
|
||||
|
|
@ -55,7 +53,6 @@ class MockElement {
|
|||
dataset: Record<string, string> = {}
|
||||
parentElement: MockElement | null = null
|
||||
style: Record<string, string> = {}
|
||||
remove = vi.fn()
|
||||
private descendants: MockElement[] = []
|
||||
|
||||
constructor(private readonly classNames: string[]) {
|
||||
|
|
@ -119,11 +116,6 @@ function createPane(id: number, webglAddon: unknown): ManagedPaneInternal {
|
|||
describe('splitManagedPane', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
disposePane.mockImplementation(
|
||||
(pane: ManagedPaneInternal, panes: Map<number, ManagedPaneInternal>) => {
|
||||
panes.delete(pane.id)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('prepares every pane under a moved mounted subtree for split reparenting', () => {
|
||||
|
|
@ -201,44 +193,3 @@ describe('splitManagedPane', () => {
|
|||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeManagedPane', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
disposePane.mockImplementation(
|
||||
(pane: ManagedPaneInternal, panes: Map<number, ManagedPaneInternal>) => {
|
||||
panes.delete(pane.id)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('reports the closed terminal in the close callback', () => {
|
||||
const closedPane = createPane(1, null)
|
||||
const siblingPane = createPane(2, null)
|
||||
const root = new MockElement(['root'])
|
||||
;(closedPane.container as unknown as MockElement).parentElement = root
|
||||
const panes = new Map<number, ManagedPaneInternal>([
|
||||
[closedPane.id, closedPane],
|
||||
[siblingPane.id, siblingPane]
|
||||
])
|
||||
const onPaneClosed = vi.fn()
|
||||
|
||||
closeManagedPane({
|
||||
paneId: closedPane.id,
|
||||
activePaneId: closedPane.id,
|
||||
panes,
|
||||
root: root as unknown as HTMLElement,
|
||||
styleOptions: {},
|
||||
managerOptions: { onPaneClosed },
|
||||
getDragCallbacks: () => ({}) as never,
|
||||
releasePaneIdentity: vi.fn(),
|
||||
setActivePaneId: vi.fn()
|
||||
})
|
||||
|
||||
expect(onPaneClosed).toHaveBeenCalledWith(closedPane.id, {
|
||||
paneId: closedPane.id,
|
||||
leafId: closedPane.leafId,
|
||||
terminal: closedPane.terminal
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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?.()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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?.()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -84,7 +84,6 @@ const BACKGROUND_BACKLOG_WARNING =
|
|||
'\x18\x1b[0m\r\n[Orca skipped hidden terminal output because the backlog exceeded 2 MB.]\r\n'
|
||||
|
||||
const queuedByTerminal = new Map<TerminalOutputTarget, QueueEntry>()
|
||||
const activeOutputTargets = new WeakSet<TerminalOutputTarget>()
|
||||
const backlogRecoveryByTerminal = new WeakMap<
|
||||
TerminalOutputTarget,
|
||||
TerminalBacklogRecoveryRequest
|
||||
|
|
@ -526,13 +525,6 @@ function hasDrainableBacklog(): boolean {
|
|||
}
|
||||
|
||||
function takeNextDrainableEntry(): QueueEntry | null {
|
||||
for (const entry of queuedByTerminal.values()) {
|
||||
if (!activeOutputTargets.has(entry.terminal) || !isEntryDrainable(entry)) {
|
||||
continue
|
||||
}
|
||||
queuedByTerminal.delete(entry.terminal)
|
||||
return entry
|
||||
}
|
||||
for (const entry of queuedByTerminal.values()) {
|
||||
if (!isEntryDrainable(entry)) {
|
||||
continue
|
||||
|
|
@ -624,17 +616,6 @@ function drainQueuedOutput(): void {
|
|||
}
|
||||
}
|
||||
|
||||
export function setActiveTerminalOutputTarget(
|
||||
terminal: TerminalOutputTarget,
|
||||
active: boolean
|
||||
): void {
|
||||
if (active) {
|
||||
activeOutputTargets.add(terminal)
|
||||
} else {
|
||||
activeOutputTargets.delete(terminal)
|
||||
}
|
||||
}
|
||||
|
||||
export function writeTerminalOutput(
|
||||
terminal: TerminalOutputTarget,
|
||||
data: string,
|
||||
|
|
@ -931,7 +912,6 @@ export function waitForTerminalOutputParsed(terminal: TerminalOutputTarget): Pro
|
|||
export function discardTerminalOutput(terminal: TerminalOutputTarget): void {
|
||||
exposeDebugApi()
|
||||
queuedByTerminal.delete(terminal)
|
||||
activeOutputTargets.delete(terminal)
|
||||
discardForegroundRenderSettle(terminal)
|
||||
recordQueueDebugPressure()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import { relativePathInsideRoot } from '../../../shared/cross-platform-path'
|
|||
import { toRuntimeWorktreeSelector } from '../runtime/runtime-worktree-selector'
|
||||
import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection'
|
||||
import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../shared/auto-rename-branch-from-work-settings'
|
||||
import { normalizeTerminalCursorStyleDefault } from '../../../shared/terminal-cursor-style-settings'
|
||||
import type { RateLimitState } from '../../../shared/rate-limit-types'
|
||||
import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../../../shared/runtime-types'
|
||||
import {
|
||||
|
|
@ -2413,13 +2414,17 @@ function getStoredSettings(): GlobalSettings {
|
|||
const stored = readJson<Partial<GlobalSettings>>(SETTINGS_STORAGE_KEY, {})
|
||||
const migratedStored = {
|
||||
...stored,
|
||||
...normalizeAutoRenameBranchFromWorkDefaultOn(stored)
|
||||
...normalizeAutoRenameBranchFromWorkDefaultOn(stored),
|
||||
...normalizeTerminalCursorStyleDefault(stored)
|
||||
}
|
||||
if (
|
||||
rawStoredSettings &&
|
||||
(stored.autoRenameBranchFromWork !== migratedStored.autoRenameBranchFromWork ||
|
||||
stored.autoRenameBranchFromWorkDefaultedOn !==
|
||||
migratedStored.autoRenameBranchFromWorkDefaultedOn)
|
||||
migratedStored.autoRenameBranchFromWorkDefaultedOn ||
|
||||
stored.terminalCursorStyle !== migratedStored.terminalCursorStyle ||
|
||||
stored.terminalCursorStyleDefaultedToBlock !==
|
||||
migratedStored.terminalCursorStyleDefaultedToBlock)
|
||||
) {
|
||||
try {
|
||||
const parsed = JSON.parse(rawStoredSettings) as unknown
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
import type { GlobalSettings } from './types'
|
||||
|
||||
type TerminalCursorStyleSettings = Pick<
|
||||
GlobalSettings,
|
||||
'terminalCursorStyle' | 'terminalCursorStyleDefaultedToBlock'
|
||||
>
|
||||
|
||||
export function normalizeTerminalCursorStyleDefault(
|
||||
settings: Partial<TerminalCursorStyleSettings> | undefined,
|
||||
options: { preserveExplicitValue?: boolean } = {}
|
||||
): TerminalCursorStyleSettings {
|
||||
const defaultedToBlock =
|
||||
settings?.terminalCursorStyleDefaultedToBlock === true || options.preserveExplicitValue === true
|
||||
|
||||
return {
|
||||
// Why: prior builds persisted the old bar default into profiles; migrate
|
||||
// those inherited values once while preserving later explicit choices.
|
||||
terminalCursorStyle: defaultedToBlock ? (settings?.terminalCursorStyle ?? 'block') : 'block',
|
||||
terminalCursorStyleDefaultedToBlock: true
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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('')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,232 @@
|
|||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import path from 'node:path'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
getTerminalContent,
|
||||
sendToTerminal,
|
||||
waitForActivePanePtyId,
|
||||
waitForActiveTerminalManager
|
||||
} from './helpers/terminal'
|
||||
import {
|
||||
analyzeRasterCursorCells,
|
||||
type TerminalRasterProbeTarget
|
||||
} from './terminal-cursor-raster-probe'
|
||||
|
||||
const CODEX_READY_RE = /Ask Codex|OpenAI/i
|
||||
const CODEX_TRUST_PROMPT_RE = /Do you trust|trust this folder|Trust this/i
|
||||
const CODEX_UPDATE_PROMPT_RE = /update available|install update|Skip for now/i
|
||||
const MAX_MEDIAN_KEY_LATENCY_MS = 150
|
||||
const MAX_WORST_KEY_LATENCY_MS = 500
|
||||
|
||||
type CodexCursorBlinkSample = {
|
||||
elapsedMs: number
|
||||
paintedCursorCellCount: number
|
||||
}
|
||||
|
||||
async function focusActiveTerminalInput(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const state = window.__store?.getState()
|
||||
const worktreeId = state?.activeWorktreeId
|
||||
const tabId =
|
||||
state?.activeTabType === 'terminal'
|
||||
? state.activeTabId
|
||||
: worktreeId
|
||||
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
|
||||
: null
|
||||
const manager = tabId ? window.__paneManagers?.get(tabId) : null
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
const textarea = pane?.container.querySelector<HTMLTextAreaElement>('.xterm-helper-textarea')
|
||||
if (!pane || !textarea) {
|
||||
throw new Error('Active terminal input is unavailable')
|
||||
}
|
||||
pane.terminal.focus()
|
||||
textarea.focus()
|
||||
})
|
||||
}
|
||||
|
||||
async function forceCursorProbeTheme(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const state = window.__store?.getState()
|
||||
const worktreeId = state?.activeWorktreeId
|
||||
const tabId =
|
||||
state?.activeTabType === 'terminal'
|
||||
? state.activeTabId
|
||||
: worktreeId
|
||||
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
|
||||
: null
|
||||
const manager = tabId ? window.__paneManagers?.get(tabId) : null
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
if (!pane) {
|
||||
throw new Error('Active terminal pane is unavailable')
|
||||
}
|
||||
pane.terminal.options.cursorStyle = 'block'
|
||||
pane.terminal.options.cursorBlink = true
|
||||
pane.terminal.options.theme = {
|
||||
...pane.terminal.options.theme,
|
||||
cursor: '#23ff45',
|
||||
cursorAccent: '#001000'
|
||||
}
|
||||
pane.terminal.focus()
|
||||
pane.terminal.refresh(0, pane.terminal.rows - 1)
|
||||
})
|
||||
}
|
||||
|
||||
async function readActiveTerminalRasterTarget(page: Page): Promise<TerminalRasterProbeTarget> {
|
||||
return page.evaluate(() => {
|
||||
const state = window.__store?.getState()
|
||||
const worktreeId = state?.activeWorktreeId
|
||||
const tabId =
|
||||
state?.activeTabType === 'terminal'
|
||||
? state.activeTabId
|
||||
: worktreeId
|
||||
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
|
||||
: null
|
||||
const manager = tabId ? window.__paneManagers?.get(tabId) : null
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
const screen = pane?.container.querySelector<HTMLElement>('.xterm-screen')
|
||||
const dimensions = pane?.terminal._core?._renderService?.dimensions?.css?.cell
|
||||
if (!pane || !screen || !dimensions) {
|
||||
throw new Error('Active terminal screen is unavailable')
|
||||
}
|
||||
const rect = screen.getBoundingClientRect()
|
||||
return {
|
||||
clip: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||
cellWidth: dimensions.width,
|
||||
cellHeight: dimensions.height,
|
||||
rows: pane.terminal.rows,
|
||||
cols: pane.terminal.cols
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function sampleCursorBlink(page: Page): Promise<CodexCursorBlinkSample[]> {
|
||||
const samples: CodexCursorBlinkSample[] = []
|
||||
const target = await readActiveTerminalRasterTarget(page)
|
||||
const viewport = page.viewportSize() ?? undefined
|
||||
const start = performance.now()
|
||||
for (let index = 0; index < 9; index += 1) {
|
||||
if (index > 0) {
|
||||
await page.waitForTimeout(200)
|
||||
}
|
||||
const screenshot = await page.screenshot()
|
||||
const cells = analyzeRasterCursorCells(Buffer.from(screenshot), target, viewport)
|
||||
samples.push({
|
||||
elapsedMs: performance.now() - start,
|
||||
paintedCursorCellCount: cells.length
|
||||
})
|
||||
}
|
||||
return samples
|
||||
}
|
||||
|
||||
async function dismissCodexPromptsIfPresent(page: Page): Promise<void> {
|
||||
const deadline = Date.now() + 15_000
|
||||
while (Date.now() < deadline) {
|
||||
const content = await getTerminalContent(page, 12_000)
|
||||
if (CODEX_READY_RE.test(content) && !CODEX_TRUST_PROMPT_RE.test(content)) {
|
||||
return
|
||||
}
|
||||
if (CODEX_TRUST_PROMPT_RE.test(content)) {
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(300)
|
||||
continue
|
||||
}
|
||||
if (CODEX_UPDATE_PROMPT_RE.test(content)) {
|
||||
await page.keyboard.type('3')
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(300)
|
||||
continue
|
||||
}
|
||||
await page.waitForTimeout(250)
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForCodexReady(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => CODEX_READY_RE.test(await getTerminalContent(page, 12_000)), {
|
||||
timeout: 45_000,
|
||||
message: 'Codex TUI did not render'
|
||||
})
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
async function waitForPromptText(page: Page, text: string): Promise<number> {
|
||||
const start = performance.now()
|
||||
while (performance.now() - start < MAX_WORST_KEY_LATENCY_MS) {
|
||||
if ((await getTerminalContent(page, 12_000)).includes(text)) {
|
||||
return performance.now() - start
|
||||
}
|
||||
await page.waitForTimeout(5)
|
||||
}
|
||||
throw new Error(`Codex prompt did not show ${text}`)
|
||||
}
|
||||
|
||||
function median(values: number[]): number {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
return sorted[Math.floor(sorted.length / 2)] ?? 0
|
||||
}
|
||||
|
||||
test.describe('local Codex terminal typing latency', () => {
|
||||
test('keeps Codex prompt typing responsive @local-real-codex', async ({ orcaPage }, testInfo) => {
|
||||
test.skip(
|
||||
process.env.ORCA_E2E_REAL_CODEX !== '1',
|
||||
'Set ORCA_E2E_REAL_CODEX=1 to exercise the locally installed Codex TUI'
|
||||
)
|
||||
test.skip(process.platform === 'win32', 'local Codex command is POSIX-shell oriented')
|
||||
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage)
|
||||
const codexSource = path.join(process.env.HOME ?? '', 'projects', 'codex')
|
||||
const launchCommand =
|
||||
`cd ${JSON.stringify(codexSource)} && ` +
|
||||
'codex --dangerously-bypass-approvals-and-sandbox --dangerously-bypass-hook-trust\r'
|
||||
|
||||
try {
|
||||
await sendToTerminal(orcaPage, ptyId, launchCommand)
|
||||
await dismissCodexPromptsIfPresent(orcaPage)
|
||||
await waitForCodexReady(orcaPage)
|
||||
await focusActiveTerminalInput(orcaPage)
|
||||
await forceCursorProbeTheme(orcaPage)
|
||||
const blinkSamples = await sampleCursorBlink(orcaPage)
|
||||
|
||||
const runId = randomUUID().replaceAll('-', '').slice(0, 8)
|
||||
const prompt = `orca_codex_latency_${runId}`
|
||||
const latencies: number[] = []
|
||||
let typed = ''
|
||||
for (const char of prompt) {
|
||||
typed += char
|
||||
const start = performance.now()
|
||||
await orcaPage.keyboard.type(char)
|
||||
await waitForPromptText(orcaPage, typed)
|
||||
latencies.push(performance.now() - start)
|
||||
}
|
||||
|
||||
const medianLatency = median(latencies)
|
||||
const worstLatency = Math.max(...latencies)
|
||||
testInfo.annotations.push({
|
||||
type: 'codex-local-typing-latency',
|
||||
description: `median=${medianLatency.toFixed(1)}ms worst=${worstLatency.toFixed(
|
||||
1
|
||||
)}ms samples=${latencies.map((value) => value.toFixed(1)).join(',')}`
|
||||
})
|
||||
testInfo.annotations.push({
|
||||
type: 'codex-local-cursor-blink',
|
||||
description: blinkSamples
|
||||
.map((sample) => `${sample.elapsedMs.toFixed(0)}ms:${sample.paintedCursorCellCount}`)
|
||||
.join(',')
|
||||
})
|
||||
|
||||
expect(blinkSamples.some((sample) => sample.paintedCursorCellCount > 0)).toBe(true)
|
||||
expect(blinkSamples.some((sample) => sample.paintedCursorCellCount === 0)).toBe(true)
|
||||
expect(medianLatency).toBeLessThan(MAX_MEDIAN_KEY_LATENCY_MS)
|
||||
expect(worstLatency).toBeLessThan(MAX_WORST_KEY_LATENCY_MS)
|
||||
} finally {
|
||||
await sendToTerminal(orcaPage, ptyId, '\x03').catch(() => undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -30,14 +30,6 @@ type HiddenTuiWindow = Window & {
|
|||
hiddenRendererMode2031ReplyCount: number
|
||||
}
|
||||
}
|
||||
__terminalHiddenSnapshotOverride?: {
|
||||
setPending: (
|
||||
ptyId: string,
|
||||
snapshot: { data: string; cols: number; rows: number; seq: number; source: 'headless' }
|
||||
) => void
|
||||
resolve: (ptyId: string) => void
|
||||
clear: (ptyId: string) => void
|
||||
}
|
||||
}
|
||||
|
||||
type HiddenTuiDebugSnapshot = {
|
||||
|
|
@ -46,24 +38,46 @@ type HiddenTuiDebugSnapshot = {
|
|||
hiddenRendererMode2031ReplyCount: number
|
||||
}
|
||||
|
||||
type TuiCursorState = {
|
||||
hidden: boolean | null
|
||||
initialized: boolean | null
|
||||
cursorElementVisible: boolean
|
||||
cursorCanvasPresent: boolean
|
||||
}
|
||||
|
||||
function tuiFrame(runId: string, frame: number): string {
|
||||
const progress = `${'█'.repeat((frame % 8) + 1)}${'░'.repeat(8 - ((frame % 8) + 1))}`
|
||||
const rows = [
|
||||
`OpenCode visual restore ${runId}`,
|
||||
`Frame ${String(frame).padStart(3, '0')}`,
|
||||
`Status ${frame % 2 === 0 ? 'thinking' : 'streaming'}`,
|
||||
`Input echo ${'#'.repeat((frame % 18) + 1)}`,
|
||||
`Diff +${frame * 3} -${frame}`,
|
||||
'╭────────────────────────────────────────────────────────────────────╮',
|
||||
`│ OpenCode visual restore Frame ${String(frame).padStart(3, '0')} ${frame % 2 === 0 ? '🟢' : '🟡'} ${progress} │`,
|
||||
'├──────────────┬──────────────────────┬──────────────────────────────┤',
|
||||
`│ model │ codex/opencode │ ${runId.slice(0, 28).padEnd(28)} │`,
|
||||
`│ status │ ${frame % 2 === 0 ? 'thinking' : 'streaming'} │ input ${'#'.repeat((frame % 18) + 1).padEnd(22)} │`,
|
||||
`│ diff │ +${String(frame * 3).padEnd(19)} │ -${String(frame).padEnd(27)} │`,
|
||||
'╰──────────────┴──────────────────────┴──────────────────────────────╯',
|
||||
`VISUAL_RESTORE_FINAL_${runId}_${frame}`
|
||||
]
|
||||
return [
|
||||
'\x1b[?2026h',
|
||||
'\x1b[?1049h',
|
||||
'\x1b[2J\x1b[H',
|
||||
'\x1b[?25l',
|
||||
rows.map((row) => `\x1b[2;36m${row}\x1b[0m`).join('\r\n'),
|
||||
'\x1b[10;18H\x1b[?25h',
|
||||
'\x1b[?2026l'
|
||||
].join('')
|
||||
}
|
||||
|
||||
function lowRiskRestoreFrame(runId: string, frame: number): string {
|
||||
const rows = [
|
||||
`LOW_RISK_RESTORE_FRAME_${runId}_${frame}`,
|
||||
`status=${frame % 2 === 0 ? 'thinking' : 'streaming'}`,
|
||||
`progress=${String(frame).padStart(3, '0')}`,
|
||||
`VISUAL_RESTORE_FINAL_${runId}_${frame}`
|
||||
]
|
||||
return `${rows.join('\r\n')}\r\n`
|
||||
}
|
||||
|
||||
async function resetHiddenDebug(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
;(window as HiddenTuiWindow).__terminalPtyOutputDebug?.reset()
|
||||
|
|
@ -85,6 +99,48 @@ async function readHiddenDebug(page: Page): Promise<HiddenTuiDebugSnapshot | nul
|
|||
})
|
||||
}
|
||||
|
||||
async function readTuiCursorState(page: Page): Promise<TuiCursorState> {
|
||||
return page.evaluate(() => {
|
||||
const store = window.__store
|
||||
const state = store?.getState()
|
||||
const worktreeId = state?.activeWorktreeId
|
||||
const tabId =
|
||||
state?.activeTabType === 'terminal'
|
||||
? state.activeTabId
|
||||
: worktreeId
|
||||
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
|
||||
: null
|
||||
const manager = tabId ? window.__paneManagers?.get(tabId) : null
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
if (!pane) {
|
||||
throw new Error('Active terminal pane is unavailable')
|
||||
}
|
||||
const terminalCore = (
|
||||
pane.terminal as unknown as {
|
||||
_core?: { coreService?: { isCursorHidden?: boolean; isCursorInitialized?: boolean } }
|
||||
}
|
||||
)._core
|
||||
const cursorElement = pane.container.querySelector<HTMLElement>('.xterm-cursor')
|
||||
const cursorRect = cursorElement?.getBoundingClientRect()
|
||||
const cursorStyle = cursorElement ? window.getComputedStyle(cursorElement) : null
|
||||
return {
|
||||
hidden: terminalCore?.coreService?.isCursorHidden ?? null,
|
||||
initialized: terminalCore?.coreService?.isCursorInitialized ?? null,
|
||||
// Why: a blinking DOM cursor may be transparent during the sampled frame;
|
||||
// disappearance regressions remove the laid-out cursor element/layer.
|
||||
cursorElementVisible:
|
||||
!!cursorElement &&
|
||||
!!cursorRect &&
|
||||
cursorRect.width > 0 &&
|
||||
cursorRect.height > 0 &&
|
||||
cursorStyle?.display !== 'none' &&
|
||||
cursorStyle?.visibility !== 'hidden',
|
||||
cursorCanvasPresent:
|
||||
pane.container.querySelector<HTMLCanvasElement>('.xterm-cursor-layer canvas') !== null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function injectPaneData(
|
||||
page: Page,
|
||||
paneKey: string,
|
||||
|
|
@ -102,31 +158,6 @@ async function injectPaneData(
|
|||
}
|
||||
}
|
||||
|
||||
async function installDelayedMainSnapshot(
|
||||
page: Page,
|
||||
ptyId: string,
|
||||
snapshot: { data: string; cols: number; rows: number; seq: number; source: 'headless' }
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ ptyId, snapshot }) => {
|
||||
;(window as HiddenTuiWindow).__terminalHiddenSnapshotOverride?.setPending(ptyId, snapshot)
|
||||
},
|
||||
{ ptyId, snapshot }
|
||||
)
|
||||
}
|
||||
|
||||
async function resolveDelayedMainSnapshot(page: Page, ptyId: string): Promise<void> {
|
||||
await page.evaluate((ptyId) => {
|
||||
;(window as HiddenTuiWindow).__terminalHiddenSnapshotOverride?.resolve(ptyId)
|
||||
}, ptyId)
|
||||
}
|
||||
|
||||
async function clearDelayedMainSnapshot(page: Page, ptyId: string): Promise<void> {
|
||||
await page.evaluate((ptyId) => {
|
||||
;(window as HiddenTuiWindow).__terminalHiddenSnapshotOverride?.clear(ptyId)
|
||||
}, ptyId)
|
||||
}
|
||||
|
||||
async function readMainSnapshotSource(
|
||||
page: Page,
|
||||
ptyId: string
|
||||
|
|
@ -178,7 +209,7 @@ async function writeHiddenSideEffectBurst(
|
|||
}
|
||||
|
||||
test.describe('Hidden terminal TUI visual restore', () => {
|
||||
test('restores skipped hidden full-screen TUI output without visible corruption', async ({
|
||||
test('restores hidden full-screen TUI output without visible corruption', async ({
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}, testInfo: TestInfo) => {
|
||||
|
|
@ -218,15 +249,9 @@ test.describe('Hidden terminal TUI visual restore', () => {
|
|||
await expect
|
||||
.poll(async () => (await readHiddenDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, {
|
||||
timeout: 10_000,
|
||||
message: 'hidden TUI output did not exercise the skipped-renderer path'
|
||||
message: 'visually rich hidden TUI output should stay on the live xterm path'
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
await expect
|
||||
.poll(() => readMainSnapshotSource(orcaPage, hiddenPane.ptyId!), {
|
||||
timeout: 10_000,
|
||||
message: 'hidden TUI restore did not use the runtime headless snapshot'
|
||||
})
|
||||
.toBe('headless')
|
||||
.toBe(0)
|
||||
|
||||
await switchToWorktree(orcaPage, secondWorktreeId)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
|
|
@ -241,7 +266,21 @@ test.describe('Hidden terminal TUI visual restore', () => {
|
|||
|
||||
const content = await getTerminalContent(orcaPage, 12_000)
|
||||
expect(content).toContain(`Frame 024`)
|
||||
expect(content).toContain('╭')
|
||||
expect(content).toContain('├')
|
||||
expect(content).toContain('█')
|
||||
expect(content).not.toContain('Orca skipped hidden terminal output')
|
||||
await expect
|
||||
.poll(() => readTuiCursorState(orcaPage), {
|
||||
timeout: 5_000,
|
||||
message: 'restored TUI cursor stayed hidden after final frame'
|
||||
})
|
||||
.toMatchObject({
|
||||
hidden: false,
|
||||
initialized: true
|
||||
})
|
||||
const cursorState = await readTuiCursorState(orcaPage)
|
||||
expect(cursorState.cursorElementVisible || cursorState.cursorCanvasPresent).toBe(true)
|
||||
|
||||
const screenshotPath = testInfo.outputPath('hidden-tui-restore-final.png')
|
||||
await orcaPage.screenshot({ path: screenshotPath, fullPage: true })
|
||||
|
|
@ -252,7 +291,7 @@ test.describe('Hidden terminal TUI visual restore', () => {
|
|||
rmSync(scriptPath, { force: true })
|
||||
})
|
||||
|
||||
test('keeps newer live TUI output visually correct while hidden restore is in flight', async ({
|
||||
test('keeps newer live output correct after hidden output stayed live', async ({
|
||||
orcaPage
|
||||
}, testInfo: TestInfo) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
|
|
@ -284,8 +323,8 @@ test.describe('Hidden terminal TUI visual restore', () => {
|
|||
.toBe(firstWorktreeId)
|
||||
|
||||
const runId = randomUUID()
|
||||
const hiddenFrame = tuiFrame(runId, 40)
|
||||
const liveFrame = tuiFrame(runId, 41)
|
||||
const hiddenFrame = lowRiskRestoreFrame(runId, 40)
|
||||
const liveFrame = lowRiskRestoreFrame(runId, 41)
|
||||
const finalMarker = `VISUAL_RESTORE_FINAL_${runId}_41`
|
||||
await resetHiddenDebug(orcaPage)
|
||||
await injectPaneData(orcaPage, paneKey, hiddenFrame, {
|
||||
|
|
@ -296,52 +335,50 @@ test.describe('Hidden terminal TUI visual restore', () => {
|
|||
await expect
|
||||
.poll(async () => (await readHiddenDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, {
|
||||
timeout: 10_000,
|
||||
message: 'hidden injected TUI output did not skip renderer parsing'
|
||||
message: 'hidden injected output should stay on the live xterm path for release'
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
.toBe(0)
|
||||
|
||||
await installDelayedMainSnapshot(orcaPage, hiddenPane.ptyId, {
|
||||
data: hiddenFrame,
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
seq: hiddenFrame.length,
|
||||
source: 'headless'
|
||||
await switchToWorktree(orcaPage, secondWorktreeId)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
await injectPaneData(orcaPage, paneKey, liveFrame, {
|
||||
seq: hiddenFrame.length + liveFrame.length,
|
||||
rawLength: liveFrame.length
|
||||
})
|
||||
|
||||
try {
|
||||
await switchToWorktree(orcaPage, secondWorktreeId)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
await injectPaneData(orcaPage, paneKey, liveFrame, {
|
||||
seq: hiddenFrame.length + liveFrame.length,
|
||||
rawLength: liveFrame.length
|
||||
await expect
|
||||
.poll(() => getTerminalContent(orcaPage, 12_000), {
|
||||
timeout: 10_000,
|
||||
message: 'newer live TUI frame did not render after hidden output stayed live'
|
||||
})
|
||||
await resolveDelayedMainSnapshot(orcaPage, hiddenPane.ptyId)
|
||||
.toContain(finalMarker)
|
||||
|
||||
await expect
|
||||
.poll(() => getTerminalContent(orcaPage, 12_000), {
|
||||
timeout: 10_000,
|
||||
message: 'newer live TUI frame did not render after delayed hidden snapshot'
|
||||
})
|
||||
.toContain(finalMarker)
|
||||
|
||||
const content = await getTerminalContent(orcaPage, 12_000)
|
||||
expect(content).toContain('Frame 041')
|
||||
expect(content).not.toContain('Frame 040')
|
||||
expect(content).not.toContain('Orca skipped hidden terminal output')
|
||||
|
||||
const screenshotPath = testInfo.outputPath('hidden-tui-delayed-restore-final.png')
|
||||
await orcaPage.screenshot({ path: screenshotPath, fullPage: true })
|
||||
await testInfo.attach('hidden-tui-delayed-restore-final.png', {
|
||||
path: screenshotPath,
|
||||
contentType: 'image/png'
|
||||
const content = await getTerminalContent(orcaPage, 12_000)
|
||||
expect(content).toContain(`LOW_RISK_RESTORE_FRAME_${runId}_41`)
|
||||
expect(content).toContain('progress=041')
|
||||
expect(content.indexOf(`LOW_RISK_RESTORE_FRAME_${runId}_41`)).toBeGreaterThan(
|
||||
content.indexOf(`LOW_RISK_RESTORE_FRAME_${runId}_40`)
|
||||
)
|
||||
expect(content).not.toContain('Orca skipped hidden terminal output')
|
||||
await expect
|
||||
.poll(() => readTuiCursorState(orcaPage), {
|
||||
timeout: 5_000,
|
||||
message: 'live TUI cursor stayed hidden after hidden output stayed live'
|
||||
})
|
||||
} finally {
|
||||
await clearDelayedMainSnapshot(orcaPage, hiddenPane.ptyId)
|
||||
}
|
||||
.toMatchObject({
|
||||
hidden: false,
|
||||
initialized: true
|
||||
})
|
||||
const screenshotPath = testInfo.outputPath('hidden-tui-live-output-final.png')
|
||||
await orcaPage.screenshot({ path: screenshotPath, fullPage: true })
|
||||
await testInfo.attach('hidden-tui-live-output-final.png', {
|
||||
path: screenshotPath,
|
||||
contentType: 'image/png'
|
||||
})
|
||||
})
|
||||
|
||||
test('keeps hidden terminal side effects live while renderer output is skipped', async ({
|
||||
test('keeps hidden terminal side effects live while hidden output stays live', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
|
|
@ -380,9 +417,9 @@ test.describe('Hidden terminal TUI visual restore', () => {
|
|||
await expect
|
||||
.poll(async () => (await readHiddenDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, {
|
||||
timeout: 10_000,
|
||||
message: 'hidden side-effect output did not exercise the skipped-renderer path'
|
||||
message: 'hidden side-effect output should stay on the live xterm path for release'
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
.toBe(0)
|
||||
await expect
|
||||
.poll(() => getRuntimePaneTitle(orcaPage, hiddenSnapshot.tabId, hiddenPane.numericPaneId), {
|
||||
timeout: 10_000,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,305 @@
|
|||
import { randomUUID } from 'node:crypto'
|
||||
import { rmSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { Page, TestInfo } from '@stablyai/playwright-test'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import {
|
||||
ensureTerminalVisible,
|
||||
getAllWorktreeIds,
|
||||
switchToWorktree,
|
||||
waitForActiveWorktree,
|
||||
waitForSessionReady
|
||||
} from './helpers/store'
|
||||
import {
|
||||
getTerminalContent,
|
||||
sendToTerminal,
|
||||
waitForActivePanePtyId,
|
||||
waitForActiveTerminalManager
|
||||
} from './helpers/terminal'
|
||||
|
||||
type TerminalRenderDiagnostics = {
|
||||
cols: number
|
||||
rows: number
|
||||
viewportY: number
|
||||
baseY: number
|
||||
hasComplexScriptOutput: boolean
|
||||
hasWebgl: boolean
|
||||
canvasCount: number
|
||||
cursorHidden: boolean | null
|
||||
visibleLineTails: string[]
|
||||
allPaneStates: {
|
||||
tabId: string
|
||||
paneId: number
|
||||
hasComplexScriptOutput: boolean
|
||||
hasMarker: boolean
|
||||
hasWebgl: boolean
|
||||
}[]
|
||||
}
|
||||
|
||||
type LongTableDebugWindow = Window & {
|
||||
__terminalPtyOutputDebug?: {
|
||||
reset: () => void
|
||||
snapshot: () => {
|
||||
hiddenRendererSkipCount: number
|
||||
hiddenRendererSkippedChars: number
|
||||
hiddenRendererMode2031ReplyCount: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function longMarkdownTableScript(runId: string): string {
|
||||
const names = [
|
||||
['Sam Syntax', 'Compiler', 'Online', '😀', '9200', 'Semicolons are optional (rage ensues)'],
|
||||
['Tori Token', 'Auth', 'Idle', '🚀', '4800', 'JWT expires during their standup'],
|
||||
['Uma Unpin', 'Frontend', 'Online', '🔥', '3500', 'Absolute positioning enjoyer'],
|
||||
['Vic Variable', 'Types', 'AFK', '💡', '6700', 'any is not a type, it is a cry for help'],
|
||||
['Wally Watchdog', 'Security', 'Online', '📦', '8200', 'Found a vuln in your vuln scanner'],
|
||||
['Xena XPath', 'DB', 'Idle', '🔐', '7300', 'Indexes everything, including the fridge'],
|
||||
['Yuki Yank', 'CLI', 'Online', '🎯', '5900', 'rm -rf / is not a party trick'],
|
||||
['Zane Zealot', 'OSS', 'Offline', '🤖', '10000', 'Contributor to 47 repos, sleeps never'],
|
||||
['Artie ASCII', 'Docs', 'Online', '🧠', '2900', 'Wrote a novel in README comments'],
|
||||
['Bianca Batch', 'ML', 'AFK', '💾', '9400', 'Training a model to write PR descriptions'],
|
||||
['Carlos Cache', 'CDN', 'Idle', '⚙', '4900', 'Stale data is still data'],
|
||||
['Diana Draft', 'Planning', 'Online', '📚', '1800', 'Needs 3 more sprints to estimate'],
|
||||
['Edgar Exit', 'Ops', 'Online', '🔧', '7600', 'Graceful shutdown specialist'],
|
||||
['Fiona Fallback', 'Resilience', 'Idle', '🧲', '5500', 'Circuit breaker connoisseur'],
|
||||
['Gabe Garbage', 'GC', 'Offline', '🧹', '4100', 'Stop-the-world is my catchphrase'],
|
||||
['Holly Hotfix', 'Release', 'Online', '🧪', '6300', 'Friday deploy champion'],
|
||||
['Ira Idempotent', 'API', 'AFK', '🔁', '6900', 'PUT me in coach'],
|
||||
['Jules Jitter', 'Mobile', 'Idle', '📱', '3200', 'Offline-first, coffee-second'],
|
||||
['Ken Kafka', 'Streams', 'Online', '📡', '7100', 'Rebalancing is a lifestyle'],
|
||||
['Luna Latency', 'Edge', 'Offline', '🧭', '4400', 'Response time measured in business days'],
|
||||
['Max Marshal', 'Memory', 'Online', '🧩', '8700', "Leak-free since '24"],
|
||||
['Nora Null', 'Safety', 'AFK', '❓', '3800', 'null is a person, not a value'],
|
||||
['Otto Offset', 'Cursors', 'Idle', '👆', '2600', 'Infinite scroll for the infinite soul'],
|
||||
['Pam Payload', 'Serialization', 'Online', '📦', '5800', 'JSON.stringify is my yoga'],
|
||||
['Reed Regex', 'Matching', 'Offline', '🔍', '6800', 'Now I have two problems']
|
||||
]
|
||||
return `
|
||||
const rows = ${JSON.stringify(names)}
|
||||
const widths = [16, 14, 12, 6, 7, 42]
|
||||
function isCombiningMark(codePoint) {
|
||||
return (codePoint >= 0x0300 && codePoint <= 0x036f) ||
|
||||
(codePoint >= 0xfe00 && codePoint <= 0xfe0f)
|
||||
}
|
||||
function isWideCodePoint(codePoint) {
|
||||
return codePoint > 0xffff ||
|
||||
(codePoint >= 0x1100 && codePoint <= 0x115f) ||
|
||||
(codePoint >= 0x2e80 && codePoint <= 0xa4cf) ||
|
||||
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
|
||||
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
|
||||
(codePoint >= 0xfe10 && codePoint <= 0xfe6f) ||
|
||||
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
|
||||
(codePoint >= 0xffe0 && codePoint <= 0xffe6)
|
||||
}
|
||||
function cellWidth(text) {
|
||||
let width = 0
|
||||
for (const char of String(text)) {
|
||||
const codePoint = char.codePointAt(0)
|
||||
if (codePoint === undefined || isCombiningMark(codePoint)) continue
|
||||
width += isWideCodePoint(codePoint) ? 2 : 1
|
||||
}
|
||||
return width
|
||||
}
|
||||
function cell(value, width) {
|
||||
const text = String(value)
|
||||
return text + ' '.repeat(Math.max(1, width - cellWidth(text)))
|
||||
}
|
||||
function line(parts) {
|
||||
return '| ' + parts.map((part, index) => cell(part, widths[index])).join(' | ') + ' |'
|
||||
}
|
||||
const outputRows = []
|
||||
outputRows.push(line(['Name', 'Team', 'Status', 'Icon', 'Score', 'Notes']))
|
||||
outputRows.push('|-' + widths.map((width) => '-'.repeat(width)).join('-|-') + '-|')
|
||||
for (let repeat = 0; repeat < 4; repeat += 1) {
|
||||
for (const row of rows) outputRows.push(line(row))
|
||||
}
|
||||
process.stdout.write('\\x1b[?2026h\\x1b[2J\\x1b[H')
|
||||
let index = 0
|
||||
const timer = setInterval(() => {
|
||||
if (index < outputRows.length) {
|
||||
process.stdout.write(outputRows[index] + '\\n')
|
||||
index += 1
|
||||
return
|
||||
}
|
||||
clearInterval(timer)
|
||||
process.stdout.write('LONG_TABLE_SCROLL_RESTORE_${runId}\\n')
|
||||
process.stdout.write('\\x1b[?2026l')
|
||||
}, 8)
|
||||
`
|
||||
}
|
||||
|
||||
async function scrollActiveTerminalLikeUser(page: Page): Promise<void> {
|
||||
const target = await page.evaluate(() => {
|
||||
const store = window.__store
|
||||
const state = store?.getState()
|
||||
const worktreeId = state?.activeWorktreeId
|
||||
const tabId =
|
||||
state?.activeTabType === 'terminal'
|
||||
? state.activeTabId
|
||||
: worktreeId
|
||||
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
|
||||
: null
|
||||
const manager = tabId ? window.__paneManagers?.get(tabId) : null
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
if (!pane) {
|
||||
throw new Error('Active terminal pane unavailable')
|
||||
}
|
||||
pane.terminal.focus()
|
||||
pane.terminal.scrollToBottom()
|
||||
const viewport =
|
||||
pane.container.querySelector<HTMLElement>('.xterm-viewport') ??
|
||||
pane.container.querySelector<HTMLElement>('.xterm')
|
||||
if (!viewport) {
|
||||
throw new Error('Active terminal viewport unavailable')
|
||||
}
|
||||
const rect = viewport.getBoundingClientRect()
|
||||
return {
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height / 2
|
||||
}
|
||||
})
|
||||
await page.mouse.move(target.x, target.y)
|
||||
await page.mouse.wheel(0, -1800)
|
||||
await page.waitForTimeout(250)
|
||||
}
|
||||
|
||||
async function closeFeatureTips(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const store = window.__store
|
||||
store?.getState().markFeatureTipsSeen(['orca-cli', 'cmd-j-palette', 'voice-dictation'])
|
||||
if (store?.getState().activeModal === 'feature-tips') {
|
||||
store.getState().closeModal()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function readTerminalRenderDiagnostics(page: Page): Promise<TerminalRenderDiagnostics> {
|
||||
return page.evaluate(() => {
|
||||
const store = window.__store
|
||||
const state = store?.getState()
|
||||
const worktreeId = state?.activeWorktreeId
|
||||
const tabId =
|
||||
state?.activeTabType === 'terminal'
|
||||
? state.activeTabId
|
||||
: worktreeId
|
||||
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
|
||||
: null
|
||||
const manager = tabId ? window.__paneManagers?.get(tabId) : null
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
if (!pane) {
|
||||
throw new Error('Active terminal pane unavailable')
|
||||
}
|
||||
const buffer = pane.terminal.buffer.active
|
||||
const visibleLineTails: string[] = []
|
||||
for (let row = 0; row < pane.terminal.rows; row += 1) {
|
||||
const line = buffer.getLine(buffer.viewportY + row)
|
||||
visibleLineTails.push(line?.translateToString(true).slice(-48) ?? '')
|
||||
}
|
||||
const terminalCore = (
|
||||
pane.terminal as unknown as {
|
||||
_core?: { coreService?: { isCursorHidden?: boolean } }
|
||||
}
|
||||
)._core
|
||||
const allPaneStates = Array.from(window.__paneManagers?.entries?.() ?? []).flatMap(
|
||||
([managerTabId, paneManager]) =>
|
||||
(paneManager.getPanes?.() ?? []).map((managedPane) => {
|
||||
const visibleText = Array.from({ length: managedPane.terminal.rows }, (_, row) => {
|
||||
const line = managedPane.terminal.buffer.active.getLine(
|
||||
managedPane.terminal.buffer.active.viewportY + row
|
||||
)
|
||||
return line?.translateToString(true) ?? ''
|
||||
}).join('\n')
|
||||
const serializedText = managedPane.serializeAddon?.serialize?.() ?? visibleText
|
||||
return {
|
||||
tabId: managerTabId,
|
||||
paneId: managedPane.id,
|
||||
hasComplexScriptOutput: managedPane.hasComplexScriptOutput === true,
|
||||
hasMarker: serializedText.includes('LONG_TABLE_SCROLL_RESTORE_'),
|
||||
hasWebgl: Boolean(managedPane.webglAddon)
|
||||
}
|
||||
})
|
||||
)
|
||||
return {
|
||||
cols: pane.terminal.cols,
|
||||
rows: pane.terminal.rows,
|
||||
viewportY: buffer.viewportY,
|
||||
baseY: buffer.baseY,
|
||||
hasComplexScriptOutput: pane.hasComplexScriptOutput === true,
|
||||
hasWebgl: Boolean(pane.webglAddon),
|
||||
canvasCount: pane.container.querySelectorAll('canvas').length,
|
||||
cursorHidden: terminalCore?.coreService?.isCursorHidden ?? null,
|
||||
visibleLineTails,
|
||||
allPaneStates
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Terminal long table scroll restore repro', () => {
|
||||
test('reproduces long markdown table artifacts after workspace switch and scroll', async ({
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}, testInfo: TestInfo) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await orcaPage.evaluate(() => {
|
||||
window.__store
|
||||
?.getState()
|
||||
.markFeatureTipsSeen(['orca-cli', 'cmd-j-palette', 'voice-dictation'])
|
||||
;(window as LongTableDebugWindow).__terminalPtyOutputDebug?.reset()
|
||||
})
|
||||
const firstWorktreeId = await waitForActiveWorktree(orcaPage)
|
||||
const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find(
|
||||
(id) => id !== firstWorktreeId
|
||||
)
|
||||
test.skip(!secondWorktreeId, 'long table restore repro needs the seeded secondary worktree')
|
||||
if (!secondWorktreeId) {
|
||||
return
|
||||
}
|
||||
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage)
|
||||
const runId = randomUUID()
|
||||
const marker = `LONG_TABLE_SCROLL_RESTORE_${runId}`
|
||||
const scriptPath = path.join(testRepoPath, `.orca-long-table-${runId}.mjs`)
|
||||
writeFileSync(scriptPath, longMarkdownTableScript(runId))
|
||||
|
||||
try {
|
||||
await sendToTerminal(orcaPage, ptyId, `node ${JSON.stringify(scriptPath)}\r`)
|
||||
await orcaPage.waitForTimeout(80)
|
||||
await switchToWorktree(orcaPage, secondWorktreeId)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
await orcaPage.waitForTimeout(1_500)
|
||||
await switchToWorktree(orcaPage, firstWorktreeId)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
await expect
|
||||
.poll(() => getTerminalContent(orcaPage, 30_000), {
|
||||
timeout: 10_000,
|
||||
message: 'long table marker did not survive workspace switch'
|
||||
})
|
||||
.toContain(marker)
|
||||
|
||||
await scrollActiveTerminalLikeUser(orcaPage)
|
||||
await closeFeatureTips(orcaPage)
|
||||
const diagnostics = await readTerminalRenderDiagnostics(orcaPage)
|
||||
const hiddenDebug = await orcaPage.evaluate(() =>
|
||||
(window as LongTableDebugWindow).__terminalPtyOutputDebug?.snapshot()
|
||||
)
|
||||
expect(hiddenDebug?.hiddenRendererSkipCount).toBe(0)
|
||||
const restoredPane = diagnostics.allPaneStates.find((paneState) => paneState.hasMarker)
|
||||
expect(restoredPane).toBeDefined()
|
||||
expect(restoredPane?.hasWebgl).toBe(false)
|
||||
expect(diagnostics.cursorHidden).toBe(false)
|
||||
await orcaPage.waitForTimeout(100)
|
||||
const screenshotPath = testInfo.outputPath('long-table-after-switch-scroll.png')
|
||||
await orcaPage.screenshot({ path: screenshotPath, fullPage: true })
|
||||
await testInfo.attach('long-table-after-switch-scroll.png', {
|
||||
path: screenshotPath,
|
||||
contentType: 'image/png'
|
||||
})
|
||||
} finally {
|
||||
rmSync(scriptPath, { force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,324 @@
|
|||
import { randomUUID } from 'node:crypto'
|
||||
import { rmSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
sendToTerminal,
|
||||
waitForActivePanePtyId,
|
||||
waitForActiveTerminalManager,
|
||||
waitForTerminalOutput
|
||||
} from './helpers/terminal'
|
||||
import {
|
||||
analyzeRasterCursorCells,
|
||||
type TerminalRasterProbeTarget
|
||||
} from './terminal-cursor-raster-probe'
|
||||
|
||||
type TerminalRenderState = {
|
||||
coreCursorHidden: boolean | null
|
||||
cursorElementCount: number
|
||||
cursorVisibleElementCount: number
|
||||
cursorBlink: boolean | null
|
||||
blinkIntervalDuration: number | null
|
||||
cursorClassName: string
|
||||
cursorAnimationName: string
|
||||
cursorAnimationDuration: string
|
||||
rowContainerClassName: string
|
||||
xtermClassName: string
|
||||
hasWebglCanvas: boolean
|
||||
hasComplexScriptOutput: boolean
|
||||
renderer: 'dom' | 'webgl'
|
||||
}
|
||||
|
||||
type CursorBlinkSample = {
|
||||
elapsedMs: number
|
||||
paintedCursorCellCount: number
|
||||
}
|
||||
|
||||
const EMOJI_TABLE_MARKER = 'ORCA_EMOJI_TABLE_RENDER_DONE'
|
||||
|
||||
function emojiTableScript(marker: string): string {
|
||||
const table = [
|
||||
'| Emoji | Name | Age | Occupation | City | Favorite Color | Pet | Hobby |',
|
||||
'| --- | --- | ---: | --- | --- | --- | --- | --- |',
|
||||
'| 😀 | Alice Johnson | 28 | Engineer | New York | Blue | 🐕 Dog | 🎸 Guitar |',
|
||||
'| 😂 | Bob Smith | 34 | Designer | London | Green | 🐱 Cat | 📚 Reading |',
|
||||
'| 🥰 | Carol Davis | 22 | Student | Paris | Pink | 🐰 Rabbit | 🎨 Painting |',
|
||||
'| 😎 | Dave Wilson | 45 | Architect | Tokyo | Black | 🐢 Turtle | 🏃 Running |',
|
||||
'| 🤩 | Eve Martinez | 31 | Writer | Berlin | Purple | 🐦 Bird | ✈️ Traveling |',
|
||||
'| 😜 | Frank Brown | 27 | Developer | Sydney | Red | 🐹 Hamster | 🎮 Gaming |',
|
||||
'| 🥳 | Grace Lee | 39 | Teacher | Seoul | Yellow | 🐟 Fish | 🌱 Gardening |',
|
||||
'| 🤔 | Henry Taylor | 41 | Doctor | Toronto | White | 🐕 Dog | 🍳 Cooking |',
|
||||
'| 😴 | Ivy Anderson | 26 | Nurse | Chicago | Orange | 🐱 Cat | 🧘 Yoga |',
|
||||
'| 🤗 | Jack Thomas | 33 | Lawyer | Boston | Navy | 🐢 Turtle | 📸 Photography |',
|
||||
'| 😈 | Karen White | 29 | Artist | Miami | Teal | 🐹 Hamster | 🧶 Knitting |',
|
||||
'| 😮 | Leo Harris | 37 | Pilot | Dubai | Gold | 🐦 Bird | 🚁 Drones |',
|
||||
'| 🤠 | Mia Clark | 24 | Barista | Seattle | Coral | 🐰 Rabbit | 🎤 Singing |',
|
||||
'| 😍 | Olivia Hall | 30 | Marketer | Austin | Pink | 🐱 Cat | 🏄 Surfing |'
|
||||
].join('\r\n')
|
||||
|
||||
return `
|
||||
process.stdout.write('\\x1b[?2026h\\x1b[?25l')
|
||||
process.stdout.write('\\x1b[2J\\x1b[H')
|
||||
process.stdout.write(${JSON.stringify(table)})
|
||||
process.stdout.write('\\r\\n${marker}\\r\\n')
|
||||
process.stdout.write('\\x1b[?25h\\x1b[?2026l')
|
||||
setTimeout(() => process.exit(0), 50)
|
||||
`
|
||||
}
|
||||
|
||||
async function readActiveTerminalRenderState(page: Page): Promise<TerminalRenderState> {
|
||||
return page.evaluate(() => {
|
||||
const state = window.__store?.getState()
|
||||
const worktreeId = state?.activeWorktreeId
|
||||
const tabId =
|
||||
state?.activeTabType === 'terminal'
|
||||
? state.activeTabId
|
||||
: worktreeId
|
||||
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
|
||||
: null
|
||||
const manager = tabId ? window.__paneManagers?.get(tabId) : null
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
if (!pane) {
|
||||
throw new Error('No active terminal pane')
|
||||
}
|
||||
|
||||
const cursorElements = Array.from(
|
||||
pane.container.querySelectorAll<HTMLElement>('.xterm-cursor, .xterm-cursor-layer *')
|
||||
)
|
||||
const cursorVisibleElementCount = cursorElements.filter((element) => {
|
||||
const style = window.getComputedStyle(element)
|
||||
const rect = element.getBoundingClientRect()
|
||||
return (
|
||||
style.display !== 'none' &&
|
||||
style.visibility !== 'hidden' &&
|
||||
Number(style.opacity || '1') > 0 &&
|
||||
rect.width > 0 &&
|
||||
rect.height > 0
|
||||
)
|
||||
}).length
|
||||
|
||||
const terminal = pane.terminal as {
|
||||
_core?: {
|
||||
coreService?: { isCursorHidden?: boolean }
|
||||
}
|
||||
}
|
||||
const cursorElement = pane.container.querySelector<HTMLElement>('.xterm-cursor')
|
||||
const cursorStyle = cursorElement ? window.getComputedStyle(cursorElement) : null
|
||||
const rowContainer = pane.container.querySelector<HTMLElement>('.xterm-rows')
|
||||
const xterm = pane.container.querySelector<HTMLElement>('.xterm')
|
||||
|
||||
return {
|
||||
coreCursorHidden:
|
||||
typeof terminal._core?.coreService?.isCursorHidden === 'boolean'
|
||||
? terminal._core.coreService.isCursorHidden
|
||||
: null,
|
||||
cursorElementCount: cursorElements.length,
|
||||
cursorVisibleElementCount,
|
||||
cursorBlink:
|
||||
typeof pane.terminal.options.cursorBlink === 'boolean'
|
||||
? pane.terminal.options.cursorBlink
|
||||
: null,
|
||||
blinkIntervalDuration:
|
||||
typeof pane.terminal.options.blinkIntervalDuration === 'number'
|
||||
? pane.terminal.options.blinkIntervalDuration
|
||||
: null,
|
||||
cursorClassName: cursorElement?.className ?? '',
|
||||
cursorAnimationName: cursorStyle?.animationName ?? '',
|
||||
cursorAnimationDuration: cursorStyle?.animationDuration ?? '',
|
||||
rowContainerClassName: rowContainer?.className ?? '',
|
||||
xtermClassName: xterm?.className ?? '',
|
||||
hasWebglCanvas: pane.container.querySelector('.xterm-webgl canvas') !== null,
|
||||
hasComplexScriptOutput: pane.hasComplexScriptOutput === true,
|
||||
renderer: pane.webglAddon ? 'webgl' : 'dom'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function forceCursorProbeTheme(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const state = window.__store?.getState()
|
||||
const worktreeId = state?.activeWorktreeId
|
||||
const tabId =
|
||||
state?.activeTabType === 'terminal'
|
||||
? state.activeTabId
|
||||
: worktreeId
|
||||
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
|
||||
: null
|
||||
const manager = tabId ? window.__paneManagers?.get(tabId) : null
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
if (!pane) {
|
||||
throw new Error('No active terminal pane')
|
||||
}
|
||||
pane.terminal.options.theme = {
|
||||
...pane.terminal.options.theme,
|
||||
cursor: '#23ff45',
|
||||
cursorAccent: '#001000'
|
||||
}
|
||||
pane.terminal.options.cursorStyle = 'block'
|
||||
pane.terminal.options.cursorBlink = true
|
||||
pane.terminal.focus()
|
||||
pane.terminal.refresh(0, pane.terminal.rows - 1)
|
||||
})
|
||||
}
|
||||
|
||||
async function readActiveTerminalRasterTarget(page: Page): Promise<TerminalRasterProbeTarget> {
|
||||
return page.evaluate(() => {
|
||||
const state = window.__store?.getState()
|
||||
const worktreeId = state?.activeWorktreeId
|
||||
const tabId =
|
||||
state?.activeTabType === 'terminal'
|
||||
? state.activeTabId
|
||||
: worktreeId
|
||||
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
|
||||
: null
|
||||
const manager = tabId ? window.__paneManagers?.get(tabId) : null
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
if (!pane) {
|
||||
throw new Error('No active terminal pane')
|
||||
}
|
||||
const screen = pane.container.querySelector<HTMLElement>('.xterm-screen')
|
||||
const dimensions = pane.terminal._core?._renderService?.dimensions?.css?.cell
|
||||
if (!screen || !dimensions) {
|
||||
throw new Error('Active terminal has no measurable xterm screen')
|
||||
}
|
||||
const rect = screen.getBoundingClientRect()
|
||||
return {
|
||||
clip: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||
cellWidth: dimensions.width,
|
||||
cellHeight: dimensions.height,
|
||||
rows: pane.terminal.rows,
|
||||
cols: pane.terminal.cols
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function sampleCursorBlink(page: Page): Promise<CursorBlinkSample[]> {
|
||||
const samples: CursorBlinkSample[] = []
|
||||
const target = await readActiveTerminalRasterTarget(page)
|
||||
const viewport = page.viewportSize() ?? undefined
|
||||
const start = performance.now()
|
||||
for (let index = 0; index < 9; index += 1) {
|
||||
if (index > 0) {
|
||||
await page.waitForTimeout(200)
|
||||
}
|
||||
const screenshot = await page.screenshot()
|
||||
const cells = analyzeRasterCursorCells(Buffer.from(screenshot), target, viewport)
|
||||
samples.push({
|
||||
elapsedMs: performance.now() - start,
|
||||
paintedCursorCellCount: cells.length
|
||||
})
|
||||
}
|
||||
return samples
|
||||
}
|
||||
|
||||
async function enableRiskyTerminalRendererPath(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('window.__store unavailable')
|
||||
}
|
||||
const state = store.getState()
|
||||
store.setState({
|
||||
settings: {
|
||||
...state.settings!,
|
||||
terminalGpuAcceleration: 'auto',
|
||||
theme: 'dark'
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('OpenCode emoji table terminal rendering', () => {
|
||||
test('keeps emoji table output visually sane and restores the cursor', async ({
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}, testInfo) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
await enableRiskyTerminalRendererPath(orcaPage)
|
||||
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage)
|
||||
const runId = randomUUID()
|
||||
const marker = `${EMOJI_TABLE_MARKER}_${runId}`
|
||||
const scriptPath = path.join(testRepoPath, `.orca-opencode-emoji-table-${runId}.mjs`)
|
||||
writeFileSync(scriptPath, emojiTableScript(marker))
|
||||
try {
|
||||
await sendToTerminal(orcaPage, ptyId, `node ${JSON.stringify(scriptPath)}\r`)
|
||||
await waitForTerminalOutput(orcaPage, marker, 10_000)
|
||||
await orcaPage.waitForTimeout(250)
|
||||
await forceCursorProbeTheme(orcaPage)
|
||||
await orcaPage.waitForTimeout(50)
|
||||
|
||||
const renderState = await readActiveTerminalRenderState(orcaPage)
|
||||
const blinkSamples = await sampleCursorBlink(orcaPage)
|
||||
const screenshotPath = testInfo.outputPath('opencode-emoji-table-final.png')
|
||||
await orcaPage.screenshot({ path: screenshotPath, fullPage: true })
|
||||
await testInfo.attach('opencode-emoji-table-final.png', {
|
||||
path: screenshotPath,
|
||||
contentType: 'image/png'
|
||||
})
|
||||
testInfo.annotations.push({
|
||||
type: 'opencode-emoji-table-rendering',
|
||||
description: JSON.stringify({ renderState, blinkSamples })
|
||||
})
|
||||
|
||||
expect(renderState.renderer).toBe('dom')
|
||||
expect(renderState.hasWebglCanvas).toBe(false)
|
||||
expect(renderState.coreCursorHidden).toBe(false)
|
||||
expect(renderState.cursorVisibleElementCount).toBeGreaterThan(0)
|
||||
expect(renderState.cursorBlink).toBe(true)
|
||||
expect(renderState.cursorAnimationName).not.toBe('none')
|
||||
expect(blinkSamples.some((sample) => sample.paintedCursorCellCount > 0)).toBe(true)
|
||||
expect(blinkSamples.some((sample) => sample.paintedCursorCellCount === 0)).toBe(true)
|
||||
} finally {
|
||||
rmSync(scriptPath, { force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('local real OpenCode demo keeps table rendering and cursor visible', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
process.env.ORCA_E2E_REAL_OPENCODE !== '1',
|
||||
'Set ORCA_E2E_REAL_OPENCODE=1 to exercise the locally installed OpenCode TUI'
|
||||
)
|
||||
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
await enableRiskyTerminalRendererPath(orcaPage)
|
||||
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage)
|
||||
await sendToTerminal(
|
||||
orcaPage,
|
||||
ptyId,
|
||||
'opencode run --demo --interactive "Give me markdown table dummy data a long table with emojis in it"\r'
|
||||
)
|
||||
try {
|
||||
await waitForTerminalOutput(orcaPage, 'Give me markdown table', 15_000)
|
||||
await waitForTerminalOutput(orcaPage, 'Emoji', 60_000)
|
||||
await waitForTerminalOutput(orcaPage, 'Alice', 60_000)
|
||||
await orcaPage.waitForTimeout(1_500)
|
||||
|
||||
await testInfo.attach('real-opencode-demo-table', {
|
||||
body: await orcaPage.screenshot({ fullPage: true }),
|
||||
contentType: 'image/png'
|
||||
})
|
||||
|
||||
const renderState = await readActiveTerminalRenderState(orcaPage)
|
||||
testInfo.annotations.push({
|
||||
type: 'real-opencode-demo-rendering',
|
||||
description: JSON.stringify(renderState)
|
||||
})
|
||||
expect(renderState.coreCursorHidden).toBe(false)
|
||||
expect(renderState.cursorVisibleElementCount).toBeGreaterThan(0)
|
||||
} finally {
|
||||
await sendToTerminal(orcaPage, ptyId, '\x03').catch(() => undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue