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
14 changes: 14 additions & 0 deletions src/lib/detection/cli-patterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,20 @@ export const CODEX_PROMPT_PATTERN = /^›\s*/m;
*/
export const CODEX_SEPARATOR_PATTERN = /^─.*Worked for.*─+$/m;

/**
* Codex CLI selection list footer pattern (Issue #619)
* Detects Codex CLI's interactive selection prompts that use arrow key
* navigation (e.g., /model command's model selection step).
*
* Matches: "press enter to confirm or esc to cancel"
* Does NOT match: "press number to confirm" (handled by detectMultipleChoicePrompt)
*
* The distinction is important: "press enter to confirm" indicates an arrow-key
* selection list (NavigationButtons), while "press number to confirm" indicates
* a numbered prompt (PromptPanel with buttons).
*/
export const CODEX_SELECTION_LIST_PATTERN = /press\s+enter\s+to\s+confirm/i;

/**
* Pasted text pattern
*
Expand Down
23 changes: 22 additions & 1 deletion src/lib/detection/status-detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
* coupling via a minimal DTO/projection type.
*/

import { stripAnsi, stripBoxDrawing, detectThinking, getCliToolPatterns, buildDetectPromptOptions, OPENCODE_RESPONSE_COMPLETE, OPENCODE_PROCESSING_INDICATOR, OPENCODE_SELECTION_LIST_PATTERN, CLAUDE_SELECTION_LIST_FOOTER, COPILOT_SELECTION_LIST_PATTERN, CODEX_PROMPT_PATTERN } from './cli-patterns';
import { stripAnsi, stripBoxDrawing, detectThinking, getCliToolPatterns, buildDetectPromptOptions, OPENCODE_RESPONSE_COMPLETE, OPENCODE_PROCESSING_INDICATOR, OPENCODE_SELECTION_LIST_PATTERN, CLAUDE_SELECTION_LIST_FOOTER, COPILOT_SELECTION_LIST_PATTERN, CODEX_PROMPT_PATTERN, CODEX_SELECTION_LIST_PATTERN } from './cli-patterns';
import { detectPrompt } from './prompt-detector';
import type { PromptDetectionResult } from './prompt-detector';
import type { CLIToolType } from '@/lib/cli-tools/types';
Expand Down Expand Up @@ -103,6 +103,7 @@ export const STATUS_REASON = {
OPENCODE_SELECTION_LIST: 'opencode_selection_list',
CLAUDE_SELECTION_LIST: 'claude_selection_list',
COPILOT_SELECTION_LIST: 'copilot_selection_list',
CODEX_SELECTION_LIST: 'codex_selection_list',
OPENCODE_RESPONSE_COMPLETE: 'opencode_response_complete',
INPUT_PROMPT: 'input_prompt',
NO_RECENT_OUTPUT: 'no_recent_output',
Expand All @@ -120,6 +121,7 @@ export const SELECTION_LIST_REASONS = new Set<string>([
STATUS_REASON.OPENCODE_SELECTION_LIST,
STATUS_REASON.CLAUDE_SELECTION_LIST,
STATUS_REASON.COPILOT_SELECTION_LIST,
STATUS_REASON.CODEX_SELECTION_LIST,
]);

/**
Expand Down Expand Up @@ -409,6 +411,25 @@ export function detectSessionStatus(
};
}

// A2. Check content area for selection list (Issue #619: Codex /model selection list)
// Codex /model Step 1 shows arrow-key selection list with
// "press enter to confirm or esc to cancel" footer.
// Must be checked AFTER thinking (A) but BEFORE idle prompt (B).
// "press number to confirm" (Step 2) is NOT matched — that's handled
// by detectMultipleChoicePrompt at priority 1.
const codexFullContentText = contentLines
.slice(0, lastContentIdx + 1)
.join('\n');
if (CODEX_SELECTION_LIST_PATTERN.test(codexFullContentText)) {
return {
status: 'waiting',
confidence: 'high',
reason: STATUS_REASON.CODEX_SELECTION_LIST,
hasActivePrompt: false,
promptDetection,
};
}

// B. Check if the last content line is the idle › prompt.
// The last non-empty line above the status bar is the current active line.
// When Codex is idle, this is the › prompt (with optional suggestion text).
Expand Down
101 changes: 98 additions & 3 deletions tests/unit/status-detector-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,14 +156,15 @@ describe('SELECTION_LIST_REASONS Set', () => {
expect(SELECTION_LIST_REASONS).toBeInstanceOf(Set);
});

it('should contain all three selection list reasons', () => {
it('should contain all four selection list reasons', () => {
expect(SELECTION_LIST_REASONS.has(STATUS_REASON.OPENCODE_SELECTION_LIST)).toBe(true);
expect(SELECTION_LIST_REASONS.has(STATUS_REASON.CLAUDE_SELECTION_LIST)).toBe(true);
expect(SELECTION_LIST_REASONS.has(STATUS_REASON.COPILOT_SELECTION_LIST)).toBe(true);
expect(SELECTION_LIST_REASONS.has(STATUS_REASON.CODEX_SELECTION_LIST)).toBe(true);
});

it('should have exactly 3 entries', () => {
expect(SELECTION_LIST_REASONS.size).toBe(3);
it('should have exactly 4 entries', () => {
expect(SELECTION_LIST_REASONS.size).toBe(4);
});

it('should not contain unrelated reasons', () => {
Expand Down Expand Up @@ -274,3 +275,97 @@ describe('detectSessionStatus - Copilot selection_list detection', () => {
expect(result.reason).not.toBe(STATUS_REASON.COPILOT_SELECTION_LIST);
});
});

// Helper: Build Codex TUI output with content area + status bar
// Codex TUI layout: content area (top) | empty padding | status bar (bottom)
function buildCodexOutput(contentLines: string[]): string {
const statusBar = ' o4-mini 50% left · /path/to/project';
const padding = Array(10).fill('');
return [...contentLines, ...padding, statusBar].join('\n');
}

describe('STATUS_REASON - CODEX_SELECTION_LIST', () => {
it('should export CODEX_SELECTION_LIST constant', () => {
expect(STATUS_REASON.CODEX_SELECTION_LIST).toBe('codex_selection_list');
});
});

describe('detectSessionStatus - Codex selection_list detection (Issue #619)', () => {
it('should detect Codex /model Step 1 selection list and return waiting status', () => {
const output = buildCodexOutput([
'Select a model',
'',
' ❯ o4-mini (current)',
' o3',
' o3-pro',
' codex-mini-latest',
'',
'press enter to confirm or esc to cancel',
]);

const result = detectSessionStatus(output, 'codex');
expect(result.status).toBe('waiting');
expect(result.confidence).toBe('high');
expect(result.reason).toBe(STATUS_REASON.CODEX_SELECTION_LIST);
expect(result.hasActivePrompt).toBe(false);
});

it('should prioritize thinking (A) over selection list', () => {
// If thinking indicator is present alongside "press enter to confirm",
// thinking should take priority (step A before selection list step)
const output = buildCodexOutput([
'Select a model',
' ❯ o4-mini (current)',
'press enter to confirm or esc to cancel',
'• Planning something', // thinking indicator in last lines
]);

const result = detectSessionStatus(output, 'codex');
expect(result.status).toBe('running');
expect(result.reason).toBe('thinking_indicator');
});

it('should detect numbered prompt (Step 2) via priority 1 detectPrompt, not selection list', () => {
// Codex /model Step 2 uses "press number to confirm" (NOT "press enter to confirm")
// This is handled by detectMultipleChoicePrompt (priority 1) as a multiple_choice prompt
const output = buildCodexOutput([
'Reasoning level',
'',
' 1. low',
' 2. medium (default)',
' 3. high',
'',
'press number to confirm or esc to cancel',
]);

const result = detectSessionStatus(output, 'codex');
// Should be detected as prompt_detected (priority 1), not codex_selection_list
expect(result.reason).not.toBe(STATUS_REASON.CODEX_SELECTION_LIST);
expect(result.status).toBe('waiting');
expect(result.reason).toBe('prompt_detected');
});

it('should NOT detect selection list for normal Codex response', () => {
const output = buildCodexOutput([
'Here is the implementation:',
'```typescript',
'function hello() { return "world"; }',
'```',
'• Ran command: npm test',
]);

const result = detectSessionStatus(output, 'codex');
expect(result.reason).not.toBe(STATUS_REASON.CODEX_SELECTION_LIST);
});

it('should NOT trigger codex_selection_list for non-codex tools', () => {
// Even if output contains "press enter to confirm", other tools should not match
const output = [
'press enter to confirm or esc to cancel',
'> ',
].join('\n');

const result = detectSessionStatus(output, 'claude');
expect(result.reason).not.toBe(STATUS_REASON.CODEX_SELECTION_LIST);
});
});
Loading