Skip to content
Merged
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
12 changes: 10 additions & 2 deletions src/app/api/worktrees/[id]/prompt-response/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import { detectPrompt, type PromptDetectionResult } from '@/lib/detection/prompt
import { stripAnsi, stripBoxDrawing, buildDetectPromptOptions } from '@/lib/detection/cli-patterns';
import { sendPromptAnswer } from '@/lib/prompt-answer-sender';
import { isValidWorktreeId } from '@/lib/security/path-validator';
import type { PromptType } from '@/types/models';
import type { PromptType, SubmitMode } from '@/types/models';
import { isValidSubmitMode } from '@/types/models';
import { createLogger } from '@/lib/logger';

const logger = createLogger('api/prompt-response');
Expand All @@ -26,6 +27,8 @@ interface PromptResponseRequest {
promptType?: PromptType;
/** Issue #287: Default option number from client-side detection (fallback when promptCheck fails) */
defaultOptionNumber?: number;
/** Issue #616: Submit mode from client-side detection (fallback when promptCheck fails) */
submitMode?: string;
}

export async function POST(
Expand All @@ -41,7 +44,11 @@ export async function POST(
}

const body: PromptResponseRequest = await req.json();
const { answer, cliTool: cliToolParam, promptType: bodyPromptType, defaultOptionNumber: bodyDefaultOptionNumber } = body;
const { answer, cliTool: cliToolParam, promptType: bodyPromptType, defaultOptionNumber: bodyDefaultOptionNumber, submitMode: bodySubmitMode } = body;

// Issue #616: Allowlist validation for submitMode
const validSubmitMode: SubmitMode | undefined =
isValidSubmitMode(bodySubmitMode) ? bodySubmitMode : undefined;

// Validation
if (!answer) {
Expand Down Expand Up @@ -124,6 +131,7 @@ export async function POST(
promptData: promptCheck?.promptData,
fallbackPromptType: bodyPromptType,
fallbackDefaultOptionNumber: bodyDefaultOptionNumber,
fallbackSubmitMode: validSubmitMode,
});
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
Expand Down
21 changes: 8 additions & 13 deletions src/app/api/worktrees/[id]/respond/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { getDbInstance } from '@/lib/db/db-instance';
import { getMessageById, updatePromptData, getWorktreeById } from '@/lib/db';
import { sendKeys } from '@/lib/tmux/tmux';
import { CLIToolManager } from '@/lib/cli-tools/manager';
import { sendPromptAnswer } from '@/lib/prompt-answer-sender';
import { startPolling } from '@/lib/polling/response-poller';
import { getAnswerInput } from '@/lib/detection/prompt-detector';
import { broadcastMessage } from '@/lib/ws-server';
Expand Down Expand Up @@ -144,20 +144,15 @@ export async function POST(
// Get session name for the CLI tool
const sessionName = cliTool.getSessionName(params.id);

// Send answer to tmux
// For Claude prompts, send the answer and then Enter separately
// This is because Claude's interactive menu responds immediately to the key press
// Send answer to tmux via shared sendPromptAnswer() (Issue #616)
try {
// Send the answer (number or y/n)
await sendKeys(sessionName, input, false);
await sendPromptAnswer({
sessionName,
answer: input,
cliToolId,
promptData: message.promptData,
});
logger.info('sent-answer-to');

// Wait a moment for the input to be processed
await new Promise(resolve => setTimeout(resolve, 100));

// Send Enter
await sendKeys(sessionName, '', true);
logger.info('sent-enter-to');
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return NextResponse.json(
Expand Down
39 changes: 36 additions & 3 deletions src/lib/detection/prompt-detect-multiple-choice.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/** Multiple choice prompt detection logic extracted from prompt-detector.ts (Issue #575). */

import type { DetectPromptOptions, PromptDetectionResult } from './types';
import type { SubmitMode } from '@/types/models';

// ============================================================================
// Constants
Expand Down Expand Up @@ -71,7 +72,18 @@ const COLLAPSED_OUTPUT_PATTERN = /^\s*\[[^\]]*\d+\s+lines?\]/i;
* When this footer is present, numbered options form an active prompt even if
* the default cursor marker is missing from the capture output.
*/
const CONFIRMATION_FOOTER_PATTERN = /press\s+enter\s+to\s+confirm\s+or\s+esc\s+to\s+cancel/i;
/**
* Extended to match both "press enter to confirm" and "press number to confirm" footers.
* Issue #616: Codex Reasoning Level UI uses "press number to confirm" instead of "press enter to confirm".
*/
const CONFIRMATION_FOOTER_PATTERN = /press\s+(?:enter|number)\s+to\s+confirm/i;

/**
* Pattern to detect "press number to confirm" footer specifically.
* When matched, the prompt uses answer_only submitMode (no Enter key needed).
* Issue #616.
*/
const NUMBER_FOOTER_PATTERN = /press\s+number\s+to\s+confirm/i;

/**
* Maximum number of lines to scan upward from questionEndIndex
Expand Down Expand Up @@ -443,6 +455,7 @@ function extractInstructionText(
* @param instructionText - Instruction text for the prompt block
* @param output - Original output text (used for rawContent truncation)
* @param truncateRawContentFn - Function to truncate raw content
* @param submitMode - Optional submit mode for the prompt (Issue #616)
* @returns PromptDetectionResult with isPrompt: true and multiple_choice data
*/
export function buildMultipleChoiceResult(
Expand All @@ -451,6 +464,7 @@ export function buildMultipleChoiceResult(
instructionText: string | undefined,
output: string,
truncateRawContentFn: (content: string) => string,
submitMode?: SubmitMode,
): PromptDetectionResult {
return {
isPrompt: true,
Expand All @@ -470,6 +484,7 @@ export function buildMultipleChoiceResult(
}),
status: 'pending',
instructionText,
...(submitMode ? { submitMode } : {}),
},
cleanContent: question.trim(),
rawContent: truncateRawContentFn(output.trim()), // Issue #235: complete prompt output (truncated) [MF-001]
Expand Down Expand Up @@ -523,7 +538,20 @@ export function detectMultipleChoicePrompt(
// Calculate scan window: last 50 non-trailing-empty lines
const scanStart = Math.max(0, effectiveEnd - 50);
const scanWindow = lines.slice(scanStart, effectiveEnd);
const hasConfirmationFooter = scanWindow.some((rawLine) => CONFIRMATION_FOOTER_PATTERN.test(rawLine.trim()));
// Single-pass footer detection: identify both confirmation footer presence and
// specific "press number to confirm" variant in one iteration (Issue #616).
let hasConfirmationFooter = false;
let hasNumberFooter = false;
for (const rawLine of scanWindow) {
const trimmed = rawLine.trim();
if (!hasConfirmationFooter && CONFIRMATION_FOOTER_PATTERN.test(trimmed)) {
hasConfirmationFooter = true;
if (NUMBER_FOOTER_PATTERN.test(trimmed)) {
hasNumberFooter = true;
}
break; // Both patterns are subsets of CONFIRMATION_FOOTER; one match suffices
}
}

// ==========================================================================
// Pass 1: Check for ❯ indicator existence in scan window
Expand Down Expand Up @@ -709,5 +737,10 @@ export function detectMultipleChoicePrompt(
const question = extractQuestionText(lines, questionEndIndex);
const instructionText = extractInstructionText(lines, questionEndIndex, effectiveEnd);

return buildMultipleChoiceResult(question, collectedOptions, instructionText, output, truncateRawContentFn);
// Issue #616: Determine submitMode from confirmation footer (detected in single-pass above)
const submitMode: SubmitMode | undefined = hasConfirmationFooter
? (hasNumberFooter ? 'answer_only' : 'answer_then_enter')
: undefined;

return buildMultipleChoiceResult(question, collectedOptions, instructionText, output, truncateRawContentFn, submitMode);
}
44 changes: 39 additions & 5 deletions src/lib/prompt-answer-sender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,39 @@

import { sendKeys, sendSpecialKeys } from './tmux/tmux';
import type { CLIToolType } from './cli-tools/types';
import type { PromptData, PromptType } from '@/types/models';
import type { PromptData, PromptType, SubmitMode } from '@/types/models';
import { isValidSubmitMode } from '@/types/models';
import { invalidateCache } from './tmux/tmux-capture-cache';

/** Regex pattern to detect checkbox-style multi-select options */
const CHECKBOX_OPTION_PATTERN = /^\[[ x]\] /;

/**
* Resolve the effective SubmitMode from promptData, fallback, and default.
* Resolution order: promptData.submitMode -> fallbackSubmitMode -> 'answer_then_enter'.
* Invalid values are normalized to 'answer_then_enter' via allowlist validation.
*
* @returns The resolved SubmitMode, guaranteed to be a valid value.
*/
function resolveSubmitMode(params: SendPromptAnswerParams): SubmitMode {
const fromPromptData = params.promptData?.type === 'multiple_choice'
? params.promptData.submitMode
: undefined;
const raw = fromPromptData ?? params.fallbackSubmitMode ?? 'answer_then_enter';
return isValidSubmitMode(raw) ? raw : 'answer_then_enter';
}

/**
* Determine whether the Enter key should be suppressed after sending the answer text.
* answer_only mode applies only when the prompt is multiple_choice and the answer is numeric.
*/
function shouldSuppressEnter(params: SendPromptAnswerParams, submitMode: SubmitMode): boolean {
if (submitMode !== 'answer_only') return false;
const isMultipleChoice = params.promptData?.type === 'multiple_choice'
|| params.fallbackPromptType === 'multiple_choice';
return isMultipleChoice && /^\d+$/.test(params.answer);
}

/**
* Build navigation key array for cursor movement.
* @param offset - positive = Down, negative = Up
Expand All @@ -33,6 +60,8 @@ export interface SendPromptAnswerParams {
fallbackPromptType?: PromptType;
/** Fallback default option number from client (only available in route.ts path) */
fallbackDefaultOptionNumber?: number;
/** Fallback submit mode from client (Issue #616) */
fallbackSubmitMode?: SubmitMode;
}

/**
Expand Down Expand Up @@ -100,11 +129,16 @@ export async function sendPromptAnswer(params: SendPromptAnswerParams): Promise<
// Standard CLI prompt: send text + Enter (y/n, Approve?, etc.)
await sendKeys(sessionName, answer, false);

// Wait a moment for the input to be processed
await new Promise(resolve => setTimeout(resolve, 100));
// Issue #616: Resolve submitMode and determine whether to suppress Enter
const resolvedSubmitMode = resolveSubmitMode(params);

// Send Enter
await sendKeys(sessionName, '', true);
if (!shouldSuppressEnter(params, resolvedSubmitMode)) {
// Wait a moment for the input to be processed
await new Promise(resolve => setTimeout(resolve, 100));

// Send Enter
await sendKeys(sessionName, '', true);
}
}

// Issue #405: Invalidate cache after sending prompt answer
Expand Down
8 changes: 7 additions & 1 deletion src/lib/prompt-response-body-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* re-verification fails. This module eliminates that duplication.
*/

import type { PromptData } from '@/types/models';
import type { PromptData, SubmitMode } from '@/types/models';

/**
* Shape of the prompt-response API request body.
Expand All @@ -20,6 +20,8 @@ export interface PromptResponseBody {
cliTool: string;
promptType?: string;
defaultOptionNumber?: number;
/** Issue #616: Submit mode for multiple choice prompts */
submitMode?: SubmitMode;
}

/**
Expand Down Expand Up @@ -49,6 +51,10 @@ export function buildPromptResponseBody(
if (defaultOption) {
body.defaultOptionNumber = defaultOption.number;
}
// Issue #616: Include submitMode if present
if (promptData.submitMode) {
body.submitMode = promptData.submitMode;
}
}
}

Expand Down
20 changes: 20 additions & 0 deletions src/types/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,13 +167,33 @@ export interface MultipleChoiceOption {
requiresTextInput?: boolean;
}

/**
* Submit mode for multiple choice prompts.
* - 'answer_only': Send only the answer number (no Enter key). Used by Codex CLI "Press number to confirm" UI.
* - 'answer_then_enter': Send the answer number followed by Enter key (default behavior).
* Issue #616: Codex Reasoning Level selection requires answer_only mode.
*/
export type SubmitMode = 'answer_only' | 'answer_then_enter';

/**
* Type guard for SubmitMode values.
* Validates that a string is a valid SubmitMode ('answer_only' or 'answer_then_enter').
* Used for allowlist validation of untrusted input from API requests.
* Issue #616.
*/
export function isValidSubmitMode(value: unknown): value is SubmitMode {
return value === 'answer_only' || value === 'answer_then_enter';
}

/**
* Multiple choice prompt data
*/
export interface MultipleChoicePromptData extends BasePromptData {
type: 'multiple_choice';
/** Available options */
options: MultipleChoiceOption[];
/** How to submit the answer: 'answer_only' (no Enter) or 'answer_then_enter' (default). Issue #616. */
submitMode?: SubmitMode;
}

/**
Expand Down
Loading
Loading