Skip to content
Merged
3 changes: 2 additions & 1 deletion API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
62 changes: 62 additions & 0 deletions src/ClaudeCodeHeadless.slashPicker.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> } }).terminal
const published = (): { visible: boolean; selected: string | null } => {
const condition = (headless.getConditionSnapshot().conditions as Record<string, { state?: { items: Array<{ id: string; selected: boolean }> } } | 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<void> => {
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 })
})
})
76 changes: 76 additions & 0 deletions src/ClaudeCodeHeadless.slashPickerCost.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
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<typeof import('./parsers/SlashPickerParser.js')>()
return { ...original, detectSlashPicker: (...args: Parameters<typeof original.detectSlashPicker>) => { 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<void> } }).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)
})

// #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<void> } }).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)
})
15 changes: 15 additions & 0 deletions src/ClaudeCodeHeadless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,21 @@ 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: source => {
if (!this.pickerState.visible) return ''
// '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 ?? ''}`
},
})

// Proxy adapter is created lazily — only when the consumer opted
Expand Down
8 changes: 7 additions & 1 deletion src/parsers/SlashPickerParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
38 changes: 37 additions & 1 deletion src/terminal/HeadlessTerminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,26 @@ 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). 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?: (source: 'live' | 'emitted') => string
}

export type ScreenSnapshot = {
Expand Down Expand Up @@ -249,6 +269,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: ((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
Expand All @@ -264,6 +286,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
Expand Down Expand Up @@ -629,7 +652,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).
if (
plain === this.lastEmittedPlain &&
recent === this.lastEmittedRecent &&
(this.gateSignature?.('live') ?? '') === this.lastEmittedSignature
) {
return
}
// Second gate, one level softer (agent-code#765): the text DID change,
Expand Down Expand Up @@ -683,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)
}

Expand Down
19 changes: 19 additions & 0 deletions test/fixtures/slash-picker/README.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading