Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -443,11 +443,24 @@ function parseCodexAsyncQuestion(screen: string): ParsedPrompt | null {
});
}

/**
* The current question frame's own options: the run of numbered rows that ends
* at the last row above the hint and counts back down to `1.`. A numbered line
* above that `1.` (a list in the assistant's reply, for example) belongs to
* something else and never counts as an option.
*/
function currentClaudeRun(rows: NumberedRow[]): NumberedRow[] {
let start = rows.length - 1;
if (start < 0) return [];
while (start > 0 && rows[start].number > 1 && rows[start - 1].number === rows[start].number - 1) start -= 1;
return rows[start].number === 1 ? rows.slice(start) : [];
}

function parseClaudeQuestion(screen: string): ParsedPrompt | null {
const lines = screen.replace(ANSI_RE, '').split(/\r?\n/);
const hintIndex = findLastIndex(lines, (line) => CLAUDE_ASK_HINT_RE.test(line));
if (hintIndex < 0) return null;
const rows = parseNumberedRows(lines, Math.max(0, hintIndex - 64), hintIndex);
const rows = currentClaudeRun(parseNumberedRows(lines, Math.max(0, hintIndex - 64), hintIndex));
if (!sequentialRows(rows) || rows.filter((row) => row.selected).length !== 1) return null;
const chatIndex = rows.findIndex((row) => row.label === 'Chat about this');
const customIndex = rows.findIndex((row) => /^Type something\.?$/i.test(row.label));
Expand Down
148 changes: 148 additions & 0 deletions server/modules/providers/tests/claude-question-numbered-list.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';

import type { TmuxRunner } from '@/modules/providers/services/builtin-relay.service.js';
import { parseClaudeAskSelectionScreen } from '@/modules/providers/services/tmux-ask-selection.service.js';
import { createVerifiedTmuxActionTarget } from '@/modules/providers/services/tmux-fresh-verifier.service.js';
import {
getTmuxInteractivePrompt,
parseTmuxInteractivePrompt,
} from '@/modules/providers/services/tmux-interactive-prompt.service.js';

// A numbered list in the assistant's reply, above a short Claude question, must
// not hide the question. Fixtures are real Claude Code TUI captures in a 78x73
// tmux pane (`capture-pane -p -e -N -S -80`, and the visible screen alone):
// - `s1`: fullscreen renderer, a 40-item list right above the question frame;
// - `s2`: default renderer, the same shape;
// - `list40`: default renderer, re-captured with colour;
// - `list3`: default renderer, an earlier 40-item list in scrollback and a
// 3-item list numbered 1.-3. directly above the frame.
const FIXTURE_DIR = new URL('./fixtures/claude-question-numbered-list/', import.meta.url);
const read = (name: string) => readFileSync(new URL(name, FIXTURE_DIR), 'utf8');
type Shot = { joined: string; screen: string };
const shot = (name: string, ext: 'txt' | 'ansi'): Shot => ({
joined: read(`${name}.joined.${ext}`),
screen: read(`${name}.screen.${ext}`),
});
const ANSI_RE = /\u001b\[[0-?]*[ -/]*[@-~]/g;

let generation = 0;
const targetFor = () => createVerifiedTmuxActionTarget(
{ socketPath: '/tmp/chatmux-numbered-list-test.sock', sessionId: '$7', windowId: '@8', paneId: '%9' },
{ pid: 42, startedAtMs: (generation += 1) },
'claude', 'test', 'claude-session',
);

/** A pane whose `capture-pane` returns the given text, trimmed as runTmux trims. */
function paneOf(text: string): TmuxRunner {
return async (args) => {
if (args.includes('capture-pane')) return { code: 0, output: text.trim() };
if (args.includes('display-message')) return { code: 0, output: '$7\t@8\t%9\n' };
return { code: 0, output: '' };
};
}

/** Numbered lines above the frame's header chip: the list that used to hide the card. */
function numberedAboveFrame(text: string): number {
const lines = text.replace(ANSI_RE, '').split('\n');
const top = lines.findIndex((line) => /^\s*☐\s+\S/.test(line));
assert.ok(top > 0, 'fixture assumption broken: no header chip');
return lines.slice(0, top).filter((line) => /^\s*(?:●\s*)?\d+\.\s/.test(line)).length;
}

const PACK = 'Pack the green fig and golden-yellow peaches together?';
const SHORT_ASKS = [
['s1-78x73', 'txt', PACK, ['Ripe avocados', 'Big red plum']],
['s2-78x73', 'txt', PACK, ['Ripe avocados', 'Big red plum']],
['list40-78x73', 'ansi', PACK, ['Ripe avocados', 'Big red plum']],
['list3-78x73', 'ansi', 'Which step goes first?', ['Apple', 'Banana', 'Cherry']],
] as const;

for (const [name, ext, question, labels] of SHORT_ASKS) {
test(`${name}: a short Claude question under a numbered list still gets its card`, async () => {
const { joined, screen } = shot(name, ext);
assert.ok(numberedAboveFrame(joined) >= 2, 'fixture assumption broken: no list above the frame');
for (const [capture, prompt] of [
['joined', await getTmuxInteractivePrompt(targetFor(), paneOf(joined))],
['screen', parseTmuxInteractivePrompt('claude', screen)],
] as const) {
assert.ok(prompt, `${capture}: the card reads the question`);
assert.equal(prompt.kind, 'question');
assert.equal(prompt.question, question);
assert.deepEqual(prompt.options.map((option) => option.label), [...labels]);
assert.equal(prompt.multiSelect, false);
assert.equal(prompt.customOptionNumber, labels.length + 1);
}
});
}

/** s2's question frame, under a list numbered 1.-3. whose items repeat the frame's own rows. */
function listDirectlyAbove(keepDivider: boolean): string {
const lines = read('s2-78x73.joined.txt').split('\n');
const chip = lines.findIndex((line) => line.includes('☐ Pick one'));
assert.ok(chip > 0, 'fixture assumption broken: no header chip');
const frameLines = lines.slice(keepDivider ? chip - 1 : chip);
return [
'● Here is the plan:',
'',
' 1. Ripe avocados',
' 2. Big red plum',
' 3. Type something.',
...frameLines,
].join('\n');
}

test('a list numbered 1.-3. directly above the frame yields the real options only', () => {
for (const keepDivider of [true, false]) {
const text = listDirectlyAbove(keepDivider);
assert.equal((text.match(/^[ ❯] 1\. /gm) ?? []).length, 2, 'fixture assumption broken: two runs start at 1.');
const prompt = parseTmuxInteractivePrompt('claude', text);
assert.ok(prompt, `divider ${keepDivider}: the card reads the question`);
assert.equal(prompt.question, PACK);
assert.deepEqual(prompt.options.map((option) => option.label), ['Ripe avocados', 'Big red plum']);
assert.equal(prompt.customOptionNumber, 3);
}
});

test('control: a short Claude question with no list above parses as before', () => {
const text = listDirectlyAbove(true).split('\n').slice(5).join('\n');
assert.equal(numberedAboveFrame(text), 0, 'fixture assumption broken: a list is still above the frame');
const prompt = parseTmuxInteractivePrompt('claude', text);
assert.ok(prompt);
assert.equal(prompt.question, PACK);
assert.deepEqual(prompt.options.map((option) => option.label), ['Ripe avocados', 'Big red plum']);
assert.deepEqual(prompt.options.map((option) => option.description), ['Soft green, very ripe', 'Deep red skin']);
assert.equal(prompt.customOptionNumber, 3);
});

// The transcript-ask path matches screen rows against the transcript's own
// labels, in a window that must number 1..N and end in "Type something." and
// "Chat about this". A list above the frame cannot complete such a window, so
// it already finds the real rows; these cases pin that.
test('transcript asks: a numbered list above the frame never hides or replaces the real rows', () => {
const question = { question: PACK, options: [{ label: 'Ripe avocados' }, { label: 'Big red plum' }] };
const fruit = { question: 'Which step goes first?', options: [{ label: 'Apple' }, { label: 'Banana' }, { label: 'Cherry' }] };
const screens = [
[shot('s1-78x73', 'txt').joined, question],
[shot('s2-78x73', 'txt').joined, question],
[shot('list40-78x73', 'ansi').joined, question],
[shot('list3-78x73', 'ansi').joined, fruit],
[listDirectlyAbove(true), question],
[listDirectlyAbove(false), question],
] as const;
for (const [screen, ask] of screens) {
const last = ask.options.length - 1;
// Every capture has row 1 focused, so moving to the last option is `last` steps down.
assert.deepEqual(parseClaudeAskSelectionScreen(screen, ask, last), {
action: 'option',
delta: last,
label: ask.options[last].label,
});
assert.deepEqual(parseClaudeAskSelectionScreen(screen, ask, ask.options.length), {
action: 'other',
delta: ask.options.length,
label: 'Direct input',
});
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
user@devbox:/tmp/demo-list-proj$ /tmp/demo-list-work/launch.sh
 ▐▛███▛█ Claude Code v2.1.291
 ▐▛███▛█ Claude Code v2.1.291
▝▜██████▀ Haiku 4.5 · Claude API
 ▝▝ ▝▝ /tmp/demo-list-proj


❯ /tui default 
  ⎿ Already using the default renderer.

❯ First write a numbered list of 40 short tmux tips, one line each, numbered 
1. to 40. Then, without any other text, use the AskUserQuestion tool 
exactly once: header "Pick one", question "Pack the green fig and 
golden-yellow peaches together?", options "Ripe avocados" (description 
"Soft green, very ripe") and "Big red plum" (description "Deep red skin"). 
Do nothing else.

● 1. Start a new session: tmux new-session -s name
2. List all sessions: tmux list-sessions
3. Attach to a session: tmux attach -t name
4. Detach from current session: Ctrl+B then D
5. Kill a session: tmux kill-session -t name
6. Rename a session: Ctrl+B then $
7. Create a new window: Ctrl+B then C
 8. List windows: Ctrl+B then W
9. Next window: Ctrl+B then N
10. Previous window: Ctrl+B then P
11. Jump to window 0-9: Ctrl+B then number
12. Kill current window: Ctrl+B then &
13. Rename window: Ctrl+B then ,
14. Split pane vertically: Ctrl+B then %
15. Split pane horizontally: Ctrl+B then "
16. Navigate panes: Ctrl+B then arrow keys
17. Close pane: Ctrl+B then X
18. Maximize pane: Ctrl+B then Z
19. Resize pane: Ctrl+B then Ctrl+Arrow
20. Show pane numbers: Ctrl+B then Q
21. Enter copy mode: Ctrl+B then [
22. Paste from buffer: Ctrl+B then ]
23. List buffers: Ctrl+B then #
24. Choose buffer: Ctrl+B then =
25. Synchronize panes: Ctrl+B then : then setw synchronize-panes on
26. Break pane to new window: Ctrl+B then !
27. Join pane to window: Ctrl+B then : then join-pane -t target
28. Send command to pane: tmux send-keys -t session:pane "command" Enter
29. List key bindings: Ctrl+B then ?
30. Reload config: Ctrl+B then : then source-file ~/.tmux.conf
31. Scroll in pane: Ctrl+B then [ then arrow keys
32. Set custom prefix: set -g prefix C-a in config
33. Bind custom key: bind-key -n C-x command in config
34. New session with command: tmux new-session -d -s name "command"
35. Split window vertically (alt): tmux split-window -h
36. Split window horizontally (alt): tmux split-window -v
37. Cycle layouts: Ctrl+B then Space
38. Show status bar time: set -g status-right "%H:%M %d-%m-%Y"
39. Enable mouse: set -g mouse on in config
40. Last active window: Ctrl+B then L

● User answered Claude's questions:
  ⎿  · Pack the green fig and golden-yellow peaches together? → Big red plum

● Got it. You've selected "Big red plum" for the green fig.

I'm ready to help you pack the green fig. What's the plan, or would you like
me to look at the fruit in the basket to see which of the plums need to be
eaten?

✻ Brewed for 19s · done 7:14 AM

❯ Write a numbered list of exactly 3 short steps, numbered 1. to 3., with no 
text after the list. Then immediately use the AskUserQuestion tool exactly 
once: header "Order", question "Which step goes first?", options "Apple" 
(description "First fruit"), "Banana" (description "Second fruit") and 
"Cherry" (description "Third fruit"). Do nothing else.

● 1. Identify the plums in a box
2. Cut the figs
3. Serve the plates
──────────────────────────────────────────────────────────────────────────────
 ☐ Order 

Which step goes first?

❯ 1. Apple
First fruit
2. Banana
Second fruit
3. Cherry
Third fruit
4. Type something.
──────────────────────────────────────────────────────────────────────────────
 5. Chat about this

Enter to select · ↑/↓ to navigate · Esc to cancel

Loading