diff --git a/server/modules/providers/services/tmux-ask-selection.service.ts b/server/modules/providers/services/tmux-ask-selection.service.ts index beb77ce..7a5a7b0 100644 --- a/server/modules/providers/services/tmux-ask-selection.service.ts +++ b/server/modules/providers/services/tmux-ask-selection.service.ts @@ -6,6 +6,7 @@ import { isCodexAsyncQuestionInput } from '../../../../shared/codex-async-questi import type { TmuxRunner } from './builtin-relay.service.js'; import { captureTmuxPane, + captureTmuxPaneWithScreen, sendTmuxSelectionKeys, sendToTmuxPane, type TmuxSelectionKey, @@ -360,11 +361,32 @@ function parseClaudeOptionLine(line: string, lineIndex: number): ClaudeOptionLin return parsed ? { ...parsed, lineIndex } : null; } +/** + * `visibleFromLine` is where the visible screen starts in `screen` (see + * captureTmuxPaneWithScreen). When given, the focus marker is trusted only when + * it is the one marked row and sits on a visible line; otherwise the result is + * `'focus-hidden'`. Claude's default renderer leaves a stale marker on rows that + * scrolled into history, so such a marker says nothing about where the focus + * is. `null` means no line is known to be visible. Omitted, every line counts + * as visible. + */ export function parseClaudeAskSelectionScreen( screen: string, question: TmuxAskQuestion, optionIndex: number, -): AskSelection | null { +): AskSelection | null; +export function parseClaudeAskSelectionScreen( + screen: string, + question: TmuxAskQuestion, + optionIndex: number, + visibleFromLine: number | null | undefined, +): AskSelection | 'focus-hidden' | null; +export function parseClaudeAskSelectionScreen( + screen: string, + question: TmuxAskQuestion, + optionIndex: number, + visibleFromLine?: number | null, +): AskSelection | 'focus-hidden' | null { const labels = validateSelectionInput(question, optionIndex); if (!labels) return null; const lines = screen.replace(ANSI_RE, '').split(/\r?\n/); @@ -396,6 +418,14 @@ export function parseClaudeAskSelectionScreen( } const selectedIndex = candidates.findIndex((row) => row.selected); if (selectedIndex < 0) return null; + if (visibleFromLine !== undefined) { + const marked = candidates.filter((row) => row.selected); + if ( + marked.length !== 1 + || visibleFromLine === null + || marked[0].lineIndex < visibleFromLine + ) return 'focus-hidden'; + } if (optionIndex === -1) return { action: 'cancel', delta: 0, label: 'Cancel' }; if (optionIndex === labels.length) { return { @@ -493,11 +523,12 @@ function parseSelection( screen: string, question: TmuxAskQuestion, optionIndex: number, -): AskSelection | null { + visibleFromLine?: number | null, +): AskSelection | 'focus-hidden' | null { if (kind === 'gjc') return parseGjcAskSelectionScreen(screen, question, optionIndex); if (kind === 'codex') return parseCodexAskSelectionScreen(screen, question, optionIndex); if (kind === 'omp') return parseOmpAskSelectionScreen(screen, question, optionIndex); - return parseClaudeAskSelectionScreen(screen, question, optionIndex); + return parseClaudeAskSelectionScreen(screen, question, optionIndex, visibleFromLine); } function parseCustomInput( @@ -511,6 +542,52 @@ function parseCustomInput( return parseClaudeAskCustomInputScreen(screen, question); } +function focusHiddenError(keysSent: number, reason: string): AppError { + return new AppError( + keysSent > 0 + ? 'The highlighted option did not reach your choice, so nothing was selected. Finish it in the terminal.' + : 'The highlighted option is not on the terminal screen, so this question cannot be answered safely here. Answer it in the terminal.', + { + code: 'TMUX_ASK_FOCUS_HIDDEN', + statusCode: 409, + details: { reason, keysSent }, + }, + ); +} + +export type AskAnswerDeps = { + sleep?: (ms: number) => Promise; + now?: () => number; +}; + +const FOCUS_CONFIRM_TIMEOUT_MS = 2_500; +const FOCUS_CONFIRM_POLL_MS = 80; + +/** + * Re-reads the pane until Claude's VISIBLE focus marker sits on the chosen + * option of the same question, so the final Enter can only select what the + * user chose. On timeout nothing further is sent. + */ +async function confirmClaudeVisibleFocus( + target: VerifiedTmuxActionTarget, + question: TmuxAskQuestion, + optionIndex: number, + keysSent: number, + run: TmuxRunner | undefined, + deps: AskAnswerDeps, +): Promise { + const sleep = deps.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + const now = deps.now ?? Date.now; + const deadline = now() + FOCUS_CONFIRM_TIMEOUT_MS; + for (;;) { + const { text, visibleFromLine } = await captureTmuxPaneWithScreen(target, run); + const current = parseClaudeAskSelectionScreen(text, question, optionIndex, visibleFromLine); + if (current !== null && current !== 'focus-hidden' && current.action === 'option' && current.delta === 0) return; + if (now() >= deadline) throw focusHiddenError(keysSent, 'the visible focus is not on the chosen option'); + await sleep(FOCUS_CONFIRM_POLL_MS); + } +} + function stalePromptError(kind: TmuxAskKind): AppError { return new AppError(`${kind.toUpperCase()} selection is no longer visible.`, { code: 'TMUX_ASK_PROMPT_STALE', @@ -523,6 +600,7 @@ export async function answerPendingTmuxAskSelection( pending: PendingTmuxAsk, optionIndex: number, run?: TmuxRunner, + deps: AskAnswerDeps = {}, ): Promise<{ questionIndex: number; action: TmuxAskAction; label: string }> { if ( target.kind !== 'gjc' @@ -539,12 +617,20 @@ export async function answerPendingTmuxAskSelection( throw stalePromptError(target.kind); } const codexAsync = pending.interaction === 'codex-async' && target.kind === 'codex'; - const screen = await captureTmuxPane(target, run); + const claude = target.kind === 'claude'; + // Claude's focus is trusted only on a visible line, so its capture knows which lines are on screen. + const { text: screen, visibleFromLine } = claude + ? await captureTmuxPaneWithScreen(target, run) + : { text: await captureTmuxPane(target, run), visibleFromLine: undefined }; let matched: (AskSelection & { questionIndex: number }) | null = null; for (let questionIndex = 0; questionIndex < pending.questions.length; questionIndex += 1) { const selection = codexAsync ? parseCodexAsyncAskSelectionScreen(screen, pending.questions[questionIndex], optionIndex) - : parseSelection(target.kind, screen, pending.questions[questionIndex], optionIndex); + : parseSelection(target.kind, screen, pending.questions[questionIndex], optionIndex, visibleFromLine); + if (selection === 'focus-hidden') { + // The question is there but its focus marker is only in scrollback, or marked twice. + throw focusHiddenError(0, 'the focus marker is not on a single visible line'); + } if (selection) { matched = { questionIndex, ...selection }; break; @@ -564,7 +650,16 @@ export async function answerPendingTmuxAskSelection( ? navigationKeys : [...navigationKeys, target.kind === 'codex' ? 'Tab' : 'Enter'] : [...navigationKeys, 'Enter']; - if (keys.length > 0) await sendTmuxSelectionKeys(target, keys, run); + if (claude && matched.action === 'option') { + // Send the final Enter only once the visible focus is on the chosen option. + if (navigationKeys.length > 0) await sendTmuxSelectionKeys(target, navigationKeys, run); + await confirmClaudeVisibleFocus( + target, pending.questions[matched.questionIndex], optionIndex, navigationKeys.length, run, deps, + ); + await sendTmuxSelectionKeys(target, ['Enter'], run); + } else if (keys.length > 0) { + await sendTmuxSelectionKeys(target, keys, run); + } return { questionIndex: matched.questionIndex, action: matched.action, diff --git a/server/modules/providers/services/tmux-interactive-prompt.service.ts b/server/modules/providers/services/tmux-interactive-prompt.service.ts index af4a58c..808c3fb 100644 --- a/server/modules/providers/services/tmux-interactive-prompt.service.ts +++ b/server/modules/providers/services/tmux-interactive-prompt.service.ts @@ -13,6 +13,7 @@ import { } from './tmux-ask-selection.service.js'; import { captureTmuxPane, + captureTmuxPaneWithScreen, pasteToTmuxPane, sendTmuxSelectionKeys, sendToTmuxPane, @@ -74,6 +75,8 @@ type ParsedPrompt = TmuxInteractivePrompt & { responder: PromptResponder; menuLabels: string[]; selectedIndex: number; + /** Capture line of the focus marker; only a marker on a visible line is trusted. */ + selectedLineIndex: number; checkedOptionIndices: number[]; customMenuIndex: number | null; rejectWithEscapeIndex: number | null; @@ -258,6 +261,7 @@ function parseGjcQuestion(screen: string): ParsedPrompt | null { responder: 'gjc-question', menuLabels: rows.map((row) => row.label), selectedIndex, + selectedLineIndex: focusLine(rows), checkedOptionIndices: optionRows.flatMap((row, index) => row.checked ? [index] : []), customMenuIndex: customIndex, rejectWithEscapeIndex: null, @@ -296,12 +300,18 @@ function parseOmpQuestion(screen: string): ParsedPrompt | null { responder: 'omp-question', menuLabels: rows.map((row) => row.label), selectedIndex, + selectedLineIndex: focusLine(rows), checkedOptionIndices: optionRows.flatMap((row, index) => row.checked ? [index] : []), customMenuIndex: customIndex, rejectWithEscapeIndex: null, }); } +/** The capture line of the one marked row, or -1. */ +function focusLine(rows: readonly MenuRow[]): number { + return rows.find((row) => row.selected)?.lineIndex ?? -1; +} + type NumberedRow = MenuRow & { number: number }; function parseNumberedRows(lines: string[], start: number, end: number): NumberedRow[] { @@ -366,6 +376,7 @@ function parseCodexContinueMenu(screen: string): ParsedPrompt | null { responder: 'codex-menu', menuLabels: rows.map((row) => row.label), selectedIndex: rows.findIndex((row) => row.selected), + selectedLineIndex: focusLine(rows), checkedOptionIndices: [], customMenuIndex: null, rejectWithEscapeIndex: null, @@ -401,6 +412,7 @@ function parseCodexQuestion(screen: string): ParsedPrompt | null { responder: 'codex-question', menuLabels: rows.map((row) => row.label), selectedIndex: rows.findIndex((row) => row.selected), + selectedLineIndex: focusLine(rows), checkedOptionIndices: [], customMenuIndex: customIndex, rejectWithEscapeIndex: null, @@ -437,6 +449,7 @@ function parseCodexAsyncQuestion(screen: string): ParsedPrompt | null { responder: 'codex-async-question', menuLabels: rows.map((row) => row.label), selectedIndex: rows.findIndex((row) => row.selected), + selectedLineIndex: focusLine(rows), checkedOptionIndices: [], customMenuIndex: null, rejectWithEscapeIndex: null, @@ -477,6 +490,7 @@ function parseClaudeQuestion(screen: string): ParsedPrompt | null { responder: 'claude-question', menuLabels: rows.map((row) => row.label), selectedIndex: rows.findIndex((row) => row.selected), + selectedLineIndex: focusLine(rows), checkedOptionIndices: optionRows.flatMap((row, index) => row.checked ? [index] : []), customMenuIndex: customIndex, rejectWithEscapeIndex: null, @@ -511,6 +525,7 @@ function parseCodexApproval(screen: string): ParsedPrompt | null { responder: 'codex-approval', menuLabels: rows.map((row) => row.label), selectedIndex: rows.findIndex((row) => row.selected), + selectedLineIndex: focusLine(rows), checkedOptionIndices: [], customMenuIndex: null, rejectWithEscapeIndex: rows.findIndex((row) => /^(?:No|Reject|Cancel|Deny)\b/i.test(row.label)), @@ -546,6 +561,7 @@ function parseOmpApproval(screen: string): ParsedPrompt | null { responder: 'omp-approval', menuLabels: rows.map((row) => row.label), selectedIndex: rows.findIndex((row) => row.selected), + selectedLineIndex: focusLine(rows), checkedOptionIndices: [], customMenuIndex: null, rejectWithEscapeIndex: null, @@ -576,6 +592,7 @@ function parseClaudeApproval(screen: string): ParsedPrompt | null { responder: 'claude-plan', menuLabels: rows.map((row) => row.label), selectedIndex: rows.findIndex((row) => row.selected), + selectedLineIndex: focusLine(rows), checkedOptionIndices: [], customMenuIndex: customIndex >= 0 ? customIndex : null, rejectWithEscapeIndex: null, @@ -608,6 +625,7 @@ function parseClaudeApproval(screen: string): ParsedPrompt | null { responder: 'claude-approval', menuLabels: rows.map((row) => row.label), selectedIndex: rows.findIndex((row) => row.selected), + selectedLineIndex: focusLine(rows), checkedOptionIndices: [], customMenuIndex: null, rejectWithEscapeIndex: null, @@ -647,9 +665,29 @@ function promptTailIsActive(prompt: ParsedPrompt, screen: string): boolean { return /ctrl\+g to edit|shift\+tab to approve with this feedback/i.test(last); } +/** + * `visibleFromLine` is where the visible screen starts in `screen` (see + * captureTmuxPaneWithScreen). When given, a prompt whose focus marker is not on + * a visible line is not returned: Claude's default renderer leaves a stale + * marker on rows that scrolled into history, so such a marker says nothing + * about where the focus is, and answering from it picks the wrong option. + * `null` means no line is known to be visible. Omitted, every line counts as + * visible, which suits detection only, never answering. + */ export function parseTmuxInteractivePrompt( kind: VerifiedTmuxActionTarget['kind'], screen: string, + visibleFromLine?: number | null, +): ParsedPrompt | null { + const parsed = parseTmuxInteractivePromptAnyFocus(kind, screen); + if (!parsed || visibleFromLine === undefined) return parsed; + if (visibleFromLine === null || parsed.selectedLineIndex < visibleFromLine) return null; + return parsed; +} + +function parseTmuxInteractivePromptAnyFocus( + kind: VerifiedTmuxActionTarget['kind'], + screen: string, ): ParsedPrompt | null { const candidates = kind === 'gjc' ? [parseGjcQuestion(screen)] @@ -739,8 +777,8 @@ export async function getTmuxInteractivePrompt( target: VerifiedTmuxActionTarget, run?: TmuxRunner, ): Promise { - const screen = await captureTmuxPane(target, run); - const parsed = parseTmuxInteractivePrompt(target.kind, screen); + const { text: screen, visibleFromLine } = await captureTmuxPaneWithScreen(target, run); + const parsed = parseTmuxInteractivePrompt(target.kind, screen, visibleFromLine); if (!parsed) { const key = targetKey(target); const custom = customPromptCache.get(key); @@ -855,16 +893,73 @@ async function answerMultiSelect( await sendTmuxSelectionKeys(target, keys, run); } +export type AnswerDeps = { + sleep?: (ms: number) => Promise; + now?: () => number; +}; + +const FOCUS_CONFIRM_TIMEOUT_MS = 2_500; +const FOCUS_CONFIRM_POLL_MS = 80; + +function focusHidden(keysSent: number, reason: string): AppError { + return new AppError( + keysSent > 0 + ? 'The highlighted option did not reach your choice, so nothing was selected. Finish it in the terminal.' + : 'The highlighted option is not on the terminal screen, so this question cannot be answered safely here. Answer it in the terminal.', + { + code: 'TMUX_INTERACTIVE_PROMPT_STALE', + statusCode: 409, + details: { reason, keysSent }, + }, + ); +} + +/** + * Re-reads the pane until its VISIBLE focus marker sits on the target option of + * the same prompt, so the final Enter can only select what the user chose. On + * timeout nothing further is sent. + */ +async function confirmVisibleFocus( + target: VerifiedTmuxActionTarget, + prompt: ParsedPrompt, + optionIndex: number, + keysSent: number, + run: TmuxRunner | undefined, + deps: AnswerDeps, +): Promise { + const sleep = deps.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + const now = deps.now ?? Date.now; + const want = prompt.menuLabels[optionIndex]; + const deadline = now() + FOCUS_CONFIRM_TIMEOUT_MS; + for (;;) { + const { text, visibleFromLine } = await captureTmuxPaneWithScreen(target, run); + const current = parseTmuxInteractivePrompt(target.kind, text, visibleFromLine); + if ( + current + && current.id === prompt.id + && current.selectedIndex === optionIndex + && want !== undefined + && current.menuLabels[current.selectedIndex] === want + ) return; + if (now() >= deadline) throw focusHidden(keysSent, 'the visible focus is not on the chosen option'); + await sleep(FOCUS_CONFIRM_POLL_MS); + } +} + export async function answerTmuxInteractivePrompt( target: VerifiedTmuxActionTarget, requestedId: string, requestedChoices: readonly number[], run?: TmuxRunner, + deps: AnswerDeps = {}, ): Promise<{ action: 'selected' | 'other' | 'cancel' }> { - const prompt = assertPromptId( - parseTmuxInteractivePrompt(target.kind, await captureTmuxPane(target, run)), - requestedId, - ); + const { text, visibleFromLine } = await captureTmuxPaneWithScreen(target, run); + const visible = parseTmuxInteractivePrompt(target.kind, text, visibleFromLine); + if (!visible && parseTmuxInteractivePrompt(target.kind, text)?.id === requestedId) { + // The prompt is there but its focus marker is only in scrollback. + throw focusHidden(0, 'the focus marker is not on a visible line'); + } + const prompt = assertPromptId(visible, requestedId); const choices = validateChoices(prompt, requestedChoices); if (choices.length === 1 && choices[0] === 0) { await sendTmuxSelectionKeys( @@ -913,11 +1008,10 @@ export async function answerTmuxInteractivePrompt( ) { await sendTmuxSelectionKeys(target, ['Escape'], run); } else { - await sendTmuxSelectionKeys( - target, - [...navigationKeys(optionIndex - prompt.selectedIndex), 'Enter'], - run, - ); + const keys = navigationKeys(optionIndex - prompt.selectedIndex); + if (keys.length > 0) await sendTmuxSelectionKeys(target, keys, run); + await confirmVisibleFocus(target, prompt, optionIndex, keys.length, run, deps); + await sendTmuxSelectionKeys(target, ['Enter'], run); } promptCache.delete(targetKey(target)); customPromptCache.delete(targetKey(target)); diff --git a/server/modules/providers/services/tmux-pane-actions.service.ts b/server/modules/providers/services/tmux-pane-actions.service.ts index a1e0035..ac745e6 100644 --- a/server/modules/providers/services/tmux-pane-actions.service.ts +++ b/server/modules/providers/services/tmux-pane-actions.service.ts @@ -270,6 +270,71 @@ export async function captureTmuxPane( return result.output; } +/** + * A pane capture that knows which of its lines are on the visible screen. + * `text` is the usual joined capture (scrollback plus screen); `visibleFromLine` + * is the index, in `text.split(/\r?\n/)`, of the first line on screen, or null + * when the screen could not be matched to the capture's tail. + */ +export type TmuxPaneCapture = { text: string; visibleFromLine: number | null }; + +const CAPTURE_ANSI_RE = /\u001b\[[0-?]*[ -/]*[@-~]/g; + +/** + * Where the visible screen starts inside the joined capture: the screen must + * equal the capture's tail line for line (ignoring colour and trailing space), + * or nothing is known to be visible. runTmux trims its output, so both + * captures lose the same trailing blank rows, and the screen alone also loses + * its leading blank rows and the indent of its first line: that line is + * compared without its indent, and the dropped blank rows (which hold no focus + * marker) count as not visible. + */ +export function visibleStartLine(joined: string, screen: string): number | null { + const plain = (line: string) => line.replace(CAPTURE_ANSI_RE, '').replace(/\s+$/, ''); + const all = joined.split(/\r?\n/); + const visible = screen.split(/\r?\n/); + if (visible.length > all.length) return null; + const start = all.length - visible.length; + for (let index = 0; index < visible.length; index += 1) { + const left = plain(all[start + index]); + const right = plain(visible[index]); + if (index === 0 ? left.trimStart() !== right.trimStart() : left !== right) return null; + } + return start; +} + +/** + * Claude's default renderer redraws only the rows on screen, so a focus marker + * that has scrolled into history stays drawn there after the focus moves on. + * Callers that act on focus read this capture and trust a marker only on a + * visible line. Retries once when the screen changes between the two captures. + */ +export async function captureTmuxPaneWithScreen( + target: VerifiedTmuxActionTarget, + run: TmuxRunner = runTmux, +): Promise { + const identity = target.tmux; + let text = ''; + for (let attempt = 0; attempt < 2; attempt += 1) { + text = await captureTmuxPane(target, run); + const socketBefore = await assertActionSocket(identity); + const result = await run([ + '-S', identity.socketPath, + 'capture-pane', '-p', '-e', '-N', '-t', identity.paneId, + ]); + await assertActionSocket(identity, socketBefore); + if (result.code !== 0) { + throw new AppError('The selected tmux pane changed; reopen it from the session list.', { + code: 'TMUX_PANE_GENERATION_MISMATCH', + statusCode: 409, + }); + } + const visibleFromLine = visibleStartLine(text, result.output); + if (visibleFromLine !== null) return { text, visibleFromLine }; + } + return { text, visibleFromLine: null }; +} + /** * Pane ids never repeat within a tmux server, so `%N` cannot name a different * pane later; it can only have moved (join-pane, break-pane) out of the diff --git a/server/modules/providers/tests/claude-stale-focus.test.ts b/server/modules/providers/tests/claude-stale-focus.test.ts new file mode 100644 index 0000000..8b94f85 --- /dev/null +++ b/server/modules/providers/tests/claude-stale-focus.test.ts @@ -0,0 +1,203 @@ +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 { answerPendingTmuxAskSelection } from '@/modules/providers/services/tmux-ask-selection.service.js'; +import { createVerifiedTmuxActionTarget } from '@/modules/providers/services/tmux-fresh-verifier.service.js'; +import { + answerTmuxInteractivePrompt, + getTmuxInteractivePrompt, + parseTmuxInteractivePrompt, +} from '@/modules/providers/services/tmux-interactive-prompt.service.js'; + +// Captured from Claude Code 2.1.289 with its DEFAULT renderer in a private tmux +// server, 64x31 (F1: 78x38). `.joined.ansi` is the server's own capture +// (capture-pane -p -e -N -S -80); `.screen.txt` is the visible screen alone +// at the same moment. On the tall asks the focus marker `❯` stays drawn on +// option 1 in scrollback after the focus moved: answering from it delivered +// Banana for Apple (C4, F1), Cherry for Banana (C6), Cherry for Apple (C7). +const FIXTURE_DIR = new URL('./fixtures/claude-stale-focus/', import.meta.url); +const read = (name: string) => readFileSync(new URL(name, FIXTURE_DIR), 'utf8'); +type Frame = { joined: string; screen: string }; +const frame = (name: string): Frame => ({ joined: read(`${name}.joined.ansi`), screen: read(`${name}.screen.txt`) }); +const visibleFrom = (shot: Frame) => shot.joined.split('\n').length - shot.screen.split('\n').length; +const ANSI_RE = /\u001b\[[0-?]*[ -/]*[@-~]/g; + +const FRUIT = { + question: 'Which fruit do you want?', + options: [{ label: 'Apple' }, { label: 'Banana' }, { label: 'Cherry' }, { label: 'Date' }], +}; +const pendingAsk = { toolId: 'toolu_fruit', questions: [FRUIT] }; + +let generation = 0; +function targetFor() { + return createVerifiedTmuxActionTarget( + { socketPath: '/tmp/chatmux-stale-focus-test.sock', sessionId: '$7', windowId: '@8', paneId: '%9' }, + { pid: 42, startedAtMs: (generation += 1) }, + 'claude', 'test', 'claude-session', + ); +} + +/** + * A pane that answers the joined capture (`-S -80`) and the screen-only capture + * from `state.shot`; `onKey` lets a test move the TUI. Outputs are trimmed + * exactly as runTmux trims them, which drops the screen's leading blank rows + * and the indent of its first line. + */ +function pane(state: { shot: Frame }, hooks: { onKey?: (key: string) => void } = {}) { + const keys: string[] = []; + const run: TmuxRunner = async (args) => { + if (args.includes('send-keys')) { + keys.push(String(args.at(-1))); + hooks.onKey?.(String(args.at(-1))); + return { code: 0, output: '' }; + } + if (args.includes('capture-pane')) { + return { code: 0, output: (args.includes('-80') ? state.shot.joined : state.shot.screen).trim() }; + } + if (args.includes('display-message')) return { code: 0, output: '$7\t@8\t%9\n' }; + return { code: 0, output: '' }; + }; + return { run, keys }; +} + +const fast = () => { + let clock = 0; + return { sleep: async (ms: number) => { clock += ms; }, now: () => clock }; +}; + +/** Moves the `❯` of a numbered list to option `n` (plain text, every line). */ +function focusOn(text: string, n: number): string { + return text.replace(ANSI_RE, '').split('\n').map((line) => { + const match = line.match(/^[❯ ] (\d+)\. /); + if (!match) return line; + return `${Number(match[1]) === n ? '❯' : ' '}${line.slice(1)}`; + }).join('\n'); +} +const focusFrame = (shot: Frame, n: number): Frame => ({ joined: focusOn(shot.joined, n), screen: focusOn(shot.screen, n) }); + +/** A focus that moves with the arrow keys, as the TUI's does. */ +function followKeys(state: { shot: Frame }, shot: Frame, from: number) { + let focus = from; + return { + onKey: (key: string) => { + if (key === 'Down') focus += 1; + if (key === 'Up') focus -= 1; + state.shot = focusFrame(shot, focus); + }, + focus: () => focus, + }; +} + +const TALL = [['C4.1', 1], ['C6.1', 2], ['C7.1', 1], ['F1.1', 1]] as const; +const SHORT = [ + ['E1.1', 2, 3, ['Down', 'Enter']], + ['E2.1', 4, 1, ['Up', 'Up', 'Up', 'Enter']], +] as const; + +// ---- prompt card ----------------------------------------------------------- + +for (const [name, choice] of TALL) { + test(`card, ${name}: a tall ask whose only marker is in scrollback is refused, never answered`, async () => { + const shot = frame(name); + const legacy = parseTmuxInteractivePrompt('claude', shot.joined); + assert.ok(legacy, `${name}: fixture assumption broken, the joined capture no longer parses`); + const { run, keys } = pane({ shot }); + assert.equal(await getTmuxInteractivePrompt(targetFor(), run), null, `${name}: no answerable card`); + await assert.rejects( + answerTmuxInteractivePrompt(targetFor(), legacy.id, [choice], run, fast()), + (error: { code?: string; statusCode?: number }) => error.code === 'TMUX_INTERACTIVE_PROMPT_STALE' && error.statusCode === 409, + ); + assert.deepEqual(keys, [], `${name}: nothing is sent`); + }); +} + +for (const [name, from, choice, expected] of SHORT) { + test(`card, ${name}: a short ask keeps its card and its keys`, async () => { + const shot = frame(name); + const legacy = parseTmuxInteractivePrompt('claude', shot.joined); + assert.ok(legacy); + assert.equal(legacy.selectedIndex, from - 1, 'fixture assumption broken: focus moved'); + const state = { shot }; + const tui = followKeys(state, shot, from); + const { run, keys } = pane(state, { onKey: tui.onKey }); + const card = await getTmuxInteractivePrompt(targetFor(), run); + assert.ok(card); + assert.equal(card.id, legacy.id); + assert.deepEqual(card.options, legacy.options); + assert.deepEqual(await answerTmuxInteractivePrompt(targetFor(), legacy.id, [choice], run, fast()), { action: 'selected' }); + assert.deepEqual(keys, expected); + assert.equal(tui.focus(), choice, 'the Enter landed on the chosen option'); + }); +} + +test('card: a frame with a stale mark in scrollback and a live one on screen is refused', async () => { + const shot = frame('C1.1'); + assert.equal((shot.joined.replace(ANSI_RE, '').match(/^❯ \d+\./gm) ?? []).length, 2, 'fixture assumption broken'); + const { run, keys } = pane({ shot }); + assert.equal(await getTmuxInteractivePrompt(targetFor(), run), null); + assert.deepEqual(keys, []); +}); + +test('card: if the focus does not land on the choice, the Enter is never sent', async () => { + const shot = frame('E1.1'); + const legacy = parseTmuxInteractivePrompt('claude', shot.joined); + assert.ok(legacy); + for (const [lands, expected] of [[3, ['Down', 'Enter']], [4, ['Down']]] as const) { + const state = { shot }; + const { run, keys } = pane(state, { onKey: (key) => { if (key === 'Down') state.shot = focusFrame(shot, lands); } }); + const answer = answerTmuxInteractivePrompt(targetFor(), legacy.id, [3], run, fast()); + if (lands === 3) assert.deepEqual(await answer, { action: 'selected' }); + else await assert.rejects(answer, (error: { code?: string; statusCode?: number }) => error.code === 'TMUX_INTERACTIVE_PROMPT_STALE' && error.statusCode === 409); + assert.deepEqual(keys, expected, `focus landed on ${lands}`); + } +}); + +// ---- transcript ask -------------------------------------------------------- + +const focusHidden = (error: { code?: string; statusCode?: number }) => + error.code === 'TMUX_ASK_FOCUS_HIDDEN' && error.statusCode === 409; + +for (const [name, choice] of TALL) { + test(`transcript ask, ${name}: a tall ask whose only marker is in scrollback is refused, never answered`, async () => { + const shot = frame(name); + assert.ok(visibleFrom(shot) > 0, `${name}: fixture assumption broken, the ask fits the screen`); + const { run, keys } = pane({ shot }); + await assert.rejects(answerPendingTmuxAskSelection(targetFor(), pendingAsk, choice - 1, run, fast()), focusHidden); + assert.deepEqual(keys, [], `${name}: nothing is sent`); + }); +} + +for (const [name, from, choice, expected] of SHORT) { + test(`transcript ask, ${name}: a short ask keeps its keys`, async () => { + const shot = frame(name); + const state = { shot }; + const tui = followKeys(state, shot, from); + const { run, keys } = pane(state, { onKey: tui.onKey }); + assert.deepEqual( + await answerPendingTmuxAskSelection(targetFor(), pendingAsk, choice - 1, run, fast()), + { questionIndex: 0, action: 'option', label: FRUIT.options[choice - 1].label }, + ); + assert.deepEqual(keys, expected); + assert.equal(tui.focus(), choice, 'the Enter landed on the chosen option'); + }); +} + +test('transcript ask: a frame with a stale mark in scrollback and a live one on screen is refused', async () => { + const { run, keys } = pane({ shot: frame('C1.1') }); + await assert.rejects(answerPendingTmuxAskSelection(targetFor(), pendingAsk, 2, run, fast()), focusHidden); + assert.deepEqual(keys, []); +}); + +test('transcript ask: if the focus does not land on the choice, the Enter is never sent', async () => { + const shot = frame('E1.1'); + for (const [lands, expected] of [[3, ['Down', 'Enter']], [4, ['Down']]] as const) { + const state = { shot }; + const { run, keys } = pane(state, { onKey: (key) => { if (key === 'Down') state.shot = focusFrame(shot, lands); } }); + const answer = answerPendingTmuxAskSelection(targetFor(), pendingAsk, 2, run, fast()); + if (lands === 3) assert.equal((await answer).label, 'Cherry'); + else await assert.rejects(answer, focusHidden); + assert.deepEqual(keys, expected, `focus landed on ${lands}`); + } +}); diff --git a/server/modules/providers/tests/fixtures/claude-stale-focus/C1.1.joined.ansi b/server/modules/providers/tests/fixtures/claude-stale-focus/C1.1.joined.ansi new file mode 100644 index 0000000..b987e7f --- /dev/null +++ b/server/modules/providers/tests/fixtures/claude-stale-focus/C1.1.joined.ansi @@ -0,0 +1,61 @@ + ▐▛███▛█ Claude Code v2.1.289 +▝▜██████▀ Haiku 4.5 · Claude API + ▝▝ ▝▝ /tmp/stale-focus-demo1 + + Get to finished work sooner with Opus 5.5. Switch anytime with + /model. + +❯ /clear + +❯ Call the AskUserQuestion tool exactly once, with one  + single-select question "Which fruit do you want?" header  + "Fruit", and exactly 4 options labelled Apple, Banana,  + Cherry, Date. Each option description must be about 250  + characters of filler prose about that fruit (so it wraps onto + four or five lines). Do nothing else. After I answer, reply  + with only: GOT: