From bf31ecbad74c904e3344e4f0674eb8ec56fc1a73 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:40:45 -0700 Subject: [PATCH] Keep Codex terminals responsive during startup (#5301) --- .../scanner-large-directory.test.ts | 24 +- src/main/codex-usage/scanner.ts | 80 ++- src/main/codex-usage/store.test.ts | 126 +++- src/main/codex-usage/store.ts | 4 +- .../TabBar.windows-shell-launch.test.ts | 1 + .../components/terminal-pane/TerminalPane.tsx | 2 + .../terminal-pane/pty-connection-types.ts | 3 + .../terminal-pane/pty-connection.test.ts | 675 +++++++++++++++++- .../terminal-pane/pty-connection.ts | 361 +++++++++- .../use-terminal-pane-lifecycle.ts | 2 + .../terminal-color-scheme-protocol.test.ts | 20 +- src/shared/terminal-color-scheme-protocol.ts | 5 + 12 files changed, 1240 insertions(+), 63 deletions(-) diff --git a/src/main/codex-usage/scanner-large-directory.test.ts b/src/main/codex-usage/scanner-large-directory.test.ts index 58c9dc4ed..c7b4c9600 100644 --- a/src/main/codex-usage/scanner-large-directory.test.ts +++ b/src/main/codex-usage/scanner-large-directory.test.ts @@ -1,12 +1,15 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Dirent, Stats } from 'node:fs' import type * as FsPromises from 'fs/promises' import { join } from 'path' -const { readdirMock, statMock } = vi.hoisted(() => ({ - readdirMock: vi.fn<(dirPath: string) => Promise>(), - statMock: vi.fn<(filePath: string) => Promise>() -})) +const { getLegacyCopiedCodexSessionBridgeScanPreferenceMock, readdirMock, statMock } = vi.hoisted( + () => ({ + getLegacyCopiedCodexSessionBridgeScanPreferenceMock: vi.fn(), + readdirMock: vi.fn<(dirPath: string) => Promise>(), + statMock: vi.fn<(filePath: string) => Promise>() + }) +) vi.mock('fs/promises', async () => { const actual = await vi.importActual('fs/promises') @@ -29,7 +32,8 @@ vi.mock('../codex/codex-home-paths', () => ({ })) vi.mock('../codex/codex-session-bridge', () => ({ - getLegacyCopiedCodexSessionBridgeScanPreference: () => null + getLegacyCopiedCodexSessionBridgeScanPreference: + getLegacyCopiedCodexSessionBridgeScanPreferenceMock })) function dirent(name: string, kind: 'directory' | 'file'): Dirent { @@ -45,6 +49,13 @@ const largeSessionEntries = Array.from({ length: FILE_COUNT }, (_, index) => ) describe('listCodexSessionFiles large directories', () => { + beforeEach(() => { + getLegacyCopiedCodexSessionBridgeScanPreferenceMock.mockReset() + getLegacyCopiedCodexSessionBridgeScanPreferenceMock.mockReturnValue(null) + readdirMock.mockReset() + statMock.mockReset() + }) + it('keeps nested session scans past the JavaScript spread-argument limit', async () => { readdirMock.mockImplementation(async (dirPath) => { if (dirPath === RUNTIME_SESSIONS_ROOT) { @@ -69,5 +80,6 @@ describe('listCodexSessionFiles large directories', () => { const { listCodexSessionFiles } = await import('./scanner') await expect(listCodexSessionFiles()).resolves.toHaveLength(FILE_COUNT) + expect(getLegacyCopiedCodexSessionBridgeScanPreferenceMock).not.toHaveBeenCalled() }) }) diff --git a/src/main/codex-usage/scanner.ts b/src/main/codex-usage/scanner.ts index bd77a020a..f23aafd6c 100644 --- a/src/main/codex-usage/scanner.ts +++ b/src/main/codex-usage/scanner.ts @@ -1,6 +1,6 @@ /* eslint-disable max-lines -- Why: Codex discovery, incremental parsing, attribution, and aggregation all depend on the same event-normalization rules. Keeping them together makes the duplicate-snapshot logic easier to audit when usage totals look wrong. */ import { basename, join, win32, posix } from 'path' -import { createReadStream } from 'fs' +import { createReadStream, existsSync } from 'fs' import { realpath, readdir, stat } from 'fs/promises' import { createInterface } from 'readline' import type { Repo } from '../../shared/types' @@ -55,6 +55,7 @@ type CodexUsageDeltaResolution = | { kind: 'baseline'; nextTotals: CodexUsageRawUsage } const YIELD_EVERY_FILES = 10 +const YIELD_EVERY_DISCOVERY_ENTRIES = 100 function ensureNumber(value: unknown): number { return typeof value === 'number' && Number.isFinite(value) ? value : 0 @@ -88,17 +89,24 @@ async function canonicalizePath(pathValue: string): Promise { } async function yieldToEventLoop(): Promise { - await new Promise((resolve) => setTimeout(resolve, 0)) + await new Promise((resolve) => setImmediate(resolve)) } -async function walkJsonlFiles(dirPath: string): Promise { +async function walkJsonlFiles( + dirPath: string, + progress: { entriesVisited: number } = { entriesVisited: 0 } +): Promise { const entries = await readdir(dirPath, { withFileTypes: true }) const files: string[] = [] for (const entry of entries) { + progress.entriesVisited += 1 + if (progress.entriesVisited % YIELD_EVERY_DISCOVERY_ENTRIES === 0) { + await yieldToEventLoop() + } const fullPath = join(dirPath, entry.name) if (entry.isDirectory()) { - appendDiscoveredFiles(files, await walkJsonlFiles(fullPath)) + appendDiscoveredFiles(files, await walkJsonlFiles(fullPath, progress)) continue } if (entry.isFile() && entry.name.endsWith('.jsonl')) { @@ -132,6 +140,10 @@ export function getCodexSessionDirectories(): string[] { ) } +function hasLegacyCopiedSessionBridgeMarkers(): boolean { + return existsSync(join(getOrcaManagedCodexHomePath(), '.orca-session-copies')) +} + export async function listCodexSessionFiles(): Promise { const files: string[] = [] for (const dirPath of getCodexSessionDirectories()) { @@ -141,29 +153,37 @@ export async function listCodexSessionFiles(): Promise { // Missing or unreadable history in one home should not hide the other. } } - return dedupeCodexSessionFileAliases(files) + return dedupeCodexSessionFileAliases(files, hasLegacyCopiedSessionBridgeMarkers()) } -async function dedupeCodexSessionFileAliases(files: string[]): Promise { +async function dedupeCodexSessionFileAliases( + files: string[], + hasLegacyBridgeMarkers: boolean +): Promise { const excludedAliases = new Set() - for (const filePath of files) { - const legacyCopyBridge = getLegacyCopiedCodexSessionBridgeScanPreference(filePath) - if (!legacyCopyBridge) { - continue - } - if (legacyCopyBridge.sourceSkipBytes !== null) { - continue - } - excludedAliases.add( - await getPhysicalFileAliasKey( - legacyCopyBridge.preferManagedCopy ? legacyCopyBridge.sourcePath : filePath + if (hasLegacyBridgeMarkers) { + for (const [index, filePath] of files.entries()) { + const legacyCopyBridge = getLegacyCopiedCodexSessionBridgeScanPreference(filePath) + if ((index + 1) % YIELD_EVERY_DISCOVERY_ENTRIES === 0) { + await yieldToEventLoop() + } + if (!legacyCopyBridge) { + continue + } + if (legacyCopyBridge.sourceSkipBytes !== null) { + continue + } + excludedAliases.add( + await getPhysicalFileAliasKey( + legacyCopyBridge.preferManagedCopy ? legacyCopyBridge.sourcePath : filePath + ) ) - ) + } } const seenAliases = new Set() const uniqueFiles: string[] = [] - for (const filePath of [...new Set(files)].sort()) { + for (const [index, filePath] of [...new Set(files)].sort().entries()) { const aliasKey = await getCodexSessionFileAliasKey(filePath) if (excludedAliases.has(aliasKey)) { continue @@ -173,6 +193,9 @@ async function dedupeCodexSessionFileAliases(files: string[]): Promise } seenAliases.add(aliasKey) uniqueFiles.push(filePath) + if ((index + 1) % YIELD_EVERY_DISCOVERY_ENTRIES === 0) { + await yieldToEventLoop() + } } return uniqueFiles } @@ -191,8 +214,14 @@ async function getPhysicalFileAliasKey(filePath: string): Promise { return `path:${await canonicalizePath(filePath)}` } -function getLegacySourceSkipBytesByPath(files: string[]): Map { +function getLegacySourceSkipBytesByPath( + files: string[], + hasLegacyBridgeMarkers = hasLegacyCopiedSessionBridgeMarkers() +): Map { const sourceSkipBytesByPath = new Map() + if (!hasLegacyBridgeMarkers) { + return sourceSkipBytesByPath + } for (const filePath of files) { const legacyCopyBridge = getLegacyCopiedCodexSessionBridgeScanPreference(filePath) if (!legacyCopyBridge || legacyCopyBridge.sourceSkipBytes === null) { @@ -734,7 +763,7 @@ function mergeSessions( for (const session of sessions) { const existing = target.get(session.sessionId) if (!existing) { - target.set(session.sessionId, structuredClone(session)) + target.set(session.sessionId, cloneSessionForMerge(session)) continue } @@ -809,6 +838,15 @@ function mergeSessions( } } +function cloneSessionForMerge(session: CodexUsageSession): CodexUsageSession { + return { + ...session, + locationBreakdown: session.locationBreakdown.map((entry) => ({ ...entry })), + modelBreakdown: session.modelBreakdown.map((entry) => ({ ...entry })), + locationModelBreakdown: session.locationModelBreakdown.map((entry) => ({ ...entry })) + } +} + function mergeDailyAggregates( target: Map, dailyAggregates: CodexUsageDailyAggregate[] diff --git a/src/main/codex-usage/store.test.ts b/src/main/codex-usage/store.test.ts index 934ea9a82..1cb41e650 100644 --- a/src/main/codex-usage/store.test.ts +++ b/src/main/codex-usage/store.test.ts @@ -1,6 +1,15 @@ /* eslint-disable max-lines */ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { CodexUsagePersistedState } from './types' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as Fs from 'fs' +import type { + CodexUsageDailyAggregate, + CodexUsagePersistedFile, + CodexUsagePersistedState, + CodexUsageSession +} from './types' const { getPathMock } = vi.hoisted(() => ({ getPathMock: vi.fn(() => '/tmp/orca-test-userdata') @@ -12,11 +21,54 @@ vi.mock('electron', () => ({ } })) -import { CodexUsageStore, normalizePersistedState } from './store' +vi.mock('fs', async () => { + const actual = await vi.importActual('fs') + return { + ...actual, + writeFileSync: vi.fn(actual.writeFileSync) + } +}) + +vi.mock('./scanner', () => ({ + createWorktreeRefs: vi.fn(() => []), + scanCodexUsageFiles: vi.fn() +})) + +import { CodexUsageStore, initCodexUsagePath, normalizePersistedState } from './store' +import { scanCodexUsageFiles } from './scanner' + +type ScanResult = { + processedFiles: CodexUsagePersistedFile[] + sessions: CodexUsageSession[] + dailyAggregates: CodexUsageDailyAggregate[] +} + +function createDeferred(): { + promise: Promise + resolve: (value: T) => void + reject: (reason?: unknown) => void +} { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve + reject = promiseReject + }) + return { promise, resolve, reject } +} + +function createEmptyScanResult(): ScanResult { + return { + processedFiles: [], + sessions: [], + dailyAggregates: [] + } +} function createStoreWithState(state: Partial): CodexUsageStore { const store = new CodexUsageStore({ getRepos: () => [], + getAllWorktreeMeta: () => ({}), getWorktreeMeta: () => undefined } as never) @@ -39,11 +91,79 @@ function createStoreWithState(state: Partial): CodexUs } describe('CodexUsageStore', () => { + let tempUserData: string + beforeEach(() => { + tempUserData = mkdtempSync(join(tmpdir(), 'orca-codex-usage-store-')) + getPathMock.mockReturnValue(tempUserData) + initCodexUsagePath() + vi.mocked(writeFileSync).mockClear() + vi.mocked(scanCodexUsageFiles).mockReset() + vi.mocked(scanCodexUsageFiles).mockResolvedValue(createEmptyScanResult()) vi.useFakeTimers() vi.setSystemTime(new Date('2026-04-10T12:00:00.000-04:00')) }) + afterEach(() => { + vi.useRealTimers() + rmSync(tempUserData, { recursive: true, force: true }) + }) + + it('persists a successful refresh with one compact disk write', async () => { + const store = createStoreWithState({ + schemaVersion: 3, + scanState: { + enabled: true, + lastScanStartedAt: null, + lastScanCompletedAt: null, + lastScanError: null + } + }) + + await store.refresh(true) + + expect(writeFileSync).toHaveBeenCalledTimes(1) + const persistedJson = readFileSync(join(tempUserData, 'orca-codex-usage.json'), 'utf-8') + expect(persistedJson).toBe(JSON.stringify(JSON.parse(persistedJson))) + expect(persistedJson).not.toContain('\n') + expect(JSON.parse(persistedJson).scanState).toMatchObject({ + enabled: true, + lastScanStartedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(), + lastScanCompletedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(), + lastScanError: null + }) + }) + + it('keeps scan start visible in memory while scan-start persistence is skipped', async () => { + const pendingScan = createDeferred() + vi.mocked(scanCodexUsageFiles).mockReturnValueOnce(pendingScan.promise) + const store = createStoreWithState({ + schemaVersion: 3, + scanState: { + enabled: true, + lastScanStartedAt: null, + lastScanCompletedAt: null, + lastScanError: 'previous failure' + } + }) + + const refreshPromise = store.refresh(true) + await Promise.resolve() + + expect(store.getScanState()).toMatchObject({ + isScanning: true, + lastScanStartedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(), + lastScanError: null + }) + expect(writeFileSync).not.toHaveBeenCalled() + + pendingScan.resolve(createEmptyScanResult()) + await refreshPromise + + expect(store.getScanState().isScanning).toBe(false) + expect(writeFileSync).toHaveBeenCalledTimes(1) + }) + it('reports no data for Orca scope when only non-Orca Codex usage exists', async () => { const store = createStoreWithState({ sessions: [ diff --git a/src/main/codex-usage/store.ts b/src/main/codex-usage/store.ts index c7fbe0d2f..99d18eb59 100644 --- a/src/main/codex-usage/store.ts +++ b/src/main/codex-usage/store.ts @@ -358,7 +358,7 @@ export class CodexUsageStore { mkdirSync(dir, { recursive: true }) } const tmpFile = `${usageFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` - writeFileSync(tmpFile, JSON.stringify(this.state, null, 2), 'utf-8') + writeFileSync(tmpFile, JSON.stringify(this.state), 'utf-8') renameSync(tmpFile, usageFile) } @@ -414,7 +414,7 @@ export class CodexUsageStore { this.state.scanState.lastScanStartedAt = Date.now() this.state.scanState.lastScanError = null - this.writeToDisk() + // Why: start-only writes rewrite the full usage cache before scan results change. this.scanPromise = (async () => { try { diff --git a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts index 5cf3f9923..343a22b5e 100644 --- a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts @@ -85,6 +85,7 @@ vi.mock('react', async () => { useLayoutEffect: () => {}, useCallback: unknown>(callback: T) => callback, useMemo: (factory: () => T) => factory(), + useCallback: unknown>(callback: T) => callback, useRef: (current: T) => ({ current }), useState: (initial: T | (() => T)) => { const value = typeof initial === 'function' ? (initial as () => T)() : initial diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 77a19f61d..f478cb588 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -1038,6 +1038,8 @@ export default function TerminalPane({ cwd, startup: { command: 'codex' }, paneTransportsRef, + paneMode2031Ref, + paneLastThemeModeRef, replayingPanesRef, isActiveRef, isVisibleRef, diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index d532ae9f0..252885402 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -2,6 +2,7 @@ import type { PtyTransport } from './pty-transport' import type { ReplayingPanesRef } from './replay-guard' import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' import type { EventProps } from '../../../../shared/telemetry-events' +import type { TerminalColorSchemeMode } from '../../../../shared/terminal-color-scheme-protocol' import type { TuiAgent } from '../../../../shared/types' export type PtyConnectionDeps = { @@ -23,6 +24,8 @@ export type PtyConnectionDeps = { restoredLeafId?: string | null restoredPtyIdByLeafId?: Record paneTransportsRef: React.RefObject> + paneMode2031Ref: React.RefObject> + paneLastThemeModeRef: React.RefObject> replayingPanesRef: ReplayingPanesRef isActiveRef: React.RefObject isVisibleRef: React.RefObject diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 81cfbe45b..8b18c4dcf 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -343,6 +343,8 @@ function createDeps(overrides: Record = {}) { restoredLeafId: null, restoredPtyIdByLeafId: {}, paneTransportsRef: { current: new Map() }, + paneMode2031Ref: { current: new Map() }, + paneLastThemeModeRef: { current: new Map() }, replayingPanesRef: { current: new Map() }, isActiveRef: { current: true }, isVisibleRef: { current: true }, @@ -3489,7 +3491,8 @@ describe('connectPanePty', () => { capturedDataCallback.current?.('\x1b]11;?\x1b\\startup frame\r\n') - expect(pane.terminal.write).toHaveBeenCalledWith( + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b]11;?\x1b\\', expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith( '\x1b]11;?\x1b\\startup frame\r\n', expect.any(Function) ) @@ -3523,7 +3526,8 @@ describe('connectPanePty', () => { capturedDataCallback.current?.('\x1b]11;?\x1b\\startup frame\r\n') - expect(pane.terminal.write).toHaveBeenCalledWith( + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b]11;?\x1b\\', expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith( '\x1b]11;?\x1b\\startup frame\r\n', expect.any(Function) ) @@ -3565,7 +3569,7 @@ describe('connectPanePty', () => { binding.dispose() }) - it('writes mode 2031 through hidden xterm instead of side-channel answering it', async () => { + it('side-channel answers mode 2031 when hidden Codex output is snapshot-backed', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } @@ -3581,10 +3585,17 @@ describe('connectPanePty', () => { const pane = createPane(1) const manager = createManager(1) + const paneMode2031Ref = { current: new Map() } + const paneLastThemeModeRef = { current: new Map() } const binding = connectPanePty( pane as never, manager as never, - createDeps({ isVisibleRef: { current: false } }) as never + createDeps({ + isVisibleRef: { current: false }, + paneMode2031Ref, + paneLastThemeModeRef, + startup: { command: 'codex' } + }) as never ) await flushAsyncTicks(6) @@ -3593,8 +3604,10 @@ describe('connectPanePty', () => { capturedDataCallback.current?.('\x1b[?2031h') vi.advanceTimersByTime(50) - expect(transport.sendInput).not.toHaveBeenCalled() - expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[?2031h') + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;2n') + expect(paneMode2031Ref.current.get(1)).toBe(true) + expect(paneLastThemeModeRef.current.get(1)).toBe('light') + expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1b[?2031h') } finally { vi.useRealTimers() } @@ -3602,6 +3615,656 @@ describe('connectPanePty', () => { binding.dispose() }) + it('answers hidden Codex mode 2031 subscribes split across becoming visible', 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) + mockStoreState = { + ...mockStoreState, + settings: { ...mockStoreState.settings, theme: 'light' } + } + + const isVisibleRef = { current: false } + const paneMode2031Ref = { current: new Map() } + const paneLastThemeModeRef = { current: new Map() } + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef, + paneMode2031Ref, + paneLastThemeModeRef, + startup: { command: 'codex' } + }) as never + ) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[?20') + isVisibleRef.current = true + capturedDataCallback.current?.('31h') + + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;2n') + expect(paneMode2031Ref.current.get(1)).toBe(true) + expect(paneLastThemeModeRef.current.get(1)).toBe('light') + expect(pane.terminal.write).not.toHaveBeenCalledWith('31h', expect.any(Function)) + + binding.dispose() + }) + + it('does not keep mode 2031 subscribed when a skipped hidden chunk unsubscribes last', 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 paneMode2031Ref = { current: new Map() } + const paneLastThemeModeRef = { current: new Map() } + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + paneMode2031Ref, + paneLastThemeModeRef, + startup: { command: 'codex' } + }) as never + ) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[?2031h\x1b[?2031l') + + expect(transport.sendInput).not.toHaveBeenCalledWith(expect.stringMatching(/\?997/)) + expect(paneMode2031Ref.current.has(1)).toBe(false) + expect(paneLastThemeModeRef.current.has(1)).toBe(false) + + binding.dispose() + }) + + it('keeps hidden Codex redraw floods off 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 binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) as never + ) + await flushAsyncTicks(6) + + vi.useFakeTimers() + try { + const hiddenCodexRedraw = `\x1b[?2026h\x1b[2J\x1b[H${'codex redraw '.repeat(8_000)}` + capturedDataCallback.current?.(hiddenCodexRedraw) + vi.advanceTimersByTime(50) + + expect(pane.terminal.write).not.toHaveBeenCalledWith(hiddenCodexRedraw) + expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + + binding.dispose() + }) + + it('keeps hidden Codex terminal query chunks 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 binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) as never + ) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[c') + + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[c', expect.any(Function)) + + binding.dispose() + }) + + it('keeps only coalesced hidden Codex terminal queries 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 binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) as never + ) + await flushAsyncTicks(6) + + const coalescedChunk = `\x1b[c\x1b[?2026h\x1b[2J\x1b[H${'codex redraw '.repeat(8_000)}` + capturedDataCallback.current?.(coalescedChunk) + + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[c', expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith(coalescedChunk, expect.any(Function)) + + binding.dispose() + }) + + it('keeps split hidden Codex terminal queries 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 binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) as never + ) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[') + capturedDataCallback.current?.(`c\x1b[?2026h${'codex redraw '.repeat(8_000)}`) + + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[c', expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + `c\x1b[?2026h${'codex redraw '.repeat(8_000)}`, + expect.any(Function) + ) + + binding.dispose() + }) + + it('keeps hidden Codex terminal queries split after ESC 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 binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) as never + ) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b') + capturedDataCallback.current?.(`[c\x1b[?2026h${'codex redraw '.repeat(8_000)}`) + + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[c', expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + `[c\x1b[?2026h${'codex redraw '.repeat(8_000)}`, + expect.any(Function) + ) + + binding.dispose() + }) + + it('flushes pending hidden Codex query prefixes when the pane becomes visible', 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 isVisibleRef = { current: false } + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef, + startup: { command: 'codex' } + }) as never + ) + try { + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b') + isVisibleRef.current = true + capturedDataCallback.current?.('[c') + + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[c', expect.any(Function)) + } finally { + binding.dispose() + } + }) + + it('keeps split hidden-to-visible Codex stateful queries behind snapshot restore', 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 getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'snapshot-before-cpr\r\n', + cols: 100, + rows: 30 + }) + + const isVisibleRef = { current: false } + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef, + startup: { command: 'codex' } + }) as never + ) + try { + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[6') + isVisibleRef.current = true + capturedDataCallback.current?.('n') + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith( + 'snapshot-before-cpr\r\n', + expect.any(Function) + ) + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[6n', expect.any(Function)) + } finally { + binding.dispose() + } + }) + + it('keeps all visible bytes after a pending hidden ESC becomes non-query output', 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 getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'snapshot-before-visible\r\n', + cols: 100, + rows: 30 + }) + + const isVisibleRef = { current: false } + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef, + startup: { command: 'codex' } + }) as never + ) + try { + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b') + isVisibleRef.current = true + capturedDataCallback.current?.('hello') + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith('hello', expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith('ello', expect.any(Function)) + } finally { + binding.dispose() + } + }) + + it('keeps split stateful Codex queries live after becoming visible', 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 getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'snapshot-before-visible\r\n', + cols: 100, + rows: 30 + }) + + const isVisibleRef = { current: false } + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef, + startup: { command: 'codex' } + }) as never + ) + try { + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[') + isVisibleRef.current = true + capturedDataCallback.current?.('6n') + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[6n', expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith('6n', expect.any(Function)) + } finally { + binding.dispose() + } + }) + + it('drops pending hidden Codex query prefixes when the PTY changes', 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 isVisibleRef = { current: false } + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef, + startup: { command: 'codex' } + }) as never + ) + try { + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b') + ;(transport.attach as unknown as (opts: { existingPtyId: string }) => void)({ + existingPtyId: 'pty-new' + }) + isVisibleRef.current = true + capturedDataCallback.current?.('[c') + + expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1b', expect.any(Function)) + expect(pane.terminal.write).toHaveBeenCalledWith('[c', expect.any(Function)) + } finally { + binding.dispose() + } + }) + + it('does not live-render split hidden Codex non-query CSI output', 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 binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) as never + ) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[?202') + capturedDataCallback.current?.('6h') + capturedDataCallback.current?.('\x1b[?203') + capturedDataCallback.current?.('1h') + + expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1b[?202', expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith('6h', expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1b[?203', expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith('1h', expect.any(Function)) + + binding.dispose() + }) + + it('keeps split hidden Codex OSC color queries 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 binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) as never + ) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b]11;?') + capturedDataCallback.current?.(`\x1b\\\x1b[?2026h${'codex redraw '.repeat(8_000)}`) + + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b]11;?\x1b\\', expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + `\x1b\\\x1b[?2026h${'codex redraw '.repeat(8_000)}`, + expect.any(Function) + ) + + binding.dispose() + }) + + it('keeps hidden Codex OSC color queries split before the prefix 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 binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) as never + ) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b]') + capturedDataCallback.current?.(`11;?\x1b\\\x1b[?2026h${'codex redraw '.repeat(8_000)}`) + + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b]11;?\x1b\\', expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + `11;?\x1b\\\x1b[?2026h${'codex redraw '.repeat(8_000)}`, + expect.any(Function) + ) + + binding.dispose() + }) + + it('keeps later hidden Codex stateless terminal queries 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 binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) as never + ) + await flushAsyncTicks(6) + + vi.useFakeTimers() + try { + vi.advanceTimersByTime(30_000) + capturedDataCallback.current?.('\x1b[5n') + vi.advanceTimersByTime(50) + + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[5n', expect.any(Function)) + } finally { + vi.useRealTimers() + } + + binding.dispose() + }) + + it('keeps clean hidden Codex stateful cursor-position queries 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 binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) as never + ) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[10;20H\x1b[6n') + + expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled() + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[10;20H\x1b[6n', expect.any(Function)) + + binding.dispose() + }) + + it('does not answer dirty hidden Codex stateful queries from stale xterm state', 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 binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) as never + ) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(`\x1b[2J\x1b[H${'codex redraw '.repeat(8_000)}`) + capturedDataCallback.current?.('\x1b[6n') + + expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled() + expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1b[6n', expect.any(Function)) + + binding.dispose() + }) + it('writes ordinary hidden output live instead of proactively restoring a snapshot', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 085cf93bb..49c85bfa7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -110,7 +110,6 @@ const HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS = 5000 const HIDDEN_OUTPUT_RESTORE_PENDING_CHARS = 512 * 1024 const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MS = 50 const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MAX = 3 -const HIDDEN_STARTUP_RENDERER_QUERY_WINDOW_MS = 10_000 const STARTUP_COMMAND_EXTENSION_RE = /\.(?:exe|cmd|bat|ps1)$/i const TERMINAL_RENDERER_RISK_SCAN_TAIL_CHARS = 256 const CURSOR_SHOW_SEQUENCE = '\x1b[?25h' @@ -307,6 +306,168 @@ function shouldKeepHiddenStartupRendererQueriesLive( return startup?.telemetry?.agent_kind === 'codex' || isCodexStartupCommand(startup?.command ?? '') } +function containsHiddenStartupRendererQuery(data: string): boolean { + // Why: hidden Codex startup must not live-render ordinary redraw floods, but + // query chunks still need xterm's built-in terminal replies to unblock TUIs. + return containsCsiRendererQuery(data) || data.includes('\x1b]10;?') || data.includes('\x1b]11;?') +} + +const HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS = 64 +const HIDDEN_STARTUP_OSC_COLOR_QUERY_PREFIXES = ['\x1b]10;?', '\x1b]11;?'] as const + +function findOscTerminatorIndex(data: string, offset: number): number { + for (let index = offset; index < data.length; index++) { + const code = data.charCodeAt(index) + if (code === 0x07) { + return index + 1 + } + if (code === 0x1b && data[index + 1] === '\\') { + return index + 2 + } + } + return -1 +} + +function extractHiddenStartupRendererQueryData( + data: string, + pending: string +): { statelessQueryData: string; statefulQueryData: string; pending: string } { + const input = pending + data + let statelessQueryData = '' + let statefulQueryData = '' + let offset = 0 + + while (offset < input.length) { + const candidateIndex = input.indexOf('\x1b', offset) + if (candidateIndex === -1) { + break + } + if (candidateIndex + 1 >= input.length) { + return { statelessQueryData, statefulQueryData, pending: input.slice(candidateIndex) } + } + if (input.startsWith('\x1b[', candidateIndex)) { + const finalByteIndex = findCsiFinalByteIndex(input, candidateIndex + 2) + if (finalByteIndex === -1) { + return { + statelessQueryData, + statefulQueryData, + pending: input.slice( + candidateIndex, + candidateIndex + HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS + ) + } + } + const sequence = input.slice(candidateIndex, finalByteIndex + 1) + if (isStatelessRendererReplyCsiQuery(sequence)) { + statelessQueryData += sequence + } else if (isStatefulRendererReplyCsiQuery(sequence)) { + statefulQueryData += sequence + } + offset = finalByteIndex + 1 + continue + } + + if (input.startsWith('\x1b]', candidateIndex)) { + const remaining = input.slice(candidateIndex) + const matchingPrefix = HIDDEN_STARTUP_OSC_COLOR_QUERY_PREFIXES.find((prefix) => + remaining.startsWith(prefix) + ) + if (!matchingPrefix) { + if ( + HIDDEN_STARTUP_OSC_COLOR_QUERY_PREFIXES.some((prefix) => prefix.startsWith(remaining)) + ) { + return { statelessQueryData, statefulQueryData, pending: remaining } + } + offset = candidateIndex + 2 + continue + } + + const terminatorIndex = findOscTerminatorIndex(input, candidateIndex + matchingPrefix.length) + if (terminatorIndex === -1) { + return { + statelessQueryData, + statefulQueryData, + pending: input.slice( + candidateIndex, + candidateIndex + HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS + ) + } + } + statelessQueryData += input.slice(candidateIndex, terminatorIndex) + offset = terminatorIndex + continue + } + + if ( + HIDDEN_STARTUP_OSC_COLOR_QUERY_PREFIXES.some((prefix) => + prefix.startsWith(input.slice(candidateIndex)) + ) + ) { + return { statelessQueryData, statefulQueryData, pending: input.slice(candidateIndex) } + } + + { + offset = candidateIndex + 1 + continue + } + } + + return { statelessQueryData, statefulQueryData, pending: '' } +} + +function containsCsiRendererQuery(data: string): boolean { + let offset = data.indexOf('\x1b[') + while (offset !== -1) { + const finalByteIndex = findCsiFinalByteIndex(data, offset + 2) + if (finalByteIndex === -1) { + return false + } + const sequence = data.slice(offset, finalByteIndex + 1) + if (isStatelessRendererReplyCsiQuery(sequence) || isStatefulRendererReplyCsiQuery(sequence)) { + return true + } + offset = data.indexOf('\x1b[', finalByteIndex + 1) + } + return false +} + +function containsStatefulRendererQuery(data: string): boolean { + let offset = data.indexOf('\x1b[') + while (offset !== -1) { + const finalByteIndex = findCsiFinalByteIndex(data, offset + 2) + if (finalByteIndex === -1) { + return false + } + const sequence = data.slice(offset, finalByteIndex + 1) + if (isStatefulRendererReplyCsiQuery(sequence)) { + return true + } + offset = data.indexOf('\x1b[', finalByteIndex + 1) + } + return false +} + +function findCsiFinalByteIndex(data: string, offset: number): number { + for (let index = offset; index < data.length; index++) { + const code = data.charCodeAt(index) + if (code >= 0x40 && code <= 0x7e) { + return index + } + } + return -1 +} + +function isStatelessRendererReplyCsiQuery(sequence: string): boolean { + if (sequence.endsWith('c')) { + return true + } + return sequence === '\x1b[5n' +} + +function isStatefulRendererReplyCsiQuery(sequence: string): boolean { + return sequence === '\x1b[6n' || (sequence.startsWith('\x1b[?') && sequence.endsWith('$p')) +} + let codexRestartNoticePresenceSource: Record< string, { previousAccountLabel: string; nextAccountLabel: string } @@ -1873,6 +2034,8 @@ export function connectPanePty( } const startFreshSpawn = (): void => { + clearPaneMode2031State() + clearHiddenOutputRestoreState() // Why: pre-signal the main process so its cooperation gate suppresses // the daemon-snapshot seed for this paneKey. We issue declare and the // spawn back-to-back without awaiting, because Electron's @@ -2016,9 +2179,9 @@ export function connectPanePty( let foregroundImmediateBudgetChars = 0 let foregroundImmediateBudgetWindowStart = 0 let hiddenMode2031ScanTail = '' - const hiddenStartupRendererQueryUntil = shouldKeepHiddenStartupRendererQueriesLive(paneStartup) - ? Date.now() + HIDDEN_STARTUP_RENDERER_QUERY_WINDOW_MS - : 0 + const shouldSnapshotHiddenCodexOutput = shouldKeepHiddenStartupRendererQueriesLive(paneStartup) + let hiddenStartupRendererQueryPending = '' + let hiddenRendererStateDirty = false function canUseMainBufferSnapshot(ptyId: string | null): ptyId is string { return Boolean(ptyId) && !isRemoteRuntimePtyId(ptyId) @@ -2051,18 +2214,14 @@ export function connectPanePty( return transport.serializeBuffer(opts) } - function isHiddenStartupRendererQueryWindowActive(): boolean { - return ( - paneStartup !== null && - Date.now() < hiddenStartupRendererQueryUntil && - !shouldWritePtyOutputForeground(deps.isVisibleRef.current) - ) - } - function respondToSkippedMode2031Subscribe(data: string): void { const scan = scanMode2031Sequences(hiddenMode2031ScanTail, data) hiddenMode2031ScanTail = scan.tail - if (!scan.subscribe) { + if (scan.finalState === 'unsubscribed') { + deps.paneMode2031Ref.current.delete(pane.id) + deps.paneLastThemeModeRef.current.delete(pane.id) + } + if (scan.finalState !== 'subscribed') { return } const settings = useAppStore.getState().settings @@ -2070,7 +2229,9 @@ export function connectPanePty( // Why: hidden snapshot-backed panes skip xterm.write for PTY bytes. Answer // mode 2031 out-of-band so TUIs still render the snapshot with the same // theme-dependent styling they would have used in a visible pane. + deps.paneMode2031Ref.current.set(pane.id, true) transport.sendInput(mode2031SequenceFor(mode)) + deps.paneLastThemeModeRef.current.set(pane.id, mode) recordHiddenMode2031Reply() } function beforeTerminalOutputWrite(): void { @@ -2169,14 +2330,19 @@ export function connectPanePty( ) } - function writePtyOutputToXterm(data: string, foreground: boolean): void { + function writePtyOutputToXterm( + data: string, + foreground: boolean, + opts?: { hiddenStartupRendererQuery?: boolean } + ): void { if (foreground) { resetHiddenOutputRestoreIfPtyChanged() } const parseHiddenStartupOutput = !foreground && canUseHiddenOutputSnapshot(transport.getPtyId()) && - isHiddenStartupRendererQueryWindowActive() + shouldSnapshotHiddenCodexOutput && + (opts?.hiddenStartupRendererQuery === true || containsHiddenStartupRendererQuery(data)) const synchronizedOutputStarted = shouldProtectNativeWindowsSynchronizedOutput && foreground && @@ -2245,18 +2411,124 @@ export function connectPanePty( } 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 + if ( + foreground || + !shouldSnapshotHiddenCodexOutput || + !canUseHiddenOutputSnapshot(transport.getPtyId()) + ) { + return false + } + // Why: CPR/DECRQM replies depend on ordered terminal state. Keep the rare + // clean stateful-query chunk live; after skipped bytes, avoid stale replies. + return hiddenRendererStateDirty || !containsStatefulRendererQuery(data) + } + + function writeHiddenStartupRendererQueries(data: string): void { + const extracted = extractHiddenStartupRendererQueryData( + data, + hiddenStartupRendererQueryPending + ) + hiddenStartupRendererQueryPending = extracted.pending + if (extracted.statelessQueryData) { + writePtyOutputToXterm(extracted.statelessQueryData, false, { + hiddenStartupRendererQuery: true + }) + } + // Stateful hidden queries require ordered terminal state. If this pane's + // hidden xterm is dirty, skipping is safer than sending stale CPR/DECRQM. + } + + function takeHiddenStartupRendererQueryPendingForForeground(data: string): { + statelessQueryData: string + statefulQueryData: string + remainingData: string + consumedCurrentChars: number + } { + const pending = hiddenStartupRendererQueryPending + hiddenStartupRendererQueryPending = '' + if (!pending) { + return { + statelessQueryData: '', + statefulQueryData: '', + remainingData: data, + consumedCurrentChars: 0 + } + } + + const input = pending + data + let statelessQueryData = '' + let statefulQueryData = '' + let consumedInputChars = pending.length + let nextPending = '' + if (input.startsWith('\x1b[')) { + const finalByteIndex = findCsiFinalByteIndex(input, 2) + if (finalByteIndex === -1) { + nextPending = input.slice(0, HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS) + consumedInputChars = input.length + } else { + const sequence = input.slice(0, finalByteIndex + 1) + if (isStatelessRendererReplyCsiQuery(sequence)) { + statelessQueryData = sequence + } else if (isStatefulRendererReplyCsiQuery(sequence)) { + statefulQueryData = sequence + } + consumedInputChars = finalByteIndex + 1 + } + } else if (input.startsWith('\x1b]')) { + const matchingPrefix = HIDDEN_STARTUP_OSC_COLOR_QUERY_PREFIXES.find((prefix) => + input.startsWith(prefix) + ) + const terminatorIndex = findOscTerminatorIndex(input, 2) + if ( + !matchingPrefix && + HIDDEN_STARTUP_OSC_COLOR_QUERY_PREFIXES.some((prefix) => prefix.startsWith(input)) + ) { + nextPending = input + consumedInputChars = input.length + } else if (terminatorIndex === -1) { + nextPending = input.slice(0, HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS) + consumedInputChars = input.length + } else { + if (matchingPrefix) { + statelessQueryData = input.slice(0, terminatorIndex) + } + consumedInputChars = terminatorIndex + } + } else if (input.length === 1) { + nextPending = input + consumedInputChars = input.length + } else { + consumedInputChars = pending.length + } + + hiddenStartupRendererQueryPending = nextPending + const consumedCurrentChars = Math.max(0, consumedInputChars - pending.length) + return { + statelessQueryData, + statefulQueryData, + remainingData: data.slice(consumedCurrentChars), + consumedCurrentChars + } + } + + function metaAfterConsumingCurrentChars( + meta: PtyDataMeta | undefined, + consumedCurrentChars: number + ): PtyDataMeta | undefined { + if (consumedCurrentChars === 0 || typeof meta?.rawLength !== 'number') { + return meta + } + return { + ...meta, + rawLength: Math.max(0, meta.rawLength - consumedCurrentChars) + } } function skipHiddenRendererOutput(data: string): void { + writeHiddenStartupRendererQueries(data) respondToSkippedMode2031Subscribe(data) markHiddenOutputRestoreNeeded() + hiddenRendererStateDirty = true if (hiddenOutputRestoreInFlight) { hiddenOutputRestoreFreshSnapshotNeeded = true } @@ -2399,11 +2671,18 @@ export function connectPanePty( function clearHiddenOutputRestoreState(): void { clearPendingLiveChunksDuringRestore() + hiddenStartupRendererQueryPending = '' + hiddenRendererStateDirty = false hiddenOutputRestoreNeeded = false hiddenOutputRestorePtyId = null hiddenOutputRestoreGeneration += 1 } + function clearPaneMode2031State(): void { + deps.paneMode2031Ref.current.delete(pane.id) + deps.paneLastThemeModeRef.current.delete(pane.id) + } + function resetHiddenOutputRestoreIfPtyChanged(): void { if (hiddenOutputRestorePtyId === null) { return @@ -2412,6 +2691,7 @@ export function connectPanePty( // Why: renderer backlog is tied to the old PTY stream; after reattach, // queued hidden bytes must not delay or replay before the new PTY. clearHiddenOutputRestoreState() + clearPaneMode2031State() discardTerminalOutput(pane.terminal) } } @@ -2475,6 +2755,7 @@ export function connectPanePty( writeReplayData('\x1b[2J\x1b[3J\x1b[H') writeReplayData(snapshot.data) writeReplayData(POST_REPLAY_LIVE_SNAPSHOT_RESET) + hiddenRendererStateDirty = false recordTerminalOutput(pane.terminal) const currentPtyId = transport.getPtyId() if (currentPtyId && !getFitOverrideForPty(currentPtyId)) { @@ -2660,6 +2941,24 @@ export function connectPanePty( // output the user is watching. Throttle only when the pane or whole // Electron document is hidden. const foreground = shouldWritePtyOutputForeground(deps.isVisibleRef.current) + if (foreground && hiddenMode2031ScanTail) { + respondToSkippedMode2031Subscribe(data) + } + // Why: a hidden Codex query can be split just before visibility changes; + // xterm needs the completed query, while other bytes still follow restore. + const pendingForegroundQuery = foreground + ? takeHiddenStartupRendererQueryPendingForForeground(data) + : null + const rendererData = pendingForegroundQuery?.remainingData ?? data + const rendererMeta = metaAfterConsumingCurrentChars( + meta, + pendingForegroundQuery?.consumedCurrentChars ?? 0 + ) + if (pendingForegroundQuery?.statelessQueryData) { + writePtyOutputToXterm(pendingForegroundQuery.statelessQueryData, true, { + hiddenStartupRendererQuery: true + }) + } const restoreAppliesToCurrentPty = hiddenOutputRestorePtyId !== null && transport.getPtyId() === hiddenOutputRestorePtyId if (shouldSkipHiddenRendererOutput(foreground, data)) { @@ -2669,14 +2968,22 @@ export function connectPanePty( restoreAppliesToCurrentPty ) { if (foreground) { - queueLiveChunkDuringRestore(data, meta) + if (pendingForegroundQuery?.statefulQueryData) { + queueLiveChunkDuringRestore(pendingForegroundQuery.statefulQueryData) + } + queueLiveChunkDuringRestore(rendererData, rendererMeta) requestHiddenOutputRestoreIfNeeded() } else if (hiddenOutputRestoreInFlight) { hiddenOutputRestoreNeeded = true hiddenOutputRestoreFreshSnapshotNeeded = true } } else { - writePtyOutputToXterm(data, foreground) + if (pendingForegroundQuery?.statefulQueryData) { + writePtyOutputToXterm(pendingForegroundQuery.statefulQueryData, true, { + hiddenStartupRendererQuery: true + }) + } + writePtyOutputToXterm(rendererData, foreground) } schedulePendingStartupCommandDelivery() @@ -2981,6 +3288,8 @@ export function connectPanePty( ? Promise.resolve(null) : window.api.pty.declarePendingPaneSerializer(cacheKey).catch(() => null) let expiredReattachError = false + clearPaneMode2031State() + clearHiddenOutputRestoreState() const reattachPromise = transport.connect({ url: '', cols, @@ -3224,6 +3533,8 @@ export function connectPanePty( // off — otherwise the next remount reads the same dead ptyId from // the store and lands in this branch again in a loop. try { + clearPaneMode2031State() + clearHiddenOutputRestoreState() transport.attach({ existingPtyId: attachPtyId, cols, @@ -3277,6 +3588,8 @@ export function connectPanePty( // even if no later spawn event or layout snapshot runs. deps.syncPanePtyLayoutBinding(pane.id, spawnedPtyId) deps.updateTabPtyId(deps.tabId, spawnedPtyId) + clearPaneMode2031State() + clearHiddenOutputRestoreState() transport.attach({ existingPtyId: spawnedPtyId, cols, diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 425f56d8e..558579482 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -523,6 +523,8 @@ export function useTerminalPaneLifecycle({ cwd, startup, paneTransportsRef, + paneMode2031Ref, + paneLastThemeModeRef, replayingPanesRef, isActiveRef, isVisibleRef, diff --git a/src/shared/terminal-color-scheme-protocol.test.ts b/src/shared/terminal-color-scheme-protocol.test.ts index f6e2da18f..76a320a91 100644 --- a/src/shared/terminal-color-scheme-protocol.test.ts +++ b/src/shared/terminal-color-scheme-protocol.test.ts @@ -22,14 +22,32 @@ describe('terminal color scheme protocol', () => { it('detects mode 2031 subscribes in compound and split private mode sequences', () => { expect(scanMode2031Sequences('', '\x1b[?25;2031h')).toMatchObject({ subscribe: true, + finalState: 'subscribed', tail: '' }) const first = scanMode2031Sequences('', '\x1b[?20') - expect(first).toMatchObject({ subscribe: false, tail: '\x1b[?20' }) + expect(first).toMatchObject({ subscribe: false, finalState: null, tail: '\x1b[?20' }) expect(scanMode2031Sequences(first.tail, '31h')).toMatchObject({ subscribe: true, + finalState: 'subscribed', + tail: '' + }) + }) + + it('reports the final mode 2031 state in match order', () => { + expect(scanMode2031Sequences('', '\x1b[?2031h\x1b[?2031l')).toMatchObject({ + subscribe: true, + unsubscribe: true, + finalState: 'unsubscribed', + tail: '' + }) + + expect(scanMode2031Sequences('', '\x1b[?2031l\x1b[?2031h')).toMatchObject({ + subscribe: true, + unsubscribe: true, + finalState: 'subscribed', tail: '' }) }) diff --git a/src/shared/terminal-color-scheme-protocol.ts b/src/shared/terminal-color-scheme-protocol.ts index df991e8c2..22c8c426f 100644 --- a/src/shared/terminal-color-scheme-protocol.ts +++ b/src/shared/terminal-color-scheme-protocol.ts @@ -22,12 +22,14 @@ export function resolveTerminalColorSchemeMode( export type Mode2031ScanResult = { subscribe: boolean unsubscribe: boolean + finalState: 'subscribed' | 'unsubscribed' | null tail: string } const NO_MODE_2031_SEQUENCE: Mode2031ScanResult = { subscribe: false, unsubscribe: false, + finalState: null, tail: '' } @@ -39,6 +41,7 @@ export function scanMode2031Sequences(previousTail: string, data: string): Mode2 const result: Mode2031ScanResult = { subscribe: false, unsubscribe: false, + finalState: null, tail: extractPrivateModeScanTail(input) } // oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars @@ -51,8 +54,10 @@ export function scanMode2031Sequences(previousTail: string, data: string): Mode2 } if ((match[2] ?? match[4]) === 'h') { result.subscribe = true + result.finalState = 'subscribed' } else { result.unsubscribe = true + result.finalState = 'unsubscribed' } } return result