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
2 changes: 1 addition & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 실측). 흐리지 않은 글이 한 글자라도 있으면 사람이 친 글로 보고 보류한다.
커서가 프롬프트 줄 밖이거나 이 모양과 다르면 보류한다. 프롬프트가 없거나 미제출 텍스트가 있거나 빈 입력창을 확인하지
Expand Down
16 changes: 14 additions & 2 deletions src/floor-tmux.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

// 화면에 보이는 입력창이 비었는지 확인한다. 카드 전송과 감시기 알림이 같은 기준을 쓴다.
Expand All @@ -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}: 미제출 입력 또는 빈 입력창으로 확인할 수 없는 화면`);
}

Expand Down
15 changes: 15 additions & 0 deletions tests/ai-identity-send-boundaries.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -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});
Expand Down
7 changes: 7 additions & 0 deletions tests/herdr-input-width.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Loading