Keep Codex terminals responsive during startup (#5301)
This commit is contained in:
parent
3a87a9e572
commit
bf31ecbad7
|
|
@ -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<Dirent[]>>(),
|
||||
statMock: vi.fn<(filePath: string) => Promise<Stats>>()
|
||||
}))
|
||||
const { getLegacyCopiedCodexSessionBridgeScanPreferenceMock, readdirMock, statMock } = vi.hoisted(
|
||||
() => ({
|
||||
getLegacyCopiedCodexSessionBridgeScanPreferenceMock: vi.fn(),
|
||||
readdirMock: vi.fn<(dirPath: string) => Promise<Dirent[]>>(),
|
||||
statMock: vi.fn<(filePath: string) => Promise<Stats>>()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('fs/promises', async () => {
|
||||
const actual = await vi.importActual<typeof FsPromises>('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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
|||
}
|
||||
|
||||
async function yieldToEventLoop(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
}
|
||||
|
||||
async function walkJsonlFiles(dirPath: string): Promise<string[]> {
|
||||
async function walkJsonlFiles(
|
||||
dirPath: string,
|
||||
progress: { entriesVisited: number } = { entriesVisited: 0 }
|
||||
): Promise<string[]> {
|
||||
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<string[]> {
|
||||
const files: string[] = []
|
||||
for (const dirPath of getCodexSessionDirectories()) {
|
||||
|
|
@ -141,29 +153,37 @@ export async function listCodexSessionFiles(): Promise<string[]> {
|
|||
// 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<string[]> {
|
||||
async function dedupeCodexSessionFileAliases(
|
||||
files: string[],
|
||||
hasLegacyBridgeMarkers: boolean
|
||||
): Promise<string[]> {
|
||||
const excludedAliases = new Set<string>()
|
||||
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<string>()
|
||||
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<string[]>
|
|||
}
|
||||
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<string> {
|
|||
return `path:${await canonicalizePath(filePath)}`
|
||||
}
|
||||
|
||||
function getLegacySourceSkipBytesByPath(files: string[]): Map<string, number> {
|
||||
function getLegacySourceSkipBytesByPath(
|
||||
files: string[],
|
||||
hasLegacyBridgeMarkers = hasLegacyCopiedSessionBridgeMarkers()
|
||||
): Map<string, number> {
|
||||
const sourceSkipBytesByPath = new Map<string, number>()
|
||||
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<string, CodexUsageDailyAggregate>,
|
||||
dailyAggregates: CodexUsageDailyAggregate[]
|
||||
|
|
|
|||
|
|
@ -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<typeof Fs>('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<T>(): {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
reject: (reason?: unknown) => void
|
||||
} {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve
|
||||
reject = promiseReject
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function createEmptyScanResult(): ScanResult {
|
||||
return {
|
||||
processedFiles: [],
|
||||
sessions: [],
|
||||
dailyAggregates: []
|
||||
}
|
||||
}
|
||||
|
||||
function createStoreWithState(state: Partial<CodexUsagePersistedState>): CodexUsageStore {
|
||||
const store = new CodexUsageStore({
|
||||
getRepos: () => [],
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getWorktreeMeta: () => undefined
|
||||
} as never)
|
||||
|
||||
|
|
@ -39,11 +91,79 @@ function createStoreWithState(state: Partial<CodexUsagePersistedState>): 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<ScanResult>()
|
||||
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: [
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ vi.mock('react', async () => {
|
|||
useLayoutEffect: () => {},
|
||||
useCallback: <T extends (...args: never[]) => unknown>(callback: T) => callback,
|
||||
useMemo: <T>(factory: () => T) => factory(),
|
||||
useCallback: <T extends (...args: never[]) => unknown>(callback: T) => callback,
|
||||
useRef: <T>(current: T) => ({ current }),
|
||||
useState: <T>(initial: T | (() => T)) => {
|
||||
const value = typeof initial === 'function' ? (initial as () => T)() : initial
|
||||
|
|
|
|||
|
|
@ -1038,6 +1038,8 @@ export default function TerminalPane({
|
|||
cwd,
|
||||
startup: { command: 'codex' },
|
||||
paneTransportsRef,
|
||||
paneMode2031Ref,
|
||||
paneLastThemeModeRef,
|
||||
replayingPanesRef,
|
||||
isActiveRef,
|
||||
isVisibleRef,
|
||||
|
|
|
|||
|
|
@ -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<string, string>
|
||||
paneTransportsRef: React.RefObject<Map<number, PtyTransport>>
|
||||
paneMode2031Ref: React.RefObject<Map<number, boolean>>
|
||||
paneLastThemeModeRef: React.RefObject<Map<number, TerminalColorSchemeMode>>
|
||||
replayingPanesRef: ReplayingPanesRef
|
||||
isActiveRef: React.RefObject<boolean>
|
||||
isVisibleRef: React.RefObject<boolean>
|
||||
|
|
|
|||
|
|
@ -343,6 +343,8 @@ function createDeps(overrides: Record<string, unknown> = {}) {
|
|||
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<number, boolean>() }
|
||||
const paneLastThemeModeRef = { current: new Map<number, 'dark' | 'light'>() }
|
||||
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<number, boolean>() }
|
||||
const paneLastThemeModeRef = { current: new Map<number, 'dark' | 'light'>() }
|
||||
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<number, boolean>() }
|
||||
const paneLastThemeModeRef = { current: new Map<number, 'dark' | 'light'>() }
|
||||
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')
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -523,6 +523,8 @@ export function useTerminalPaneLifecycle({
|
|||
cwd,
|
||||
startup,
|
||||
paneTransportsRef,
|
||||
paneMode2031Ref,
|
||||
paneLastThemeModeRef,
|
||||
replayingPanesRef,
|
||||
isActiveRef,
|
||||
isVisibleRef,
|
||||
|
|
|
|||
|
|
@ -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: ''
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue