From c91ea599d9fd92ea6e4cf5bcbcc22086b2f50031 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 7 Jun 2026 05:27:58 -0700 Subject: [PATCH] Track split OSC metadata in headless terminals (#4783) --- src/main/daemon/headless-emulator.test.ts | 27 +++++++++++++++++++++++ src/main/daemon/headless-emulator.ts | 22 ++++++++++++++++-- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/main/daemon/headless-emulator.test.ts b/src/main/daemon/headless-emulator.test.ts index 3b3e42279..c6b09d5c8 100644 --- a/src/main/daemon/headless-emulator.test.ts +++ b/src/main/daemon/headless-emulator.test.ts @@ -109,6 +109,24 @@ describe('HeadlessEmulator', () => { expect(emulator.getSnapshot().cwd).toBe('/path/here') }) + + it('tracks OSC-7 CWD across split PTY chunks', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + + await emulator.write('\x1b]7;file:///split') + await emulator.write('/project\x07') + + expect(emulator.getSnapshot().cwd).toBe('/split/project') + }) + + it('tracks OSC-7 CWD when ESC and OSC marker arrive in separate chunks', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + + await emulator.write('\x1b') + await emulator.write(']7;file:///split-escape\x07') + + expect(emulator.getSnapshot().cwd).toBe('/split-escape') + }) }) describe('OSC title tracking', () => { @@ -128,6 +146,15 @@ describe('HeadlessEmulator', () => { expect(emulator.getSnapshot().lastTitle).toBe('Codex idle') }) + it('tracks OSC titles across split PTY chunks', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + + await emulator.write('\x1b]0;Codex work') + await emulator.write('ing\x07') + + expect(emulator.getSnapshot().lastTitle).toBe('Codex working') + }) + it('adopts title metadata seeded from an external serializer', () => { emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index 401a1a28e..54ade4c65 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -15,6 +15,7 @@ export type HeadlessSnapshotOptions = { } const DEFAULT_SCROLLBACK = 5000 +const OSC_SCAN_TAIL_LIMIT = 4096 // Why: PTY/SSH chunks can split a long combined DECSET before the final h/l. // Keep parser state far beyond normal mode lists while still bounding memory. const PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096 @@ -53,6 +54,7 @@ export class HeadlessEmulator { private serializer: SerializeAddon private cwd: string | null = null private lastTitle: string | null = null + private oscScanTail = '' private privateModeScanTail = '' private mouseTrackingMode: MouseTrackingMode = 'none' private sgrMouseMode = false @@ -88,8 +90,10 @@ export class HeadlessEmulator { return Promise.resolve() } - this.scanOsc7(data) - const lastTitle = extractLastOscTitle(data) + const oscInput = this.oscScanTail + data + this.oscScanTail = this.extractOscScanTail(oscInput) + this.scanOsc7(oscInput) + const lastTitle = extractLastOscTitle(oscInput) if (lastTitle !== null) { this.lastTitle = lastTitle } @@ -165,6 +169,20 @@ export class HeadlessEmulator { } } + private extractOscScanTail(input: string): string { + const lastOsc = input.lastIndexOf('\x1b]') + const lastEscape = input.endsWith('\x1b') ? input.length - 1 : -1 + const start = Math.max(lastOsc, lastEscape) + if (start === -1) { + return '' + } + const suffix = input.slice(start) + if (suffix.includes('\x07') || suffix.includes('\x1b\\')) { + return '' + } + return suffix.slice(-OSC_SCAN_TAIL_LIMIT) + } + private scanPrivateModes(data: string): void { const input = this.privateModeScanTail + data this.privateModeScanTail = this.extractPrivateModeScanTail(input)