From 848bb529fd57d8280577e5fe614fd6dc52fdad39 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 27 Sep 2026 00:52:17 -0700 Subject: [PATCH 1/4] fix(picker): detect Claude 2.1.283's indented picker rows and republish colour-only selection changes agent-code#1253, on a real Claude Code 2.1.283 recording (test/fixtures/slash-picker): - SlashPickerParser split the untrimmed row on 2+ spaces; 2.1.283 indents picker rows by two spaces, so parts[0] was empty and no row was ever detected. - HeadlessTerminal's text-only flush gate dropped the colour-only repaint of arrowing through the picker. An owner-supplied gateSignature (the selected row, only while a picker is visible) joins the gate; the common frame stays attribute-blind (agent-code#390). The test replays the recording through ClaudeCodeHeadless and asserts the published claude.slash-picker condition; the parser trim, the signature, the gate term and the evaluator's picker input are each mutation-pinned. Co-Authored-By: Claude Opus 5.5 --- src/ClaudeCodeHeadless.slashPicker.test.ts | 62 +++++++++++++++++++ src/ClaudeCodeHeadless.ts | 11 ++++ src/parsers/SlashPickerParser.ts | 8 ++- src/terminal/HeadlessTerminal.ts | 24 ++++++- test/fixtures/slash-picker/README.md | 19 ++++++ .../colour-only-selection-2.1.283.json | 51 +++++++++++++++ 6 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 src/ClaudeCodeHeadless.slashPicker.test.ts create mode 100644 test/fixtures/slash-picker/README.md create mode 100644 test/fixtures/slash-picker/colour-only-selection-2.1.283.json diff --git a/src/ClaudeCodeHeadless.slashPicker.test.ts b/src/ClaudeCodeHeadless.slashPicker.test.ts new file mode 100644 index 0000000..2c534f1 --- /dev/null +++ b/src/ClaudeCodeHeadless.slashPicker.test.ts @@ -0,0 +1,62 @@ +import { readFileSync } from 'node:fs' +import { EventEmitter } from 'events' +import type { IPty } from 'node-pty' +import { describe, expect, it, vi } from 'vitest' + +import { ClaudeCodeHeadless } from './ClaudeCodeHeadless.js' + +// agent-code#1253, on a REAL Claude Code 2.1.283 recording (test/fixtures/ +// slash-picker/README.md): arrowing through the slash picker can change only +// the highlight colour, with byte-identical text. HeadlessTerminal's flush gate +// compares text only, so no screen frame fired, the picker was never reparsed, +// and `claude.slash-picker` kept the old selected row until the text changed. +// +// Asserted through the published conditions snapshot, not the private picker +// state, because the producer was unpinned too: an empty picker in the +// evaluator input survived every suite. + +type Recording = { cols: number; rows: number; steps: Array<{ step: string; chunks: string[] }> } +const recording = JSON.parse(readFileSync( + new URL('../test/fixtures/slash-picker/colour-only-selection-2.1.283.json', import.meta.url), 'utf8', +)) as Recording + +function fakePty(cols: number, rows: number): IPty { + const disposable = { dispose: vi.fn() } + return { + pid: 1, process: 'claude', cols, rows, handleFlowControl: false, + write: vi.fn(), resize: vi.fn(), clear: vi.fn(), pause: vi.fn(), resume: vi.fn(), kill: vi.fn(), + onData: vi.fn(() => disposable), onExit: vi.fn(() => disposable), + } as unknown as IPty +} + +describe('slash picker on a real recording (#1253)', () => { + it('publishes every selection change, including colour-only ones', async () => { + const headless = new ClaudeCodeHeadless({ pty: fakePty(recording.cols, recording.rows), cwd: '/tmp', cols: recording.cols, rows: recording.rows }) + const terminal = (headless as unknown as { terminal: EventEmitter & { writeForTest(data: string): Promise } }).terminal + const published = (): { visible: boolean; selected: string | null } => { + const condition = (headless.getConditionSnapshot().conditions as Record } } | undefined>)['claude.slash-picker'] + if (!condition?.state) return { visible: false, selected: null } + return { visible: true, selected: condition.state.items.find(item => item.selected)?.id ?? null } + } + const play = async (step: string): Promise => { + for (const chunk of recording.steps.find(entry => entry.step === step)!.chunks) await terminal.writeForTest(chunk) + } + + await play('open-filtered') + await vi.waitFor(() => expect(published()).toEqual({ visible: true, selected: '/copy' }), { timeout: 2000 }) + + // The text of every picker row is identical to `open-filtered`: only the colour moved. + await play('arrow-down-1') + await vi.waitFor(() => expect(published()).toEqual({ visible: true, selected: '/color' }), { timeout: 2000 }) + + await play('arrow-down-2') + await vi.waitFor(() => expect(published()).toEqual({ visible: true, selected: '/config' }), { timeout: 2000 }) + + // Scrolls back to the exact text of `arrow-down-1`, colour on /color. + await play('arrow-up-1') + await vi.waitFor(() => expect(published()).toEqual({ visible: true, selected: '/color' }), { timeout: 2000 }) + + await play('escape') + await vi.waitFor(() => expect(published()).toEqual({ visible: false, selected: null }), { timeout: 2000 }) + }) +}) diff --git a/src/ClaudeCodeHeadless.ts b/src/ClaudeCodeHeadless.ts index be9072d..f9264dd 100644 --- a/src/ClaudeCodeHeadless.ts +++ b/src/ClaudeCodeHeadless.ts @@ -481,6 +481,17 @@ export class ClaudeCodeHeadless extends EventEmitter { // of silent shadowing that would undo the 60Hz→10Hz fix // (agent-code#390) the next time someone edits only one of them. snapshotIntervalMs: options.snapshotIntervalMs, + // agent-code#1253: arrowing through the slash picker can change only the + // highlight colour, with byte-identical text, and the text gate then + // dropped the frame, so the picker was never reparsed. While a picker is + // visible (the last parsed state), the selected row joins the gate. With + // no picker this is a constant '' and costs one property read: the + // common frame stays attribute-blind (agent-code#390). + gateSignature: () => { + if (!this.pickerState.visible) return '' + const selected = detectSlashPicker(this.terminal.getTerminal()).items.find(item => item.selected) + return `picker:${selected?.id ?? ''}` + }, }) // Proxy adapter is created lazily — only when the consumer opted diff --git a/src/parsers/SlashPickerParser.ts b/src/parsers/SlashPickerParser.ts index f7c7aa7..6540613 100644 --- a/src/parsers/SlashPickerParser.ts +++ b/src/parsers/SlashPickerParser.ts @@ -127,7 +127,13 @@ export function detectSlashPicker(term: TerminalInstance): SlashPickerState { // Split the row into (name, description). CC renders commands // with a run of 2+ spaces between the name column and the // description column. - const raw = line.translateToString(true).replace(/\s+$/, '') + // WHY trim BOTH ends (agent-code#1253): Claude Code 2.1.283 indents every + // picker row by two spaces (" /copy Copy Claude's…"). Splitting the + // untrimmed row on 2+ spaces made parts[0] the empty indent, the id check + // below rejected every row, and the picker was never detected at all on + // current Claude (recorded: test/fixtures/slash-picker). The first-cell + // scan above already skips leading spaces; the text split must too. + const raw = line.translateToString(true).trim() const parts = raw.split(/\s{2,}/) const id = (parts[0] ?? '').trim() const description = parts.slice(1).join(' ').trim() diff --git a/src/terminal/HeadlessTerminal.ts b/src/terminal/HeadlessTerminal.ts index b517b4f..1d2a76f 100644 --- a/src/terminal/HeadlessTerminal.ts +++ b/src/terminal/HeadlessTerminal.ts @@ -77,6 +77,17 @@ export type HeadlessTerminalOptions = { * and streaming-card extraction are all human-timescale, and * paste-confirmation waits have multi-second timeouts. */ snapshotIntervalMs?: number + /** + * An extra, owner-supplied term for the text-only change gate + * (agent-code#1253). Called on every flush that the text gate would + * otherwise drop; a different value from the last emitted frame's lets the + * frame through. The owner keeps it CHEAP and returns a constant ('') in the + * common case. ClaudeCodeHeadless returns the slash picker's selected row, + * and only while a picker is visible, because selection there is + * colour-only. See the gate's KNOWN TRADEOFF comment for why the gate itself + * stays attribute-blind. + */ + gateSignature?: () => string } export type ScreenSnapshot = { @@ -249,6 +260,8 @@ export class HeadlessTerminal extends EventEmitter { // negligible next to the churn it prevents. private lastEmittedPlain: string | null = null private lastEmittedRecent: string | null = null + private lastEmittedSignature = '' + private readonly gateSignature: (() => string) | undefined // Chrome-blind keys of the last emitted frame plus the markdown strings // emitted with it (agent-code#765). WHY the markdown is retained: a frame // whose normalized keys equal the last emit's differs only in spinner @@ -264,6 +277,7 @@ export class HeadlessTerminal extends EventEmitter { super() this.pty = options.pty this.snapshotIntervalMs = options.snapshotIntervalMs ?? 100 + this.gateSignature = options.gateSignature const cols = options.cols ?? 120 const rows = options.rows ?? 40 @@ -629,9 +643,17 @@ export class HeadlessTerminal extends EventEmitter { // stays the right default. const plain = this.snapshotPlain() const recent = this.snapshotRecent() - if (plain === this.lastEmittedPlain && recent === this.lastEmittedRecent) { + // WHY the signature is read only when the text is unchanged, and + // compared as a third term (agent-code#1253): a colour-only change + // (the slash picker's selected row) is exactly the attribute-only + // repaint the text gate drops. The owner's signature names the one + // attribute-driven fact it needs, cheaply, instead of making every + // frame attribute-aware (see KNOWN TRADEOFF above). + const signature = this.gateSignature?.() ?? '' + if (plain === this.lastEmittedPlain && recent === this.lastEmittedRecent && signature === this.lastEmittedSignature) { return } + this.lastEmittedSignature = signature // Second gate, one level softer (agent-code#765): the text DID change, // but did anything other than spinner chrome change? Normalize both // strings (glyph at line start → fixed token, timers → `Ns`, token diff --git a/test/fixtures/slash-picker/README.md b/test/fixtures/slash-picker/README.md new file mode 100644 index 0000000..d700f84 --- /dev/null +++ b/test/fixtures/slash-picker/README.md @@ -0,0 +1,19 @@ +# slash-picker + +`colour-only-selection-2.1.283.json`: real Claude Code 2.1.283 PTY output, 100x32, recorded on 2026-09-27 for agent-code#1253. The CLI was driven directly in a PTY with node-pty, not through Agent Code, in a fresh temp cwd with folder trust accepted. No prompt was ever submitted. + +| step | keys | what the screen shows after it | +|---|---|---| +| `open-filtered` | startup, then `/co` in ONE write | picker `/copy /color /config /compact`, with `/copy` selected | +| `arrow-down-1` | Down | **the same four rows, the same text**; only the highlight colour moves to `/color` | +| `arrow-down-2` | Down | the list scrolls (`/color /config /compact /context`), `/config` selected | +| `arrow-up-1` | Up | scrolls back to the first four rows, `/color` selected | +| `escape` | Esc | picker closed | + +`arrow-down-1` is the colour-only selection change #1253 is about. HeadlessTerminal's text-only flush gate emitted no frame for it. + +**How the steps were sliced:** by CONTENT, replaying chunk by chunk and reading the picker rows and the selected row's colour after each one, not by send time. Claude paints some keys late, so a key's output often landed after the next key was sent, and time labels were off by one step. + +**Why `/co` was sent in one write:** the unfiltered picker lists the user's personal skills. A same-length redaction of those rows did NOT survive replay: Claude repaints only the cells that change, so the redacted characters leaked into the next frame. Filtering first means that list is never painted. + +**Redactions** (same byte length, in rows Claude never repaints): the account plan in the banner (`Claude XXX`) and the temp cwd suffix. The replayed screens were diffed before and after redaction, and only those two texts differ. Everything else was read in full: Claude's built-in command list, the effort/permission status lines and a transcript-saving warning. diff --git a/test/fixtures/slash-picker/colour-only-selection-2.1.283.json b/test/fixtures/slash-picker/colour-only-selection-2.1.283.json new file mode 100644 index 0000000..6a91575 --- /dev/null +++ b/test/fixtures/slash-picker/colour-only-selection-2.1.283.json @@ -0,0 +1,51 @@ +{ + "recordedAt": "2026-09-27", + "claudeCodeVersion": "2.1.283", + "cols": 100, + "rows": 32, + "note": "Real Claude Code PTY output for agent-code#1253. See README.md for how it was recorded, sliced and redacted.", + "steps": [ + { + "step": "open-filtered", + "chunks": [ + "\u001b7\u001b[r\u001b8\u001b[?25h", + "\u001b[?25l", + "\u001b[?2004h\u001b[?2031h\u001b[?1004h", + "\u001b[>4m\u001b[0q\u001b[?u\u001b[c\u001b[?2004h\u001b[?2031h\u001b[?1004h", + "\u001b]0;\u2733 Claude Code\u0007", + "\u001b[?1049h\u001b[2J\u001b[H\u001b[?1000h\u001b[?1002h\u001b[?1003h\u001b[?1006h\u001b[?25l\u001b[H\r\u001b[1B\u001b[38;2;215;119;87m \u2590\u001b[48;2;0;0;0m\u259b\u2588\u2588\u2588\u259b\u2588\u001b[12G\u001b[39m\u001b[49m\u001b[1mClaude Code\u001b[24G\u001b[22m\u001b[38;2;153;153;153mv2.1.283\r\u001b[1B\u001b[38;2;215;119;87m\u259d\u259c\u001b[48;2;0;0;0m\u2588\u2588\u2588\u2588\u2588\u001b[49m\u2588\u2580\u001b[12G\u001b[38;2;153;153;153mOpus 5.5 \u00b7 Claude XXX\r\u001b[1B\u001b[38;2;215;119;87m \u259d\u259d \u259d\u259d \u001b[12G\u001b[38;2;153;153;153m/private/tmp/picker-xxxxxx\r\u001b[80C\u001b[23B\u25d0 medium \u00b7 /effort\r\u001b[1B\u001b[38;2;136;136;136m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\u001b[1B\u001b[39m\u276f\u00a0\u001b[7m \r\u001b[1B\u001b[27m\u001b[38;2;136;136;136m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500", + "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\u001b[2C\u001b[1B\u001b[38;2;255;193;7m\u26a0 Transcript saving is off \u2014 inherited CLAUDE_CODE_CHILD_SESSION marker\u001b[38;2;153;153;153m \u00b7 restart with CLAUDE_C\u2026\r\u001b[2C\u001b[1B\u001b[38;2;255;107;128m\u23f5\u23f5 bypass permissions on\u001b[38;2;153;153;153m (shift+tab to cycle)\u001b[39m\u001b[32;1H\u001b[29;3H", + "\u001b[>0q\u001b[?u\u001b[c", + "\u001b(B\u000f\u001b[?2004h\u001b[?1000h\u001b[?1002h\u001b[?1003h\u001b[?1006h", + "\u001b[H\r\u001b[2C\u001b[22B\u001b[38;2;177;185;249m/\u001b[1mco\u001b[22mpy \u001b[1mCo\u001b[22mpy Claude's last response to clipboard (or /copy N for\r\u001b[39C\u001b[1Bthe Nth-latest)\r\u001b[2C\u001b[1B\u001b[38;2;153;153;153m/\u001b[39m\u001b[1mco\u001b[22m\u001b[38;2;153;153;153mlor Set the prompt bar \u001b[39m\u001b[1mco\u001b[22m\u001b[38;2;153;153;153mlor for this session\r\u001b[2C\u001b[1B/\u001b[39m\u001b[1mco\u001b[22m\u001b[38;2;153;153;153mnfig Open settings\r\u001b[2C\u001b[1B/\u001b[39m\u001b[1mco\u001b[22m\u001b[38;2;153;153;153mmpact Free up \u001b[39m\u001b[1mco\u001b[22m\u001b[38;2;153;153;153mntext by summarizing the conversation so far\u001b[39m\u001b[K\r\u001b[2C\u001b[2B/co\u001b[7m \u001b[27m\u001b[32;1H\u001b[29;6H" + ] + }, + { + "step": "arrow-down-1", + "chunks": [ + "\u001b[H\r\u001b[2C\u001b[22B\u001b[38;2;153;153;153m/\u001b[39m\u001b[1mco\u001b[22m\u001b[38;2;153;153;153mpy \u001b[39m\u001b[1mCo\u001b[22m\u001b[38;2;153;153;153mpy Claude's last response to clipboard (or /copy N for\r\u001b[39C\u001b[1Bthe Nth-latest)\r\u001b[2C\u001b[1B\u001b[38;2;177;185;249m/\u001b[1mco\u001b[22mlor Set the prompt bar \u001b[1mco\u001b[22mlor for this session\u001b[39m\u001b[32;1H\u001b[29;6H" + ] + }, + { + "step": "arrow-down-2", + "chunks": [ + "\u001b[H\r\u001b[2C\u001b[22B\u001b[K\r\u001b[2C\u001b[1B\u001b[38;2;153;153;153m/\u001b[39m\u001b[1mco\u001b[22m\u001b[38;2;153;153;153mlor Set\u001b[44Gthe promp\u001b[54G bar \u001b[39m\u001b[1mco\u001b[22m\u001b[38;2;153;153;153mlor for this session\r\u001b[5C\u001b[1B\u001b[38;2;177;185;249mnfig\u001b[40GOpen s\u001b[47Gttings\u001b[39m\u001b[K\r\u001b[5C\u001b[1B\u001b[38;2;153;153;153mmpact\u001b[40GFr\u001b[43Ge\u001b[45Gup \u001b[39m\u001b[1mco\u001b[51G\u001b[22m\u001b[38;2;153;153;153mtext by summarizing the conversation so far\r\u001b[5C\u001b[1Bntex\u001b[40GVisualize current \u001b[39m\u001b[1mco\u001b[22m\u001b[38;2;153;153;153mntext usa\u001b[70Ge as\u001b[75Ga colored grid\u001b[39m\u001b[K\u001b[32;1H\u001b[29;6H" + ] + }, + { + "step": "arrow-up-1", + "chunks": [ + "\u001b[H\r\u001b[2C\u001b[22B\u001b[38;2;153;153;153m/\u001b[39m\u001b[1mco\u001b[22m\u001b[38;2;153;153;153mpy \u001b[39m\u001b[1mCo\u001b[22m\u001b[38;2;153;153;153mpy Claude's last response to clipboard (or /copy N for\r\u001b[2C\u001b[1B\u001b[39m \u001b[38;2;153;153;153mthe\u001b[44GNth-lates\u001b[54G)\u001b[39m\u001b[K\r\u001b[5C\u001b[1B\u001b[38;2;177;185;249mlor \u001b[40GSet th\u001b[47G prompt bar \u001b[1mco\u001b[22mlor for this session\r\u001b[5C\u001b[1B\u001b[38;2;153;153;153mnfig \u001b[40GOp\u001b[43Gn\u001b[45Gsetti\u001b[51Ggs\u001b[39m\u001b[K\r\u001b[5C\u001b[1B\u001b[38;2;153;153;153mmpac\u001b[40GFree up \u001b[39m\u001b[1mco\u001b[22m\u001b[38;2;153;153;153mntext by summarizin\u001b[70G the\u001b[75Gconversation so far\u001b[39m\u001b[32;1H\u001b[29;6H" + ] + }, + { + "step": "escape", + "chunks": [ + "\u001b[H\r\u001b[39C\u001b[22B\u001b[38;2;153;153;153m \u001b[39m\u001b[1mCo\u001b[22m\u001b[38;2;153;153;153mpy Claude's last response to clipboard (or /copy N for\r\u001b[39C\u001b[1B\u001b[39m \u001b[38;2;153;153;153mthe Nth-latest)\r\u001b[39C\u001b[1B\u001b[38;2;177;185;249m Set the prompt bar \u001b[1mco\u001b[22mlor for this se\u001b[78Gsion\r\u001b[39C\u001b[1B\u001b[38;2;153;153;153m Open se\u001b[49Gtings\r\u001b[39C\u001b[1B Fr\u001b[44Ge up \u001b[39m\u001b[1mco\u001b[22m\u001b[38;2;153;153;153mntext by su\u001b[63Gmarizing the conversation so far\u001b[39m\u001b[32;1H\u001b[29;6H", + "\u001b[H\r\u001b[2C\u001b[22B\u001b[K\r\u001b[40C\u001b[1B\u001b[K\r\u001b[2C\u001b[1B\u001b[K\r\u001b[2C\u001b[1B\u001b[K\r\u001b[2C\u001b[1B \u001b[38;2;153;153;153m\u25d0 medium \u00b7\u001b[92G/effort\u001b[39m\u001b[32;1H\u001b[29;6H", + "\u001b[H\r\u001b[80C\u001b[26B\u001b[K\u001b[32;1H\u001b[29;6H" + ] + } + ] +} \ No newline at end of file From 128b8ea855b59db8c99dbfaf746043f63ad68459 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 27 Sep 2026 00:59:07 -0700 Subject: [PATCH 2/4] test(picker): the gate signature costs nothing while no picker is visible agent-code#1253 review b (surviving mutant): computing the signature on every flush passed every test. Identical no-picker frames now must parse the picker only once per EMITTED frame (21 calls vs 1 frame without the visibility guard). Co-Authored-By: Claude Opus 5.5 --- ...ClaudeCodeHeadless.slashPickerCost.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 src/ClaudeCodeHeadless.slashPickerCost.test.ts diff --git a/src/ClaudeCodeHeadless.slashPickerCost.test.ts b/src/ClaudeCodeHeadless.slashPickerCost.test.ts new file mode 100644 index 0000000..8e812f5 --- /dev/null +++ b/src/ClaudeCodeHeadless.slashPickerCost.test.ts @@ -0,0 +1,43 @@ +import { EventEmitter } from 'events' +import type { IPty } from 'node-pty' +import { expect, it, vi } from 'vitest' + +// #1253 review b (surviving mutant): the picker-selection gate signature must +// cost nothing while no picker is visible. The flush gate is text-only because +// per-frame attribute work pinned main at ~80% CPU (agent-code#390), and +// Claude's TUI repaints identical chrome many times a second while working. So +// on identical no-picker frames the picker parser must run only for frames that +// are actually emitted, never once per dropped flush. +const calls = vi.hoisted(() => ({ detect: 0 })) +vi.mock('./parsers/SlashPickerParser.js', async importOriginal => { + const original = await importOriginal() + return { ...original, detectSlashPicker: (...args: Parameters) => { calls.detect += 1; return original.detectSlashPicker(...args) } } +}) +const { ClaudeCodeHeadless } = await import('./ClaudeCodeHeadless.js') + +function fakePty(): IPty { + const disposable = { dispose: vi.fn() } + return { + pid: 1, process: 'claude', cols: 80, rows: 24, handleFlowControl: false, + write: vi.fn(), resize: vi.fn(), clear: vi.fn(), pause: vi.fn(), resume: vi.fn(), kill: vi.fn(), + onData: vi.fn(() => disposable), onExit: vi.fn(() => disposable), + } as unknown as IPty +} + +it('does not parse the picker on dropped flushes while no picker is visible', async () => { + const headless = new ClaudeCodeHeadless({ pty: fakePty(), cwd: '/tmp', cols: 80, rows: 24, snapshotIntervalMs: 1 }) + const terminal = (headless as unknown as { terminal: EventEmitter & { writeForTest(data: string): Promise } }).terminal + let screens = 0 + terminal.on('screen', () => { screens += 1 }) + const RULE = '─'.repeat(60) + // Home the cursor and repaint the same composer: every flush after the first + // sees byte-identical text, the shape of Claude's idle chrome redraws. + const frame = `\x1b[H${RULE}\r\n❯ \r\n${RULE}` + for (let i = 0; i < 20; i++) { + await terminal.writeForTest(frame) + await new Promise(resolve => setTimeout(resolve, 5)) + } + expect(screens).toBeGreaterThan(0) + // The screen handler parses once per emitted frame; nothing else may. + expect(calls.detect).toBe(screens) +}) From 2d9a0db973569dec6b265a49b245b9b0bbd795f0 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 27 Sep 2026 01:03:48 -0700 Subject: [PATCH 3/4] fix(picker): read the live signature only for unchanged text, record the emitted one after parsing agent-code#1253 review a: the signature was read on changed-text frames too (a wasted grid parse per picker frame), and the value recorded for an emitted frame was the PRE-parse one, so a picker that just opened emitted a duplicate frame on the next no-op write. gateSignature(source): 'live' only when the text is unchanged; 'emitted' right after the screen handler parsed. Both pinned on the real recording (duplicate frame: 5 vs 4; parses on a scroll: 2 vs 1 per frame). Co-Authored-By: Claude Opus 5.5 --- ...ClaudeCodeHeadless.slashPickerCost.test.ts | 33 ++++++++++++++++ src/ClaudeCodeHeadless.ts | 10 +++-- src/terminal/HeadlessTerminal.ts | 38 +++++++++++++------ 3 files changed, 66 insertions(+), 15 deletions(-) diff --git a/src/ClaudeCodeHeadless.slashPickerCost.test.ts b/src/ClaudeCodeHeadless.slashPickerCost.test.ts index 8e812f5..67fde27 100644 --- a/src/ClaudeCodeHeadless.slashPickerCost.test.ts +++ b/src/ClaudeCodeHeadless.slashPickerCost.test.ts @@ -41,3 +41,36 @@ it('does not parse the picker on dropped flushes while no picker is visible', as // The screen handler parses once per emitted frame; nothing else may. expect(calls.detect).toBe(screens) }) + +// #1253 review a, on the real 2.1.283 recording: (1) the signature recorded +// for an emitted frame must be the POST-parse one, or a picker that just +// opened emits a duplicate frame on the next no-op write; (2) a frame whose +// text changed is emitted anyway, so the live signature must not re-parse the +// grid for it (the screen handler's parse is the only one). +it('neither duplicates a frame after the picker opens nor parses a changed frame twice', async () => { + const { readFileSync } = await import('node:fs') + const recording = JSON.parse(readFileSync(new URL('../test/fixtures/slash-picker/colour-only-selection-2.1.283.json', import.meta.url), 'utf8')) as { cols: number; rows: number; steps: Array<{ step: string; chunks: string[] }> } + const headless = new ClaudeCodeHeadless({ pty: fakePty(), cwd: '/tmp', cols: recording.cols, rows: recording.rows, snapshotIntervalMs: 1 }) + const terminal = (headless as unknown as { terminal: EventEmitter & { writeForTest(data: string): Promise } }).terminal + let screens = 0 + terminal.on('screen', () => { screens += 1 }) + const settle = () => new Promise(resolve => setTimeout(resolve, 20)) + const play = async (step: string) => { + for (const chunk of recording.steps.find(entry => entry.step === step)!.chunks) await terminal.writeForTest(chunk) + await settle() + } + await play('open-filtered') + expect(headless.getSlashPickerState().visible).toBe(true) + + // (1) No-op writes (cursor home: no text, no colour change). + const afterOpen = screens + await terminal.writeForTest('\x1b[H'); await settle() + await terminal.writeForTest('\x1b[H'); await settle() + expect(screens).toBe(afterOpen) + + // (2) A scroll changes the text: one parse per emitted frame, no more. + const [screensBefore, detectBefore] = [screens, calls.detect] + await play('arrow-down-2') + expect(screens).toBeGreaterThan(screensBefore) + expect(calls.detect - detectBefore).toBe(screens - screensBefore) +}) diff --git a/src/ClaudeCodeHeadless.ts b/src/ClaudeCodeHeadless.ts index f9264dd..6754f4c 100644 --- a/src/ClaudeCodeHeadless.ts +++ b/src/ClaudeCodeHeadless.ts @@ -487,10 +487,14 @@ export class ClaudeCodeHeadless extends EventEmitter { // visible (the last parsed state), the selected row joins the gate. With // no picker this is a constant '' and costs one property read: the // common frame stays attribute-blind (agent-code#390). - gateSignature: () => { + gateSignature: source => { if (!this.pickerState.visible) return '' - const selected = detectSlashPicker(this.terminal.getTerminal()).items.find(item => item.selected) - return `picker:${selected?.id ?? ''}` + // 'emitted' runs right after the screen handler stored this frame's + // parse, so it costs nothing; only 'live' (unchanged text, picker + // open) reads the grid. + const picker = source === 'emitted' ? this.pickerState : detectSlashPicker(this.terminal.getTerminal()) + if (!picker.visible) return '' + return `picker:${picker.items.find(item => item.selected)?.id ?? ''}` }, }) diff --git a/src/terminal/HeadlessTerminal.ts b/src/terminal/HeadlessTerminal.ts index 1d2a76f..0f8c5c5 100644 --- a/src/terminal/HeadlessTerminal.ts +++ b/src/terminal/HeadlessTerminal.ts @@ -79,15 +79,24 @@ export type HeadlessTerminalOptions = { snapshotIntervalMs?: number /** * An extra, owner-supplied term for the text-only change gate - * (agent-code#1253). Called on every flush that the text gate would - * otherwise drop; a different value from the last emitted frame's lets the - * frame through. The owner keeps it CHEAP and returns a constant ('') in the - * common case. ClaudeCodeHeadless returns the slash picker's selected row, - * and only while a picker is visible, because selection there is - * colour-only. See the gate's KNOWN TRADEOFF comment for why the gate itself - * stays attribute-blind. + * (agent-code#1253). ClaudeCodeHeadless returns the slash picker's selected + * row while a picker is visible, because selection there is colour-only. + * See the gate's KNOWN TRADEOFF comment for why the gate itself stays + * attribute-blind. + * + * Two readings, so that the grid is parsed only when it has to be: + * - 'live': read from the current grid, and ONLY on a flush whose text is + * unchanged (the one case the text gate would drop). A changed-text + * frame is emitted anyway, so reading it there was a wasted parse + * (#1253 review a). + * - 'emitted': recorded right AFTER a frame is emitted, from the state the + * owner's 'screen' handler has just parsed. Recording it before + * emission stored the pre-parse value ('' for a picker that was just + * opening), so the next no-op write emitted a duplicate frame (#1253 + * review a). + * The owner keeps both cheap and returns '' in the common (no-picker) case. */ - gateSignature?: () => string + gateSignature?: (source: 'live' | 'emitted') => string } export type ScreenSnapshot = { @@ -261,7 +270,7 @@ export class HeadlessTerminal extends EventEmitter { private lastEmittedPlain: string | null = null private lastEmittedRecent: string | null = null private lastEmittedSignature = '' - private readonly gateSignature: (() => string) | undefined + private readonly gateSignature: ((source: 'live' | 'emitted') => string) | undefined // Chrome-blind keys of the last emitted frame plus the markdown strings // emitted with it (agent-code#765). WHY the markdown is retained: a frame // whose normalized keys equal the last emit's differs only in spinner @@ -649,11 +658,13 @@ export class HeadlessTerminal extends EventEmitter { // repaint the text gate drops. The owner's signature names the one // attribute-driven fact it needs, cheaply, instead of making every // frame attribute-aware (see KNOWN TRADEOFF above). - const signature = this.gateSignature?.() ?? '' - if (plain === this.lastEmittedPlain && recent === this.lastEmittedRecent && signature === this.lastEmittedSignature) { + if ( + plain === this.lastEmittedPlain && + recent === this.lastEmittedRecent && + (this.gateSignature?.('live') ?? '') === this.lastEmittedSignature + ) { return } - this.lastEmittedSignature = signature // Second gate, one level softer (agent-code#765): the text DID change, // but did anything other than spinner chrome change? Normalize both // strings (glyph at line start → fixed token, timers → `Ns`, token @@ -705,6 +716,9 @@ export class HeadlessTerminal extends EventEmitter { recentMarkdown: derived.recentMarkdown, spinnerOnly: reusable !== null, }) + // After the handlers ran (emit is synchronous): the signature of what + // was actually emitted and parsed. See gateSignature. + this.lastEmittedSignature = this.gateSignature?.('emitted') ?? '' }, this.snapshotIntervalMs) } From 07eecece87793f5764d7d25fc79cc3930a923fa8 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 27 Sep 2026 01:12:00 -0700 Subject: [PATCH 4/4] docs(api): document HeadlessTerminal's gateSignature option; correct the snapshotIntervalMs default agent-code#1253 review c: the new option was missing from the public options table; the table also still said 16 ms although the default has been 100 ms since 673e786. Co-Authored-By: Claude Opus 5.5 --- API.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/API.md b/API.md index 497d9c5..528e75e 100644 --- a/API.md +++ b/API.md @@ -427,7 +427,8 @@ Consequences for consumers: | `pty` | `IPty` | — (required) | PTY to mirror. Consumer owns its lifecycle. | | `cols` | `number` | `120` | Terminal columns. | | `rows` | `number` | `40` | Terminal rows. | -| `snapshotIntervalMs` | `number` | `16` | Throttle interval (ms) for `screen` events. | +| `snapshotIntervalMs` | `number` | `100` | Throttle interval (ms) for `screen` events. | +| `gateSignature` | `(source: 'live' \| 'emitted') => string` | — | Optional extra term for the `screen` change gate, which otherwise compares text only. `'live'` is read from the grid ONLY on a flush whose text is unchanged, and a value that differs from the last emitted frame's lets the frame through. `'emitted'` is recorded right after a frame is emitted, once your `screen` handlers have run. Keep both cheap and return a constant (`''`) in the common case: the gate is text-only because per-frame attribute work is expensive. `ClaudeCodeHeadless` uses it for the slash picker's colour-only selection, and only while a picker is visible. | ### 4.2 Lifecycle