Fix split ANSI leakage in terminal preview (#5478)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-06-16 12:20:37 -07:00 committed by GitHub
parent b516108bde
commit 37c7e4bbc5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 483 additions and 32 deletions

View File

@ -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<string, { lastOscTitle: string | null }>
}
).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<string, { lastOscTitle: string | null; lastAgentStatus: string | null }>
}
).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<string, { lastOscTitle: string | null; lastAgentStatus: string | null }>
}
).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<string, { lastOscTitle: string | null; lastAgentStatus: string | null }>
}
).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)

View File

@ -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<typeof createAgentStatusOscProcessor>
>()
// 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<string, string>()
// 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

View File

@ -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(),

View File

@ -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<string, PtyRecord>()
private oscTitleScanTailByPtyId = new Map<string, string>()
private meaningfulContentScanTailByPtyId = new Map<string, string>()
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)}`
}

View File

@ -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)}`
}