diff --git a/docs/design.md b/docs/design.md index 740938a1..423fae4d 100644 --- a/docs/design.md +++ b/docs/design.md @@ -85,7 +85,7 @@ tmux는 (a)(b)(c)를 충족했고 (d)는 Mac에서 통과했다(서버 왕복은 입력 줄은 커서가 있는 프롬프트 줄(`›`·`>`·`❯`·devin `❭`) 하나다. 화면 전체의 프롬프트 모양 줄을 후보로 세지 않는다 — codex 배너(`│ >_`)·과거 프롬프트 때문에 실제 대기 화면을 거부한 사고(2026-09-23). 그 위쪽 가장 가까운 글 줄도 프롬프트 모양이면 여러 줄 입력과 구별할 수 없어 보류한다. - 입력 상자 윗줄은 0열에서 pane 폭을 채우는 가로줄(표지 허용)이며, 입력 상자(devin·claude)는 바로 아래 닫는 가로줄 밑을 상태줄로 보고, 상자가 없으면(codex) 빈 줄 뒤 상태줄 한 줄만 허용한다. + 입력 상자 윗줄은 0열에서 pane 폭을 채우는 가로줄(표지 허용)이며, 입력 상자(devin·claude)는 바로 아래 닫는 가로줄 밑을 상태줄로 보고, 상자가 없으면(codex) 빈 줄 뒤 상태줄 한 줄만 허용한다. 단 상태줄에 ` · `가 있고 바로 다음 줄이 단축키 안내(`? for shortcuts` 포함, codex 0.163.0 두 줄 바닥글)이면 그 한 줄까지 허용한다. 프롬프트 뒤 글은 실제 화면에서 확인한 빈 입력 자리표시자만 빈 입력으로 본다(작업 중 자리표시자는 제외). 입력칸 글이 모두 흐린 글씨(SGR 2, `capture-pane -e`)면 실행기가 띄운 예시·다음 입력 추천으로 보고 빈 입력으로 본다(claude-code의 `Try "…"`와 작업 뒤 추천, 2026-09-23 실측). 흐리지 않은 글이 한 글자라도 있으면 사람이 친 글로 보고 보류한다. 커서가 프롬프트 줄 밖이거나 이 모양과 다르면 보류한다. 프롬프트가 없거나 미제출 텍스트가 있거나 빈 입력창을 확인하지 diff --git a/src/floor-tmux.mjs b/src/floor-tmux.mjs index a64f795f..7d0029ee 100644 --- a/src/floor-tmux.mjs +++ b/src/floor-tmux.mjs @@ -442,7 +442,7 @@ export function sendTmuxEnter(session, {run = tmuxOut} = {}) { } // 카드 입력창 판별. 추측으로 넣지 않고 실제 화면에서 확인한 형태만 둔다(2026-09-23 실측). -// - 빈 입력 자리표시자: devin v3000.11.1, codex v0.155.0. 작업 중 자리표시자(Guide Devin…)는 빈 입력이 아니다. +// - 빈 입력 자리표시자: devin v3000.11.1, codex v0.155.0·v0.163.0. 작업 중 자리표시자(Guide Devin…)는 빈 입력이 아니다. // - 입력 상자 경계(devin·claude): 0열에서 pane 폭을 꽉 채우는 가로줄. 입력의 이어지는 줄은 들여쓰기되므로 // 사용자 글이 이 모양이 될 수 없다. 윗줄에는 devin처럼 `(bypass permissions on)` 표지가 섞인다. const PROMPT_LINE = /^\s*[│┃]?[ \t]*[›>❯❭](.*)$/u; @@ -473,6 +473,16 @@ function allDimAfterPrompt(raw = "") { } return seen; } +// codex-cli 0.163.0부터 빈 입력창 아래 바닥글이 두 줄이다(2026-10-17 격리 소켓 실측, 160x40): +// ` GPT-6-Astra high · ~/Dev/kadan · main · Context 0% used · weekly 36% left` +// ` ← for agents · ? for shortcuts ⚠ 1 warning · f2 to view` +// 둘째 줄은 단축키 안내다. 안정된 부분은 `? for shortcuts`이고 경고 조각은 경고가 있을 때만 붙는다. +// 글 모양으로만 판단한다. 안내 줄이 흐린 글씨인지는 받은 사본에 없어 기대지 않는다. +const CODEX_HINT_PART = /^(?:← for agents|\? for shortcuts|⚠ \d+ warnings?|f2 to view)$/u; +const isCodexHintLine = line => { + const parts = line.trim().split(/\s{2,}| · /u).map(part => part.trim()).filter(Boolean); + return parts.includes("? for shortcuts") && parts.every(part => CODEX_HINT_PART.test(part)); +}; const isInputRule = (line, width, labelled) => [...line].length === width && (labelled ? /^─.*─$/u : /^─+$/u).test(line); // 화면에 보이는 입력창이 비었는지 확인한다. 카드 전송과 감시기 알림이 같은 기준을 쓴다. @@ -499,10 +509,12 @@ export function checkEmptyInput(session, run, row, width, label) { const rest = prompt[1].trim(); if (rest && !EMPTY_INPUT_PLACEHOLDERS.has(rest) && !CLAUDE_PLACEHOLDER.test(rest) && !allDimAfterPrompt(raw[row])) deny("KADAN_PANE_INPUT_PENDING", `${label}: 미제출 입력이 있음`); // 상자형(devin·claude)은 바로 아래 닫는 줄 밑을 상태줄로 본다. 상자 없음(codex)은 빈 줄 뒤 상태줄 한 줄만 허용한다. + // 단 상태줄에 ` · `가 있고 바로 다음 줄이 단축키 안내(0.163.0)이면 그 한 줄까지 허용한다. 그 뒤는 빈 줄뿐이어야 한다. const below = lines.slice(row + 1); const footer = below.findIndex(line => line.trim()); + const tail = footer + (below[footer + 1] !== undefined && below[footer]?.includes(" · ") && isCodexHintLine(below[footer + 1]) ? 2 : 1); const closed = boxed ? isInputRule(below[0] ?? "", ruleWidth, false) - : footer < 0 || (footer > 0 && below.slice(footer + 1).every(line => !line.trim())); + : footer < 0 || (footer > 0 && below.slice(tail).every(line => !line.trim())); if (!closed) deny("KADAN_PANE_INPUT_PENDING", `${label}: 미제출 입력 또는 빈 입력창으로 확인할 수 없는 화면`); } diff --git a/tests/ai-identity-send-boundaries.test.mjs b/tests/ai-identity-send-boundaries.test.mjs index 0466826c..157e972f 100644 --- a/tests/ai-identity-send-boundaries.test.mjs +++ b/tests/ai-identity-send-boundaries.test.mjs @@ -44,6 +44,15 @@ const rule = (label='') => label ? '─'.repeat(40-label.length-3)+' '+label+' const realDevin = (input='❭ Ask Devin to build features, fix bugs, or work on your code') => ` Devin CLI\n\n${rule('(bypass permissions on)')}\n${input}\n${rule()}\nSWE-2 Max ctrl+v to paste image\n\n`; const realClaude = (input='❯ ') => `⏺ 이전 답변\n${rule()}\n${input}\n${rule()}\n ~/Dev/x[main]\n Opus 5.5 | ctx:26%\n ⏵⏵ bypass permissions on\n`; +// codex-cli 0.163.0 실측 화면(2026-10-17, 160x40): 프롬프트 36행, 37~39행이 빈 줄·상태줄·안내 줄. +const codex163Status = ' GPT-6-Astra high · ~/Dev/kadan · main · Context 0% used · weekly 36% left'; +const codex163Hint = (warn = true) => { + const left = ' ← for agents · ? for shortcuts'; + const right = '⚠ 1 warning · f2 to view'; + return warn ? left + ' '.repeat(160 - left.length - right.length) + right : left; +}; +const codex163 = (...below) => [...Array(36).fill('old output'), '› Ask Codex to do anything', ...below].join('\n'); + function fixture({state='11|0|0|0', width=40, screen='› ', ps='11 1 11 11 /opt/bin/codex --model test-model', afterLoad, psStatus=0} = {}) { const calls=[]; let loaded=false; return {calls, options:{cardIdentity:identity, pid:1, hrtime:()=>1n, @@ -89,6 +98,10 @@ for (const [name, config, code] of [ ['빈 줄 건너 위쪽 프롬프트에 미제출 입력', {state:'11|0|2|2',screen:'› USER_UNSUBMITTED\n\n› '}, 'KADAN_PANE_INPUT_UNKNOWN'], ['codex 상태줄 위 미제출 둘째 줄', {state:'11|0|1|2',screen:'\n› \n SECOND_PENDING\n\n gpt-6-astra medium · ~/Dev/x'}, 'KADAN_PANE_INPUT_PENDING'], ['codex 상태줄 뒤 추가 줄', {state:'11|0|1|2',screen:'\n› \n\n gpt-6-astra medium · ~/Dev/x\n EXTRA'}, 'KADAN_PANE_INPUT_PENDING'], + ['codex 0.163 프롬프트 바로 아래 글(빈 줄 없음)', {state:'11|0|36|2',width:160,screen:codex163(codex163Status, codex163Hint())}, 'KADAN_PANE_INPUT_PENDING'], + ['codex 0.163 상태줄 + 안내 문구가 아닌 보통 글', {state:'11|0|36|2',width:160,screen:codex163('', codex163Status, ' SECOND_PENDING')}, 'KADAN_PANE_INPUT_PENDING'], + ['codex 0.163 상태줄 + 안내 줄 + 셋째 글 줄', {state:'11|0|36|2',width:160,screen:codex163('', codex163Status, codex163Hint(), ' THIRD_PENDING')}, 'KADAN_PANE_INPUT_PENDING'], + ['codex 안내 문구지만 첫 줄에 가운뎃점이 없음', {state:'11|0|36|2',width:160,screen:codex163('', ' 사람이 쓴 초안', codex163Hint())}, 'KADAN_PANE_INPUT_PENDING'], ['프롬프트 없는 빈 화면', {screen:''}, 'KADAN_PANE_INPUT_UNKNOWN'], ['실제 모델 불일치', {ps:'11 1 11 11 codex --model other'}, 'KADAN_AI_PROCESS_UNVERIFIED'], ['실제 모델 미확인', {ps:'11 1 11 11 codex'}, 'KADAN_AI_PROCESS_UNVERIFIED'], @@ -121,6 +134,8 @@ test('실제 대기 화면(devin·claude·codex)의 빈 입력창에는 카드 ['새 claude 창의 흐린 예시 문구', '11|0|2|2', realClaude('❯ Try "write a test for watch-runner.mjs"')], ['작업 뒤 흐린 다음 입력 추천(실측 모양)', '11|0|2|2', realClaude('\x1b[39m❯\u00a0\x1b[2m3단계 진행해\x1b[0m')], ['codex 시작 화면(배너의 >_ 포함)', '11|0|4|2', '╭──────────╮\n│ >_ OpenAI Codex (v0.155.0) │\n╰──────────╯\n\n› Ask Codex to do anything\n\n gpt-6-astra medium · ~/Dev/x\n\n'], + ['codex 0.163 두 줄 바닥글(경고 있음)', '11|0|36|2', codex163('', codex163Status, codex163Hint())], + ['codex 0.163 두 줄 바닥글(경고 없음)', '11|0|36|2', codex163('', codex163Status, codex163Hint(false))], ['codex 하단 입력창(과거 프롬프트 포함)', '11|0|5|2', 'old\n› earlier message\n\n• answer\n\n› Ask Codex to do anything\n\n gpt-6-astra medium · ~/Dev/x'], ]) { const f=fixture({state, screen}); diff --git a/tests/herdr-input-width.test.mjs b/tests/herdr-input-width.test.mjs index 3df858a0..17fb1522 100644 --- a/tests/herdr-input-width.test.mjs +++ b/tests/herdr-input-width.test.mjs @@ -47,6 +47,13 @@ test("상자 없는 Codex 화면은 기존 판정을 유지한다", () => { assert.throws(() => check("\n› 남아 있는 글\n\nstatus", 1), { code: "KADAN_PANE_INPUT_PENDING" }); }); +test("codex 0.163 두 줄 바닥글은 폭 없는 경로에서도 빈 입력으로 본다", () => { + const status = " GPT-6-Astra high · ~/Dev/kadan · main · Context 0% used · weekly 36% left"; + const hint = " ← for agents · ? for shortcuts" + " ".repeat(100) + "⚠ 1 warning · f2 to view"; + assert.doesNotThrow(() => check(`\n› Ask Codex to do anything\n\n${status}\n${hint}`, 1)); + assert.throws(() => check(`\n› Ask Codex to do anything\n\n${status}\n${hint}\n THIRD`, 1), { code: "KADAN_PANE_INPUT_PENDING" }); +}); + test("폭 숫자를 주는 tmux 경로는 기존처럼 정확히 같은 길이만 통과한다", () => { const text = screen(...header, '❯ Try "x"', RULE, ...footer); assert.doesNotThrow(() => check(text, 3, 212));