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
16 changes: 1 addition & 15 deletions src/cli/tui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1656,21 +1656,7 @@ export const runTuiRepl = async (options: TuiRunOptions): Promise<void> => {
)?.usage ?? input
: input;
const normalizedPrompt = resolvedPrompt.trim();
const shouldRequestApproval =
embeddedPaneMode &&
options.executionMode === "real" &&
normalizedPrompt.length > 0 &&
!normalizedPrompt.startsWith("/");

if (shouldRequestApproval) {
hasExecuted = true;
registerRunBlock(resolvedPrompt, "pending-approval");
pendingApprovalPrompt = resolvedPrompt;
skipApprovalLineFeed = char === "\r";
render();
} else {
executePrompt(resolvedPrompt);
}
executePrompt(resolvedPrompt);
// P3-3: capture in history + persist async (non-fatal on failure).
inputHistory.push(normalizedPrompt);
void saveHistoryToDisk(historyPath, inputHistory.toArray()).catch(
Expand Down
64 changes: 62 additions & 2 deletions src/cli/tui/panels/embedded-terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,22 @@ const APPROVAL_PROMPT_HINT_PATTERNS = [
/\bcontinue\b/i,
] as const;

// Stricter y/n patterns used for multi-row approval detection to reduce false positives.
const APPROVAL_STRICT_HINT_PATTERNS = [
/\by\/n\b/i,
/\byes\/no\b/i,
/\[y\/n\]/i,
/\[y\/N\]/i,
] as const;

// Matches lines that consist only of box-drawing / border characters + whitespace.
// These decorative rows (e.g. "╰──────────╯") appear at the edges of codex approval
// dialogs and should be skipped rather than treated as meaningful content.
const DECORATIVE_LINE_RE = /^[\s╭╮╰╯─│┌┐└┘├┤┬┴┼╔╗╚╝═║╠╣╦╩╬▲▼◀▶•·\-━┅┄━─]+$/u;

const isDecorativeLine = (text: string): boolean =>
text.length > 0 && DECORATIVE_LINE_RE.test(text);

interface RenderedRowInfo {
cells: TerminalCell[];
plainText: string;
Expand Down Expand Up @@ -1310,7 +1326,21 @@ export class EmbeddedTerminalPane {
}

const { rows } = this.ensureRenderCache(Math.max(1, maxWidth));
for (let index = rows.length - 1; index >= 0; index -= 1) {

// Codex may render approval dialogs across multiple rows (e.g. a box UI with a border row
// at the bottom). Scan up to 8 rows from the bottom:
// • Decorative border rows (╰──╯) are skipped so they don't break the scan.
// • Single-line approval is detected immediately and returned.
// • Verb and strict-hint are accumulated across rows for multi-row dialogs.
// • The first non-decorative row that has neither a verb nor a strict hint stops
// the scan — it means meaningful non-approval output appeared (approval resolved).
const APPROVAL_SCAN_WINDOW = 8;
const scanStart = Math.max(0, rows.length - APPROVAL_SCAN_WINDOW);

let approvalVerbLine: string | undefined;
let hasStrictHint = false;

for (let index = rows.length - 1; index >= scanStart; index -= 1) {
const row = rows[index];
if (row === undefined || row.plainText.length === 0) {
continue;
Expand All @@ -1320,6 +1350,12 @@ export class EmbeddedTerminalPane {
continue;
}

// Purely decorative border lines (e.g. "╰──────╯") do not break the scan
if (isDecorativeLine(row.plainText)) {
continue;
}

// Single-line approval (fastest path)
if (isApprovalPromptLine(row.plainText)) {
return {
kind: "approval",
Expand All @@ -1328,7 +1364,31 @@ export class EmbeddedTerminalPane {
};
}

return null;
// Accumulate verb / strict hint for multi-row dialog detection
const lineHasVerb = APPROVAL_PROMPT_PATTERNS.some((p) => p.test(row.plainText));
const lineHasStrictHint = APPROVAL_STRICT_HINT_PATTERNS.some((p) => p.test(row.plainText));

if (lineHasVerb && approvalVerbLine === undefined) {
approvalVerbLine = row.plainText;
}
if (lineHasStrictHint) {
hasStrictHint = true;
}

// Stop at the first meaningful row that is unrelated to approval —
// subsequent output means the approval was already resolved.
if (!lineHasVerb && !lineHasStrictHint) {
break;
}
}

// Multi-row approval: verb on one row, [y/n] hint on another
if (approvalVerbLine !== undefined && hasStrictHint) {
return {
kind: "approval",
label: "Codex 승인 대기",
detail: approvalVerbLine,
};
}

return null;
Expand Down
5 changes: 4 additions & 1 deletion src/core/pipeline/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1401,7 +1401,10 @@ export const orchestratePipeline = async (
// 다른 모드: 번역된 원본 명령 + RAG 컨텍스트 + 태스크 정보 포함.
let prompt: string;
if (request.presentationMode === "embedded-pane") {
prompt = compiledPrompt.compressed_prompt;
// 한국어 입력이면 응답 언어 지시를 앞에 붙여 codex가 한국어로 응답하도록 한다.
prompt = responseLanguageInstruction
? `${responseLanguageInstruction}${compiledPrompt.compressed_prompt}`
: compiledPrompt.compressed_prompt;
} else {
const promptParts: string[] = [];
if (responseLanguageInstruction) promptParts.push(responseLanguageInstruction.trimEnd());
Expand Down
29 changes: 29 additions & 0 deletions tests/ts/unit/cli/tui/panels/embedded-terminal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,35 @@ describe("EmbeddedTerminalPane", () => {
expect(pane.getStatusBannerLine(80, { now: Date.now() })).toBeNull();
});

// T8b: multi-row approval dialog (verb on one row, [y/N] hint on another)
it("getStatusBannerLine detects multi-row approval when verb and hint are on separate rows", () => {
// Simulate codex box-style approval dialog
pane.addEvent({ type: "chunk", timestamp: Date.now(), stream: "stdout", data: "╭─ Execute Command? ───────────────────╮\n" });
pane.addEvent({ type: "chunk", timestamp: Date.now(), stream: "stdout", data: "│ curl -I https://api.github.com │\n" });
pane.addEvent({ type: "chunk", timestamp: Date.now(), stream: "stdout", data: "│ Allow? [y/N] │\n" });
pane.addEvent({ type: "chunk", timestamp: Date.now(), stream: "stdout", data: "╰──────────────────────────────────────╯\n" });

const banner = pane.getStatusBannerLine(80, { now: Date.now() });
expect(banner).not.toBeNull();
expect(banner?.severity).toBe("warn");
expect(banner?.text).toContain("승인 대기");
});

it("getStatusBannerLine clears multi-row approval when subsequent non-approval output appears", () => {
pane.addEvent({ type: "chunk", timestamp: Date.now(), stream: "stdout", data: "│ Allow? [y/N] │\n" });
pane.addEvent({ type: "chunk", timestamp: Date.now(), stream: "stdout", data: "╰──────────────────────────────────────╯\n" });
expect(pane.getStatusBannerLine(80, { now: Date.now() })).not.toBeNull();

// Non-approval output appears — approval resolved
pane.addEvent({ type: "chunk", timestamp: Date.now(), stream: "stdout", data: "HTTP/2 200\n" });
expect(pane.getStatusBannerLine(80, { now: Date.now() })).toBeNull();
});

it("getStatusBannerLine does not detect approval from decorative-only border lines", () => {
pane.addEvent({ type: "chunk", timestamp: Date.now(), stream: "stdout", data: "╰──────────────────────────────────────╯\n" });
expect(pane.getStatusBannerLine(80, { now: Date.now() })).toBeNull();
});

// T9: getFocusFooterLine — correct hint per focus state
it("getFocusFooterLine includes Ctrl+T hint for detoks-input focus", () => {
pane.addEvent({ type: "chunk", timestamp: Date.now(), stream: "stdout", data: "output\n" });
Expand Down
Loading