diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index cfa288264..04b187b37 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -6411,6 +6411,140 @@ describe('OrcaRuntimeService', () => { expect(read.latestCursor).toBe('1') }) + it('does not retain split ANSI controls as visible terminal preview text', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', 'Working\r\x1b[', 100) + runtime.onPtyData('pty-1', '38;2;190;210;223;49mWo', 101) + + const colorRead = await runtime.readTerminal(terminal.handle) + const colorRetained = colorRead.tail.join('\n') + expect(colorRetained).toContain('Wo') + expect(colorRetained).not.toContain('38;2') + expect(colorRetained).not.toContain('49m') + + runtime.onPtyData('pty-1', 'rking\x1b[?2026', 102) + runtime.onPtyData('pty-1', 'l', 103) + + const modeRead = await runtime.readTerminal(terminal.handle) + const retained = modeRead.tail.join('\n') + expect(retained).toContain('Working') + expect(retained).not.toContain('38;2') + expect(retained).not.toContain('?2026') + expect(retained).not.toContain('49m') + + runtime.onPtyData('pty-1', ` done\x1b]0;${'x'.repeat(5000)}`, 104) + runtime.onPtyData('pty-1', '\u0007\n', 105) + + const longRead = await runtime.readTerminal(terminal.handle) + const longRetained = longRead.tail.join('\n') + expect(longRetained).toContain('Working done') + expect(longRetained).not.toContain('x'.repeat(100)) + const pty = ( + runtime as unknown as { + ptysById: Map + } + ).ptysById.get('pty-1') + expect(pty?.lastOscTitle).toBe('x'.repeat(4092)) + }) + + it('does not retain split ST-terminated string controls as preview text', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', 'Before \x1b_Gi=31337,s=1,', 100) + runtime.onPtyData('pty-1', 'v=1,a=q,t=d,f=24;AAAA\x1b\\After\n', 101) + + const read = await runtime.readTerminal(terminal.handle) + const retained = read.tail.join('\n') + expect(retained).toContain('BeforeAfter') + expect(retained).not.toContain('Gi=31337') + expect(retained).not.toContain('AAAA') + }) + + it('preserves non-ASCII terminal preview text in chunks with controls', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b[32mHéllo 🌊\x1b[0m\n', 100) + + const read = await runtime.readTerminal(terminal.handle) + expect(read.tail).toEqual(['Héllo 🌊']) + }) + + it('detects split OSC titles before retaining terminal previews', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '\x1b]0;Codex work', 100) + runtime.onPtyData('pty-1', 'ing\x07Visible\n', 101) + + const pty = ( + runtime as unknown as { + ptysById: Map + } + ).ptysById.get('pty-1') + expect(pty?.lastOscTitle).toBe('Codex working') + expect(pty?.lastAgentStatus).toBe('working') + + const [terminal] = (await runtime.listTerminals()).terminals + const read = await runtime.readTerminal(terminal.handle) + expect(read.tail.join('\n')).toContain('Visible') + expect(read.tail.join('\n')).not.toContain('Codex working') + }) + + it('detects ST-terminated OSC titles split before the final backslash', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x1b', 100) + runtime.onPtyData('pty-1', '\\Visible\n', 101) + + const pty = ( + runtime as unknown as { + ptysById: Map + } + ).ptysById.get('pty-1') + expect(pty?.lastOscTitle).toBe('Codex working') + expect(pty?.lastAgentStatus).toBe('working') + }) + + it('preserves a trailing escape after a completed OSC title', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07\x1b', 100) + runtime.onPtyData('pty-1', ']0;Codex done\x07Visible\n', 101) + + const pty = ( + runtime as unknown as { + ptysById: Map + } + ).ptysById.get('pty-1') + expect(pty?.lastOscTitle).toBe('Codex done') + expect(pty?.lastAgentStatus).toBe('idle') + }) + + it('seeds newly synced leaves from PTY pending ANSI state', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.registerPty('pty-1', TEST_WORKTREE_ID) + runtime.onPtyData('pty-1', 'Working\r\x1b[', 100) + + syncSinglePty(runtime) + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '38;2;190;210;223;49mDone\n', 101) + + const read = await runtime.readTerminal(terminal.handle) + const retained = read.tail.join('\n') + expect(retained).toContain('Done') + expect(retained).not.toContain('38;2') + expect(retained).not.toContain('49m') + }) + it('bounds retained partial terminal output before preview reads', async () => { const runtime = new OrcaRuntimeService(store) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 9f5b4726e..ca3fc68ab 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -7,6 +7,7 @@ import { isClaudeManagementTitle, isShellProcess } from '../../shared/agent-detection' +import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail' import type { AgentStatus } from '../../shared/agent-detection' import { AGENT_STATUS_STALE_AFTER_MS, @@ -774,6 +775,7 @@ type RuntimeLeafRecord = RuntimeSyncedLeaf & { lastExitCode: number | null tailBuffer: string[] tailPartialLine: string + tailPendingAnsi: string tailTruncated: boolean tailLinesTotal: number preview: string @@ -834,6 +836,7 @@ type RuntimePtyWorktreeRecord = { lastOutputAt: number | null tailBuffer: string[] tailPartialLine: string + tailPendingAnsi: string tailTruncated: boolean tailLinesTotal: number preview: string @@ -1705,6 +1708,9 @@ export class OrcaRuntimeService { string, ReturnType >() + // Why: ordinary OSC 0/1/2 titles can split across PTY chunks, especially over + // SSH/relay buffering. Keep a small raw scan tail so status titles are not lost. + private oscTitleScanTailByPtyId = new Map() // Why: latest agent-status payload per pane, retained so worktree.ps can serve // mobile the same inline agent rows the desktop sidebar renders. Cleared on pty // teardown so dead agents don't linger. See RuntimeAgentRowSnapshot. @@ -2412,6 +2418,8 @@ export class OrcaRuntimeService { existing && existing.ptyId !== ptyId ? existing.ptyGeneration + 1 : (existing?.ptyGeneration ?? 0) + const existingPty = ptyId ? this.ptysById.get(ptyId) : undefined + const tailSource = existing?.ptyId === ptyId ? existing : existingPty nextLeaves.set(leafKey, { ...leaf, @@ -2419,16 +2427,17 @@ export class OrcaRuntimeService { ptyGeneration, connected: ptyId !== null, writable: this.graphStatus === 'ready' && ptyId !== null, - lastOutputAt: existing?.ptyId === ptyId ? existing.lastOutputAt : null, - lastExitCode: existing?.ptyId === ptyId ? existing.lastExitCode : null, - tailBuffer: existing?.ptyId === ptyId ? existing.tailBuffer : [], - tailPartialLine: existing?.ptyId === ptyId ? existing.tailPartialLine : '', - tailTruncated: existing?.ptyId === ptyId ? existing.tailTruncated : false, - tailLinesTotal: existing?.ptyId === ptyId ? existing.tailLinesTotal : 0, - preview: existing?.ptyId === ptyId ? existing.preview : '', - lastAgentStatus: existing?.ptyId === ptyId ? existing.lastAgentStatus : null, - lastOscTitle: existing?.ptyId === ptyId ? existing.lastOscTitle : null, - lastOscTitleAt: existing?.ptyId === ptyId ? existing.lastOscTitleAt : null, + lastOutputAt: tailSource?.lastOutputAt ?? null, + lastExitCode: tailSource?.lastExitCode ?? null, + tailBuffer: tailSource?.tailBuffer ?? [], + tailPartialLine: tailSource?.tailPartialLine ?? '', + tailPendingAnsi: tailSource?.tailPendingAnsi ?? '', + tailTruncated: tailSource?.tailTruncated ?? false, + tailLinesTotal: tailSource?.tailLinesTotal ?? 0, + preview: tailSource?.preview ?? '', + lastAgentStatus: tailSource?.lastAgentStatus ?? null, + lastOscTitle: tailSource?.lastOscTitle ?? null, + lastOscTitleAt: tailSource?.lastOscTitleAt ?? null, paneTitleUpdatedAt: existing?.ptyId === ptyId && existing.paneTitle === leaf.paneTitle ? existing.paneTitleUpdatedAt @@ -3746,20 +3755,16 @@ export class OrcaRuntimeService { // strips the escape sequences. Agent CLIs (Claude Code, Gemini, etc.) // announce status via OSC 0/1/2 title sequences — this is the same // detection path the renderer uses for notifications and sidebar badges. - const oscTitle = extractLastOscTitle(data) + const oscTitle = this.extractLastOscTitleForPty(ptyId, data) const agentStatus = oscTitle ? detectAgentStatusFromTitle(oscTitle) : null - let normalizedData: string | null = null - const getNormalizedData = (): string => { - normalizedData ??= normalizeTerminalChunk(data) - return normalizedData - } const pty = this.getOrCreatePtyWorktreeRecord(ptyId) let shouldTouchPtyBackedSessionTabs = false const ptyTailBefore = pty ? { lines: pty.tailBuffer, partialLine: pty.tailPartialLine, + pendingAnsi: pty.tailPendingAnsi, truncated: pty.tailTruncated, linesTotal: pty.tailLinesTotal } @@ -3769,10 +3774,12 @@ export class OrcaRuntimeService { pty.connected = true pty.disconnectedAt = null pty.lastOutputAt = at + const normalized = normalizeTerminalChunk(data, pty.tailPendingAnsi) + pty.tailPendingAnsi = normalized.pendingAnsi const nextTail = appendNormalizedToTailBuffer( pty.tailBuffer, pty.tailPartialLine, - getNormalizedData() + normalized.text ) ptyTailAfter = nextTail pty.tailBuffer = nextTail.lines @@ -3814,6 +3821,7 @@ export class OrcaRuntimeService { tailStateMatches( leaf.tailBuffer, leaf.tailPartialLine, + leaf.tailPendingAnsi, leaf.tailTruncated, leaf.tailLinesTotal, ptyTailBefore @@ -3823,14 +3831,17 @@ export class OrcaRuntimeService { // the PTY tail update instead of splitting large output twice. leaf.tailBuffer = pty.tailBuffer leaf.tailPartialLine = pty.tailPartialLine + leaf.tailPendingAnsi = pty.tailPendingAnsi leaf.tailTruncated = pty.tailTruncated leaf.tailLinesTotal = pty.tailLinesTotal leaf.preview = pty.preview } else { + const normalized = normalizeTerminalChunk(data, leaf.tailPendingAnsi) + leaf.tailPendingAnsi = normalized.pendingAnsi const nextTail = appendNormalizedToTailBuffer( leaf.tailBuffer, leaf.tailPartialLine, - getNormalizedData() + normalized.text ) leaf.tailBuffer = nextTail.lines leaf.tailPartialLine = nextTail.partialLine @@ -3893,6 +3904,21 @@ export class OrcaRuntimeService { return processor(data) } + private extractLastOscTitleForPty(ptyId: string, data: string): string | null { + const previousTail = this.oscTitleScanTailByPtyId.get(ptyId) + if (!previousTail && !data.includes('\x1b')) { + return null + } + const input = `${previousTail ?? ''}${data}` + const scanTail = extractOscTitleScanTail(input) + if (scanTail.length > 0) { + this.oscTitleScanTailByPtyId.set(ptyId, scanTail) + } else { + this.oscTitleScanTailByPtyId.delete(ptyId) + } + return extractLastOscTitle(input) + } + private emitTerminalAgentStatusEvents(ptyId: string, chunk: ProcessedAgentStatusChunk): void { // Why: snapshot retention (for mobile worktree.ps) must run even when no // renderer listener is attached, so we don't early-return on a missing @@ -5193,6 +5219,7 @@ export class OrcaRuntimeService { this.recentPtyOutputById.delete(ptyId) this.ptyOutputSequenceById.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) + this.oscTitleScanTailByPtyId.delete(ptyId) this.clearAgentRowSnapshotsForPty(ptyId) // Layout state machine: clear `layouts` and `layoutQueues`. Any // already-queued applyLayout work for this ptyId will run, but every @@ -14183,6 +14210,7 @@ export class OrcaRuntimeService { lastOutputAt: state.lastOutputAt ?? null, tailBuffer: [], tailPartialLine: '', + tailPendingAnsi: '', tailTruncated: false, tailLinesTotal: 0, preview: state.preview ?? '' @@ -14301,6 +14329,7 @@ export class OrcaRuntimeService { // but their retained transcripts must not accumulate after the process dies. pty.tailBuffer = [] pty.tailPartialLine = '' + pty.tailPendingAnsi = '' pty.tailTruncated = false pty.tailLinesTotal = 0 } @@ -14324,6 +14353,7 @@ export class OrcaRuntimeService { this.recentPtyOutputById.delete(ptyId) this.ptyOutputSequenceById.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) + this.oscTitleScanTailByPtyId.delete(ptyId) this.clearAgentRowSnapshotsForPty(ptyId) const handle = this.handleByPtyId.get(ptyId) if (handle) { @@ -18574,6 +18604,7 @@ export class OrcaRuntimeService { const MAX_TAIL_LINES = 2000 const MAX_TAIL_CHARS = 256 * 1024 const MAX_TAIL_PARTIAL_CHARS = 4000 +const MAX_TAIL_PENDING_ANSI_CHARS = 4096 const DEFAULT_TERMINAL_READ_LIMIT = 120 const MAX_TERMINAL_READ_LIMIT = 2000 const MAX_TERMINAL_PREVIEW_CHARS = 32 * 1024 @@ -18861,23 +18892,38 @@ function parseAnsiControlSequence( } return null } + if (isStTerminatedStringControlIntroducer(introducer)) { + for (let index = escapeIndex + 2; index < value.length; index += 1) { + if (value[index] === '\u001b' && value[index + 1] === '\\') { + return { kind: 'other', endIndex: index + 1 } + } + } + return null + } return { kind: 'other', endIndex: escapeIndex + 1 } } +function isStTerminatedStringControlIntroducer(introducer: string | undefined): boolean { + return introducer === 'P' || introducer === 'X' || introducer === '^' || introducer === '_' +} + function tailStateMatches( lines: string[], partialLine: string, + pendingAnsi: string, truncated: boolean, linesTotal: number, snapshot: { lines: string[] partialLine: string + pendingAnsi: string truncated: boolean linesTotal: number } ): boolean { if ( partialLine !== snapshot.partialLine || + pendingAnsi !== snapshot.pendingAnsi || truncated !== snapshot.truncated || linesTotal !== snapshot.linesTotal || lines.length !== snapshot.lines.length @@ -19475,18 +19521,50 @@ function mergeWorktreeStatus( return WORKTREE_STATUS_PRIORITY[next] > WORKTREE_STATUS_PRIORITY[current] ? next : current } -function normalizeTerminalChunk(chunk: string): string { +function normalizeTerminalChunk( + chunk: string, + pendingAnsi: string = '' +): { text: string; pendingAnsi: string } { // Why: most high-throughput PTY chunks are plain printable text. Avoid // running every ANSI/OSC regex over megabytes that do not need normalization. - if (!terminalChunkNeedsNormalization(chunk)) { - return chunk + if (pendingAnsi.length === 0 && !terminalChunkNeedsNormalization(chunk)) { + return { text: chunk, pendingAnsi: '' } } - return chunk - .replace(/\r\n/g, '\n') - .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') - .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '') - .replace(/\x1b[@-_]/g, '') - .replace(/[^\x08\x09\x0a\x0d\x20-\x7e]/g, '') + const combined = `${pendingAnsi}${chunk}` + let text = '' + for (let index = 0; index < combined.length; index += 1) { + const char = combined[index] + if (char === '\x1b') { + if (index + 1 >= combined.length) { + return { text, pendingAnsi: combined.slice(index) } + } + const parsed = parseAnsiControlSequence(combined, index) + if (!parsed) { + return { + text, + pendingAnsi: trimPendingAnsiControl(combined.slice(index)) + } + } + index = parsed.endIndex + continue + } + if (char === '\r' && combined[index + 1] === '\n') { + text += '\n' + index += 1 + continue + } + const code = combined.charCodeAt(index) + if (code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0d) { + text += char + } else if (isTerminalPreviewPrintableCodeUnit(code)) { + text += char + } + } + return { text, pendingAnsi: '' } +} + +function isTerminalPreviewPrintableCodeUnit(code: number): boolean { + return code >= 0x20 && code !== 0x7f && (code < 0x80 || code > 0x9f) } function terminalChunkNeedsNormalization(chunk: string): boolean { @@ -19494,10 +19572,11 @@ function terminalChunkNeedsNormalization(chunk: string): boolean { const code = chunk.charCodeAt(index) if ( code === 0x1b || + code === 0x7f || code === 0x0d || code < 0x09 || (code > 0x0a && code < 0x20) || - code > 0x7e + (code >= 0x80 && code <= 0x9f) ) { return true } @@ -19505,6 +19584,15 @@ function terminalChunkNeedsNormalization(chunk: string): boolean { return false } +function trimPendingAnsiControl(value: string): string { + if (value.length <= MAX_TAIL_PENDING_ANSI_CHARS) { + return value + } + const introducer = value.slice(0, Math.min(2, value.length)) + const suffixBudget = Math.max(0, MAX_TAIL_PENDING_ANSI_CHARS - introducer.length) + return `${introducer}${value.slice(-suffixBudget)}` +} + function maxTimestamp(left: number | null, right: number | null): number | null { if (left === null) { return right diff --git a/src/main/stats/agent-detector.test.ts b/src/main/stats/agent-detector.test.ts index 8ed3c785c..c65acb52f 100644 --- a/src/main/stats/agent-detector.test.ts +++ b/src/main/stats/agent-detector.test.ts @@ -84,6 +84,104 @@ describe('AgentDetector', () => { expect(stats.onAgentStop).toHaveBeenCalledWith('pty-1', 120) }) + it('records lifecycle transitions from split OSC titles', () => { + const stats = { + onAgentStart: vi.fn(), + onAgentStop: vi.fn() + } + const detector = new AgentDetector(stats as never) + + detector.onData('pty-1', '\x1b]0;Codex work', 100) + detector.onData('pty-1', 'ing\x07', 101) + detector.onData('pty-1', 'meaningful output', 120) + detector.onData('pty-1', '\x1b]0;Codex do', 140) + detector.onData('pty-1', 'ne\x07', 141) + + expect(stats.onAgentStart).toHaveBeenCalledTimes(1) + expect(stats.onAgentStart).toHaveBeenCalledWith('pty-1', 101) + expect(stats.onAgentStop).toHaveBeenCalledTimes(1) + expect(stats.onAgentStop).toHaveBeenCalledWith('pty-1', 120) + }) + + it('does not treat an ST-split OSC title as meaningful output', () => { + const stats = { + onAgentStart: vi.fn(), + onAgentStop: vi.fn() + } + const detector = new AgentDetector(stats as never) + + detector.onData('pty-1', oscTitle('Codex working'), 100) + detector.onData('pty-1', 'real output', 120) + detector.onData('pty-1', '\x1b]0;Codex done\x1b', 140) + detector.onData('pty-1', '\\', 141) + + expect(stats.onAgentStop).toHaveBeenCalledTimes(1) + expect(stats.onAgentStop).toHaveBeenCalledWith('pty-1', 120) + }) + + it('does not treat split ST-terminated string controls as meaningful output', () => { + const stats = { + onAgentStart: vi.fn(), + onAgentStop: vi.fn() + } + const detector = new AgentDetector(stats as never) + + detector.onData('pty-1', oscTitle('Codex working'), 100) + detector.onData('pty-1', 'real output', 120) + detector.onData('pty-1', '\x1b_Gi=31337,s=1,', 140) + detector.onData('pty-1', 'v=1,a=q,t=d,f=24;AAAA\x1b\\', 141) + detector.onData('pty-1', oscTitle('Codex done'), 160) + + expect(stats.onAgentStop).toHaveBeenCalledTimes(1) + expect(stats.onAgentStop).toHaveBeenCalledWith('pty-1', 120) + }) + + it('treats non-ASCII output in escaped chunks as meaningful', () => { + const stats = { + onAgentStart: vi.fn(), + onAgentStop: vi.fn() + } + const detector = new AgentDetector(stats as never) + + detector.onData('pty-1', oscTitle('Codex working'), 100) + detector.onData('pty-1', '\x1b[32m修正中 🌊\x1b[0m', 120) + detector.onData('pty-1', oscTitle('Codex done'), 140) + + expect(stats.onAgentStop).toHaveBeenCalledTimes(1) + expect(stats.onAgentStop).toHaveBeenCalledWith('pty-1', 120) + }) + + it('keeps capped split OSC title tails from becoming meaningful output', () => { + const stats = { + onAgentStart: vi.fn(), + onAgentStop: vi.fn() + } + const detector = new AgentDetector(stats as never) + + detector.onData('pty-1', oscTitle('Codex working'), 100) + detector.onData('pty-1', 'real output', 120) + detector.onData('pty-1', `\x1b]0;${'x'.repeat(5000)}`, 140) + detector.onData('pty-1', ' Codex done\x07', 141) + + expect(stats.onAgentStop).toHaveBeenCalledTimes(1) + expect(stats.onAgentStop).toHaveBeenCalledWith('pty-1', 120) + }) + + it('preserves a trailing escape after a completed OSC title for stats detection', () => { + const stats = { + onAgentStart: vi.fn(), + onAgentStop: vi.fn() + } + const detector = new AgentDetector(stats as never) + + detector.onData('pty-1', '\x1b]0;bash\x07\x1b', 100) + detector.onData('pty-1', ']0;Codex working\x07', 101) + + expect(stats.onAgentStart).toHaveBeenCalledTimes(1) + expect(stats.onAgentStart).toHaveBeenCalledWith('pty-1', 101) + expect(stats.onAgentStop).not.toHaveBeenCalled() + }) + it('stops an active session on PTY exit', () => { const stats = { onAgentStart: vi.fn(), diff --git a/src/main/stats/agent-detector.ts b/src/main/stats/agent-detector.ts index 371b79619..f514ccce2 100644 --- a/src/main/stats/agent-detector.ts +++ b/src/main/stats/agent-detector.ts @@ -1,5 +1,6 @@ import { extractLastOscTitle, detectAgentStatusFromTitle } from '../../shared/agent-detection' import type { AgentStatus } from '../../shared/agent-detection' +import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail' import type { StatsCollector } from './collector' type PtyAgentState = 'unknown' | 'agent' | 'stopped' @@ -20,6 +21,8 @@ type PtyRecord = { type MeaningfulContentDetector = (chunk: string) => boolean +const MEANINGFUL_CONTENT_SCAN_TAIL_LIMIT = 4096 + /** * Lightweight normalization to detect whether a PTY data chunk contains * meaningful (non-ANSI, non-OSC) output. Mirrors the regex passes in @@ -30,7 +33,13 @@ function hasMeaningfulContent(chunk: string): boolean { // chain just to prove they contain visible output. for (let index = 0; index < chunk.length; index++) { const code = chunk.charCodeAt(index) - if (code === 0x1b || code < 0x09 || (code > 0x0d && code < 0x20) || code > 0x7e) { + if ( + code === 0x1b || + code === 0x7f || + code < 0x09 || + (code > 0x0d && code < 0x20) || + (code >= 0x80 && code <= 0x9f) + ) { break } if (code > 0x20) { @@ -44,13 +53,19 @@ function hasMeaningfulContent(chunk: string): boolean { // eslint-disable-next-line no-control-regex .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC sequences // eslint-disable-next-line no-control-regex + .replace(/\x1b\][^\x07]*(?:\x1b)?$/g, '') // incomplete OSC tail + // eslint-disable-next-line no-control-regex + .replace(/\x1b[PX^_][\s\S]*?\x1b\\/g, '') // ST-terminated string controls + // eslint-disable-next-line no-control-regex + .replace(/\x1b[PX^_][\s\S]*(?:\x1b)?$/g, '') // incomplete string-control tail + // eslint-disable-next-line no-control-regex .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '') // CSI sequences // eslint-disable-next-line no-control-regex .replace(/\x1b[@-_]/g, '') // Fe sequences // eslint-disable-next-line no-control-regex .replace(/\u0008/g, '') // backspace // eslint-disable-next-line no-control-regex - .replace(/[^\x09\x0a\x20-\x7e]/g, '') // non-printable + .replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '') // non-printable .trim() return stripped.length > 0 } @@ -73,6 +88,8 @@ function hasMeaningfulContent(chunk: string): boolean { */ export class AgentDetector { private ptys = new Map() + private oscTitleScanTailByPtyId = new Map() + private meaningfulContentScanTailByPtyId = new Map() private stats: StatsCollector private meaningfulContentDetector: MeaningfulContentDetector @@ -104,8 +121,13 @@ export class AgentDetector { } let hasMeaningfulOutput: boolean | null = null + const previousMeaningfulTail = this.meaningfulContentScanTailByPtyId.get(ptyId) + const meaningfulData = previousMeaningfulTail ? `${previousMeaningfulTail}${rawData}` : rawData const getHasMeaningfulOutput = (): boolean => { - hasMeaningfulOutput ??= this.meaningfulContentDetector(rawData) + if (hasMeaningfulOutput === null) { + hasMeaningfulOutput = this.meaningfulContentDetector(meaningfulData) + this.updateMeaningfulContentScanTail(ptyId, meaningfulData) + } return hasMeaningfulOutput } @@ -113,7 +135,7 @@ export class AgentDetector { record.lastMeaningfulOutputAt = at } - const title = extractLastOscTitle(rawData) + const title = this.extractLastOscTitleForPty(ptyId, rawData) if (title === null) { return } @@ -173,5 +195,89 @@ export class AgentDetector { record.state = 'stopped' this.ptys.delete(ptyId) + this.oscTitleScanTailByPtyId.delete(ptyId) + this.meaningfulContentScanTailByPtyId.delete(ptyId) + } + + private extractLastOscTitleForPty(ptyId: string, rawData: string): string | null { + const previousTail = this.oscTitleScanTailByPtyId.get(ptyId) + if (!previousTail && !rawData.includes('\x1b')) { + return null + } + const input = `${previousTail ?? ''}${rawData}` + const scanTail = extractOscTitleScanTail(input) + if (scanTail.length > 0) { + this.oscTitleScanTailByPtyId.set(ptyId, scanTail) + } else { + this.oscTitleScanTailByPtyId.delete(ptyId) + } + return extractLastOscTitle(input) + } + + private updateMeaningfulContentScanTail(ptyId: string, rawData: string): void { + const tail = extractMeaningfulContentScanTail(rawData) + if (tail.length > 0) { + this.meaningfulContentScanTailByPtyId.set(ptyId, tail) + } else { + this.meaningfulContentScanTailByPtyId.delete(ptyId) + } } } + +function extractMeaningfulContentScanTail(value: string): string { + const escapeIndex = value.lastIndexOf('\x1b') + if (escapeIndex === -1) { + return '' + } + const parsed = parseMeaningfulControlSequence(value, escapeIndex) + return parsed === null ? trimMeaningfulContentScanTail(value.slice(escapeIndex)) : '' +} + +function parseMeaningfulControlSequence(value: string, escapeIndex: number): number | null { + const introducer = value[escapeIndex + 1] + if (!introducer) { + return null + } + if (introducer === '[') { + for (let index = escapeIndex + 2; index < value.length; index += 1) { + const code = value.charCodeAt(index) + if (code >= 0x40 && code <= 0x7e) { + return index + } + } + return null + } + if (introducer === ']') { + for (let index = escapeIndex + 2; index < value.length; index += 1) { + if (value[index] === '\u0007') { + return index + } + if (value[index] === '\u001b' && value[index + 1] === '\\') { + return index + 1 + } + } + return null + } + if (isStTerminatedStringControlIntroducer(introducer)) { + for (let index = escapeIndex + 2; index < value.length; index += 1) { + if (value[index] === '\u001b' && value[index + 1] === '\\') { + return index + 1 + } + } + return null + } + return escapeIndex + 1 +} + +function isStTerminatedStringControlIntroducer(introducer: string): boolean { + return introducer === 'P' || introducer === 'X' || introducer === '^' || introducer === '_' +} + +function trimMeaningfulContentScanTail(value: string): string { + if (value.length <= MEANINGFUL_CONTENT_SCAN_TAIL_LIMIT) { + return value + } + const introducer = value.slice(0, Math.min(2, value.length)) + const suffixBudget = Math.max(0, MEANINGFUL_CONTENT_SCAN_TAIL_LIMIT - introducer.length) + return `${introducer}${value.slice(-suffixBudget)}` +} diff --git a/src/shared/osc-title-scan-tail.ts b/src/shared/osc-title-scan-tail.ts new file mode 100644 index 000000000..e37bed400 --- /dev/null +++ b/src/shared/osc-title-scan-tail.ts @@ -0,0 +1,25 @@ +const OSC_TITLE_SCAN_TAIL_LIMIT = 4096 +const OSC_TITLE_PREFIX_LENGTH = 4 + +export function extractOscTitleScanTail(input: string): string { + const lastOsc = input.lastIndexOf('\x1b]') + if (lastOsc !== -1) { + const suffix = input.slice(lastOsc) + if (!suffix.includes('\x07') && !suffix.includes('\x1b\\')) { + return trimOscTitleScanTail(suffix) + } + return input.endsWith('\x1b') ? '\x1b' : '' + } + return input.endsWith('\x1b') ? '\x1b' : '' +} + +function trimOscTitleScanTail(value: string): string { + if (value.length <= OSC_TITLE_SCAN_TAIL_LIMIT) { + return value + } + // Preserve the OSC introducer while keeping the newest payload bytes, so + // bounded tails can still reconstruct a split title terminator. + const prefix = value.slice(0, Math.min(OSC_TITLE_PREFIX_LENGTH, value.length)) + const suffixBudget = Math.max(0, OSC_TITLE_SCAN_TAIL_LIMIT - prefix.length) + return `${prefix}${value.slice(-suffixBudget)}` +}