From e8495abcc8bccfcfaf4963210525d891fa7c0b11 Mon Sep 17 00:00:00 2001 From: Luke Norland <217919286+Luke-Norland@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:37:54 +0000 Subject: [PATCH] fix(chat): never answer a Claude question from a stale highlight Claude Code's default renderer redraws only the rows on screen. When an AskUserQuestion is taller than the pane and the user moves the highlight, the old focus marker stays drawn on option 1 in the scrollback. The joined capture (scrollback plus screen) showed that stale marker as the focus, so an answer from chat sent arrow keys counted from the wrong row and Claude received an option the user did not choose (Apple became Banana, Banana became Cherry). - Capture the visible screen alongside the joined capture and locate it in the joined lines, tolerating the runner's trim. - Trust a focus marker only on a visible line, in both answer paths: the prompt card and the transcript ask selection. A hidden or doubled marker gives no card and refuses to answer with a 409 that tells the user to answer in the terminal. - Re-read the pane before the final Enter and send it only when the visible focus is on the chosen option; otherwise nothing further is sent. Questions that fit the screen keep their cards and keys. Co-Authored-By: Claude Opus 5.5 --- .../services/tmux-ask-selection.service.ts | 107 ++++++++- .../tmux-interactive-prompt.service.ts | 116 +++++++++- .../services/tmux-pane-actions.service.ts | 65 ++++++ .../tests/claude-stale-focus.test.ts | 203 ++++++++++++++++++ .../claude-stale-focus/C1.1.joined.ansi | 61 ++++++ .../claude-stale-focus/C1.1.screen.txt | 31 +++ .../claude-stale-focus/C4.1.joined.ansi | 68 ++++++ .../claude-stale-focus/C4.1.screen.txt | 31 +++ .../claude-stale-focus/C6.1.joined.ansi | 66 ++++++ .../claude-stale-focus/C6.1.screen.txt | 31 +++ .../claude-stale-focus/C7.1.joined.ansi | 78 +++++++ .../claude-stale-focus/C7.1.screen.txt | 31 +++ .../claude-stale-focus/E1.1.joined.ansi | 34 +++ .../claude-stale-focus/E1.1.screen.txt | 31 +++ .../claude-stale-focus/E2.1.joined.ansi | 34 +++ .../claude-stale-focus/E2.1.screen.txt | 31 +++ .../claude-stale-focus/F1.1.joined.ansi | 74 +++++++ .../claude-stale-focus/F1.1.screen.txt | 38 ++++ .../tests/provider-routes-contract-cases-1.ts | 6 +- .../tests/tmux-ask-selection.service.test.ts | 3 + .../tmux-interactive-prompt.service.test.ts | 7 +- 21 files changed, 1126 insertions(+), 20 deletions(-) create mode 100644 server/modules/providers/tests/claude-stale-focus.test.ts create mode 100644 server/modules/providers/tests/fixtures/claude-stale-focus/C1.1.joined.ansi create mode 100644 server/modules/providers/tests/fixtures/claude-stale-focus/C1.1.screen.txt create mode 100644 server/modules/providers/tests/fixtures/claude-stale-focus/C4.1.joined.ansi create mode 100644 server/modules/providers/tests/fixtures/claude-stale-focus/C4.1.screen.txt create mode 100644 server/modules/providers/tests/fixtures/claude-stale-focus/C6.1.joined.ansi create mode 100644 server/modules/providers/tests/fixtures/claude-stale-focus/C6.1.screen.txt create mode 100644 server/modules/providers/tests/fixtures/claude-stale-focus/C7.1.joined.ansi create mode 100644 server/modules/providers/tests/fixtures/claude-stale-focus/C7.1.screen.txt create mode 100644 server/modules/providers/tests/fixtures/claude-stale-focus/E1.1.joined.ansi create mode 100644 server/modules/providers/tests/fixtures/claude-stale-focus/E1.1.screen.txt create mode 100644 server/modules/providers/tests/fixtures/claude-stale-focus/E2.1.joined.ansi create mode 100644 server/modules/providers/tests/fixtures/claude-stale-focus/E2.1.screen.txt create mode 100644 server/modules/providers/tests/fixtures/claude-stale-focus/F1.1.joined.ansi create mode 100644 server/modules/providers/tests/fixtures/claude-stale-focus/F1.1.screen.txt diff --git a/server/modules/providers/services/tmux-ask-selection.service.ts b/server/modules/providers/services/tmux-ask-selection.service.ts index beb77ce4..7a5a7b09 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 af4a58c3..808c3fb0 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 a1e00350..ac745e69 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 00000000..8b94f857 --- /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 00000000..b987e7f5 --- /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: