@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
241 lines (240 loc) • 13.5 kB
JavaScript
import { BridgeError } from "./protocol.js";
const TAB_BAR = /^\s*(?:←\s+)?((?:[☐☒]\s+\S[^☐☒✔→]*?\s*)+)(?:✔\s+Submit\s*)?(?:→\s*)?$/;
const ROW = /^\s*(❯)?\s*(\d)\.\s+(?:\[([ ✔])\]\s+)?(.*?)\s*$/;
const NEXT = /^\s*(❯)?\s+(?:Next|Submit)\s*$/;
const normalize = (text) => text.replace(/\s+/g, " ").trim();
/** Claude cuts a question at 2000 characters with "…" while the phone may send
* more, so the pane's title matches when it is the question's own head. */
export const sameTitle = (shown, asked) => {
const want = normalize(asked);
if (shown === want)
return true;
return shown.endsWith("…") && shown.length > 200 && want.startsWith(shown.slice(0, -1).trimEnd());
};
/** Claude collapses runs of whitespace in a label, and the Hook reads only the
* first line of one it wraps, so a long label matches by its head. */
export const sameLabel = (shown, asked) => {
const want = normalize(asked);
return shown === want || (shown.length >= 30 && want.startsWith(shown));
};
/** Claude Code (2.1.284) draws a question that has a line break or is wider
* than 80 columns inside a dimmed left border, "│ " before each line, on its
* tab and in the review. The border is not part of the question. */
const GUTTER = /^(\s*)│(?: |$)/;
/** The question dialog the pane is drawing now, or undefined when the last
* lines are not one: the last tab bar anchors it, and an active dialog ends
* with its "Esc to cancel" footer or the review's Submit/Cancel rows. */
export function claudeQuestionDialog(text) {
const lines = text.split(/\r?\n/);
let bar = -1;
for (let index = lines.length - 1; index >= 0; index--)
if (TAB_BAR.test(lines[index]) && /[☐☒]/.test(lines[index])) {
bar = index;
break;
}
if (bar < 0)
return undefined;
const tabs = [...lines[bar].matchAll(/[☐☒]\s+([^☐☒✔→]+?)(?=\s{2,}|\s*[☐☒✔→]|\s*$)/g)].map(match => match[1].trim());
const body = lines.slice(bar + 1);
const content = body.map(line => line.replace(GUTTER, "$1").trim()).filter(line => line && !/^─+$/.test(line));
if (content[0] === "Review your answers") {
if (!body.some(line => /^\s*❯?\s*1\.\s+Submit answers\s*$/.test(line)))
return undefined;
const answers = [];
for (const line of content.slice(1)) {
if (/^Ready to submit/.test(line))
break;
const question = /^●\s+(.+)$/.exec(line), answer = /^→\s+(.+)$/.exec(line), last = answers[answers.length - 1];
if (question)
answers.push({ question: question[1], answer: "" });
else if (answer && last)
last.answer = answer[1];
// A long question or answer wraps onto the next line.
else if (last) {
if (last.answer)
last.answer += " " + line;
else
last.question += " " + line;
}
}
return { kind: "review", tabs, answers };
}
if (!content.some(line => /Esc to cancel/.test(line)))
return undefined;
const first = body.findIndex(line => ROW.test(line));
if (first < 0)
return undefined;
const title = normalize(body.slice(0, first).map(line => line.replace(GUTTER, "$1")).filter(line => line.trim()).join(" "));
const rows = [];
let cursor, chat = false;
for (const line of body.slice(first)) {
const next = NEXT.exec(line);
if (next) {
if (next[1])
cursor = "next";
continue;
}
const match = ROW.exec(line);
if (!match)
continue;
const number = Number(match[2]);
if (match[1])
cursor = number;
if (/^Chat about this$/.test(match[4])) {
chat = true;
continue;
}
const answered = !match[3] && / ✔$/.test(match[4]);
rows.push({ number, label: answered ? match[4].slice(0, -2).trim() : match[4], answered, cursor: !!match[1],
...(match[3] ? { checked: match[3] === "✔" } : {}) });
}
if (!title || !chat || rows.length < 2)
return undefined;
// The last row above "Chat about this" is the typed answer ("Type
// something." until something is typed there).
const other = rows.pop();
return { kind: "question", tabs, title, multiSelect: rows.some(row => row.checked !== undefined), options: rows, other, ...(cursor !== undefined ? { cursor } : {}) };
}
/** A 409 that says whether any key reached the terminal: `question-not-shown`
* (nothing typed, retrying is safe) or `question-unconfirmed` (keys were sent,
* so look at the terminal before answering again). */
const refusal = (message, sent) => sent
? new BridgeError(409, `${message} Some keys were already sent, so check the terminal before answering again, or cancel the question.`, { code: "question-unconfirmed" })
: new BridgeError(409, `${message} Nothing was sent; check the question and answer again, or cancel it.`, { code: "question-not-shown" });
/** A typed answer, one key per character; herdr names the space bar. */
const typedKeys = (text) => [...text].map(character => character === " " ? "space" : character);
/** Answer `answers[i]` for question `from + i`, then submit the set when
* `submit` is true. Each step is checked against a fresh read of the pane. */
export async function answerClaudeQuestionDialog(io, questions, answers, options = {}) {
const from = options.from ?? 0, submit = options.submit ?? true;
let sent = false;
const changed = (message) => refusal(message, sent);
const realKeys = io.keys.bind(io);
io = { ...io, keys: async (keys) => { sent = true; await realKeys(keys); } };
const sleep = io.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
const matches = (screen, index) => screen?.kind === "question" && sameTitle(screen.title, questions[index].question);
// Claude redraws a moment after a key; poll until the expected screen shows.
const settle = async (check, ms = 2_000) => {
for (let waited = 0;; waited += 120) {
const value = check(claudeQuestionDialog(await io.read()));
if (value !== undefined || waited >= ms)
return value;
await sleep(120);
}
};
// After a key that answers, Claude may take seconds to redraw on a busy
// computer; the answer was taken, so wait for the redraw instead of
// calling it refused (the phone would say it was not confirmed).
const ANSWERED_MS = 8_000;
const initial = await settle(screen => screen);
if (!initial)
throw changed("The terminal is not showing Claude's question.");
// A set of several questions draws one tab per question; a single question
// draws its own header. The tabs must be this set's, in this order.
if (initial.tabs.length !== questions.length)
throw changed("The terminal is asking a different set of questions.");
const reach = async (index) => {
const screen = await settle(value => value);
if (screen?.kind === "question" && sameTitle(screen.title, questions[index].question))
return screen;
const at = screen?.kind === "review" ? questions.length
: screen?.kind === "question" ? questions.findIndex(question => sameTitle(screen.title, question.question)) : -1;
if (at < 0)
throw changed("The terminal is showing a different question.");
// Arrow keys would edit a typed answer; step off that row first.
if (screen?.kind === "question" && screen.other && screen.cursor === screen.other.number)
await io.keys(["up"]);
await io.keys(Array(Math.abs(index - at)).fill(index > at ? "right" : "left"));
const moved = await settle(value => matches(value, index) ? value : undefined);
if (!moved)
throw changed("Could not move the terminal to the question being answered.");
return moved;
};
const leaves = (index) => (value) => value === undefined ? "gone" : !matches(value, index) ? "moved" : undefined;
for (const [offset, answer] of answers.entries()) {
const index = from + offset, question = questions[index];
if (!question)
throw new BridgeError(400, "The answer names a question that was not asked.");
const typed = answer.text?.trim() ?? "";
let screen = await reach(index);
if (screen.multiSelect !== !!question.multiSelect || screen.options.length !== question.options.length
|| screen.options.some((row, position) => !sameLabel(row.label, question.options[position].label))) {
throw changed("The terminal's options differ from the question being answered.");
}
if (!screen.multiSelect) {
if (typed) {
const other = screen.other;
await io.keys([String(other.number)]);
const focused = await settle(value => matches(value, index) && value.cursor === other.number ? value : undefined);
if (!focused)
throw changed("Could not open the typed answer.");
await io.keys(typedKeys(typed));
const shown = await settle(value => matches(value, index) && value.other && normalize(value.other.label).startsWith(normalize(typed).slice(0, 20)) ? value : undefined);
if (!shown)
throw changed("The typed answer did not appear in the terminal.");
await io.keys(["enter"]);
}
else {
await io.keys([String(screen.options[answer.options[0]].number)]);
}
if (!await settle(leaves(index), ANSWERED_MS))
throw changed("The terminal did not take the answer.");
continue;
}
// Multi-select: toggle only the boxes that differ, one key per write
// (Ink reads "13" in one write as a single input and toggles nothing),
// and check each box before the next.
const rows = [...screen.options, ...(screen.other ? [screen.other] : [])];
for (const [position, row] of rows.entries()) {
const wanted = position < screen.options.length ? answer.options.includes(position) : !!row.checked && !!typed;
if (wanted === !!row.checked)
continue;
const box = (value) => matches(value, index)
? !!(position < value.options.length ? value.options[position] : value.other)?.checked : undefined;
// A key that lands while the tab is still drawing can be dropped; the
// box itself says whether one more press is needed.
for (let attempt = 0; attempt < 2 && box(screen) !== wanted; attempt++) {
await io.keys([String(row.number)]);
screen = await settle(value => box(value) === wanted ? value : undefined, 1_000) ?? screen;
}
if (box(screen) !== wanted)
throw changed("The terminal's checkboxes did not match the answer.");
}
if (typed) {
const other = screen.other, cursor = typeof screen.cursor === "number" ? screen.cursor : 1;
if (cursor !== other.number)
await io.keys(Array(Math.abs(other.number - cursor)).fill(other.number > cursor ? "down" : "up"));
if (!await settle(value => matches(value, index) && value.cursor === other.number ? value : undefined))
throw changed("Could not reach the typed answer row.");
await io.keys(typedKeys(typed));
const shown = await settle(value => matches(value, index) && value.other?.checked && normalize(value.other.label).startsWith(normalize(typed).slice(0, 20)) ? value : undefined);
if (!shown)
throw changed("The typed answer did not appear in the terminal.");
// Tab moves from the typed row to Next; Enter there leaves the question.
await io.keys(["tab"]);
if (!await settle(value => matches(value, index) && value.cursor === "next" ? value : undefined))
throw changed("Could not reach the question's Next row.");
await io.keys(["enter"]);
}
else {
// Tab from the typed row would stop on Next instead of leaving.
await io.keys(screen.other && screen.cursor === screen.other.number ? ["up", "tab"] : ["tab"]);
}
if (!await settle(leaves(index), ANSWERED_MS))
throw changed("The terminal did not move past the question.");
}
if (!submit)
return;
const review = await settle(value => value?.kind === "review" ? value : value === undefined ? "gone" : undefined, ANSWERED_MS);
// A lone single-select question submits on its digit.
if (review === "gone")
return;
if (!review)
throw changed("The terminal did not reach the review of the answers.");
if (review.answers.length !== questions.length || review.answers.some((row, position) => !sameTitle(normalize(row.question), questions[position].question) || !row.answer)) {
throw changed("Not every question has an answer in the terminal.");
}
await io.keys(["1"]);
if (await settle(value => value === undefined ? "gone" : undefined, ANSWERED_MS) !== "gone")
throw changed("The terminal did not submit the answers.");
}