From 0f4ec6be77ea3a6b88cc5c05703f885e8c8b2df2 Mon Sep 17 00:00:00 2001 From: Hako Date: Fri, 23 Oct 2026 21:57:45 +0900 Subject: [PATCH] refactor(server): move the live-screen tail checks into server/dialogs/live.ts Move the OmO task-row regexes, omoFooterIsActive, promptTailIsActive, the pi and Command Code footer helpers, hintAtEnd, parsePrompt and liveScreen verbatim. live.ts exports parsePrompt, promptTailIsActive and liveScreen; prompt.ts imports them. prompt.ts keeps every map and route; no re-exports were needed. Plan: .omo/plans/agent-modules-split.md P1b. --- server/dialogs/live.ts | 205 ++++++++++++++++++++++++++++++++++++++++ server/prompt.ts | 207 ++--------------------------------------- 2 files changed, 211 insertions(+), 201 deletions(-) create mode 100644 server/dialogs/live.ts diff --git a/server/dialogs/live.ts b/server/dialogs/live.ts new file mode 100644 index 0000000000..124118f614 --- /dev/null +++ b/server/dialogs/live.ts @@ -0,0 +1,205 @@ +import { paneRead } from "../herdr/client.ts"; +import { ANSI_RE, type OmoAsk, PI_MODEL_HINT_RE, type ParsedPrompt, SOLID_RULE_RE, cleanLine, findLastIndex, isDivider, wrapped } from "./shared.ts"; +import { registry } from "./registry.ts"; +import { CODEX_ASK_HINT_RE, CODEX_ASYNC_ASK_HINT_RE, CODEX_CONTINUE_HINT_RE } from "./codex.ts"; +import { OMO_FOOTER_LINES, OMO_HINT_LINES, OMO_NOT_FOOTER_RE, OMO_OPTIONS_HINT_RE, OMO_PENDING_HINT_RE, OMO_REVIEW_HINT_RE, OMO_TYPING_HINT_RE, omoModelListWaits } from "./omo.ts"; +import { CLAUDE_ASK_HINT_RE, CLAUDE_CONFIRM_HINT_RE, CLAUDE_EFFORT_HINT_RE, claudeModelListWaits, withoutClaudeTasks } from "./claude.ts"; +import { OMP_CLIPPED_SINGLE_HINT_RE, OMP_MULTI_HINT_RE, OMP_REVIEW_HINT_RE, OMP_SINGLE_CLIPPED_HINT_RE, OMP_SINGLE_HINT_RE } from "./omp.ts"; +import { HERMES_HINT_RE } from "./hermes.ts"; +import { PI_INPUT_HINT_RE, PI_MENU_HINT_RE } from "./pi.ts"; +import { openCodeV1QuestionActive } from "./opencode.ts"; + +/** + * A row of OmO's omo-task widget under the input box (omo-ai plugin/extensions/omo-task.js): a live row + * ` · · [ · ]` with the route `agent:X`, `category:X` or `task`, + * a finished row `| `, or the `+N more` line after five rows. + */ +const OMO_TASK_ROW_RE = /^(?:[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏‖] .+? · (?:agent:|category:|task(?: ·|$))|[^|]+\|(?:agent:|category:|task\b)|\+\d+ more$)/u; +/** a spinner row with no task route: another program's output, never the widget */ +const OMO_SPINNER_ROW_RE = /^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏‖] /u; +/** a narrow pane wraps a task row's last fields (ending in the elapsed time) onto one more short line */ +const OMO_TASK_TAIL_RE = /^(?:[^\u00b7]+ \u00b7 )*\d+[hms](?: \d+[hms])*$/u; +const OMO_TASK_WRAP_MAX = 32; +/** where each of the form's hints ends, however a narrow pane wraps it */ +const OMO_HINT_END_RE = /\besc (?:cancel|back|discard)$/; +/** OmO's apparently empty input box; never sufficient evidence for sending text into it. */ +const OMO_EMPTY_BOX_RE = /^[❯›>]$/; + +/** the last row of Claude's review of several answers, which ends it in place of a hint */ +/** the last row of Claude's review of several answers, which ends it in place of a hint */ +const CLAUDE_SUBMIT_TAIL_RE = /^(?:[›>❯]\s*)?\d+\.\s+Cancel$/i; + +/** The rule below an OmO form or composer, then extension task status and the native footer. */ +function omoFooterIsActive(lines: string[]): boolean { + let rules = 0; + let footer = 0; + let widget = false; + let tail = false; + for (const line of lines) { + if (!line) continue; + if (OMO_SPINNER_ROW_RE.test(line) && !OMO_TASK_ROW_RE.test(line)) return false; + if (SOLID_RULE_RE.test(line)) { + if (rules > 0 || footer > 0) return false; + rules = 1; + } else if (rules > 0 && footer === 0 && ( + /^(?:[▶◌✗✓] .*\bcategory:|▶ .* \bwave \d+\/\d+\b)/.test(line) + || (widget && /^(?:\d+[hms]\s*)+$/.test(line)) + )) { + tail = false; + // A duration may wrap off the task row. Only these status rows precede the native footer; + // arbitrary output, a second rule, or another program's input still invalidates the form. + widget = true; + } else if (rules > 0 && footer === 0 && !OMO_NOT_FOOTER_RE.test(line) && ( + OMO_TASK_ROW_RE.test(line) + || (tail && line.length <= OMO_TASK_WRAP_MAX && OMO_TASK_TAIL_RE.test(line)) + )) { + // a task row, or the one short line it wrapped onto + tail = OMO_TASK_ROW_RE.test(line); + } else if (rules === 0 || ++footer > OMO_FOOTER_LINES || OMO_NOT_FOOTER_RE.test(line)) return false; + } + return rules === 1; +} + +export function promptTailIsActive(prompt: ParsedPrompt, screen: string): boolean { + const cleanLines = screen.replace(ANSI_RE, "").split(/\r?\n/).map(cleanLine); + const visible = cleanLines.filter((line) => line && !isDivider(line)); + // Claude's review has no hint of its own: its last row ends the panel, the session's rule and task list come after it + const shown = prompt.responder === "claude-submit" ? withoutClaudeTasks(visible, (lines, end) => CLAUDE_SUBMIT_TAIL_RE.test(lines[end - 1] ?? "")) + : prompt.responder.startsWith("claude-") ? withoutClaudeTasks(visible) : visible; + const last = shown.at(-1) ?? ""; + // The menu is still at the bottom. A narrow pane wraps its hint, so the last line alone can + // be the hint's tail (`cancel`): the lines before it count only when the match runs into + // the last one, never for a hint that ended above later output (an answered, stale menu). + const ends = (re: RegExp): boolean => [1, 2, 3].some((span) => + re.test(shown.slice(-span).join(" ")) && (span === 1 || !re.test(shown.slice(-span, -1).join(" ")))); + if (prompt.responder === "omp-question") return ends(OMP_SINGLE_HINT_RE) || ends(OMP_CLIPPED_SINGLE_HINT_RE) || ends(OMP_SINGLE_CLIPPED_HINT_RE) || ends(OMP_MULTI_HINT_RE); + if (prompt.responder === "omp-submit") return ends(OMP_REVIEW_HINT_RE); + // Hermes keeps its status line (and maybe the hint's wrapped tail) under the hint; an answered + // question leaves the screen, so a hint among the last few lines is the live one + if (prompt.responder === "hermes-question" || prompt.responder === "hermes-input") return shown.slice(-4).some((line) => HERMES_HINT_RE.test(line)); + if (prompt.responder === "codex-menu") return ends(CODEX_CONTINUE_HINT_RE); + if (prompt.responder === "codex-question") return ends(CODEX_ASK_HINT_RE); + if (prompt.responder === "codex-async-question") return cleanLines.slice(-4).some((line) => CODEX_ASYNC_ASK_HINT_RE.test(line)) || ends(CODEX_ASYNC_ASK_HINT_RE); + if (prompt.responder === "claude-question") return ends(CLAUDE_ASK_HINT_RE); + if (prompt.responder === "claude-submit") return CLAUDE_SUBMIT_TAIL_RE.test(last); + // the last row carries the cursor once a move has put it there + // Codex's folder trust prompt ends "enter continue · esc back" or, from 0.163, "· esc quit". + // Only those two: its update prompt ends "· esc skip" and must not read as an approval. + if (prompt.responder === "codex-approval") return ends(/press enter to confirm|esc to cancel|enter continue\s*·\s*esc (?:back|quit)$|^(?:[›>❯]\s*)?\d+\.\s+(?:No|Reject|Cancel|Deny)\b/i); + if (prompt.responder === "omp-approval") return ends(/^(?:[›>❯•]\s*)?(?:Approve|Deny)$|esc.*cancel/i); + // Claude Code 2.1.29x ends with "Esc to cancel", optionally followed by its stop-agents + // chord. Anchor that suffix so arbitrary trailing text cannot keep an old approval live. + if (prompt.responder === "claude-approval") return ends(/esc to cancel.*(?:tab|ctrl\+e)|ctrl\+e to explain|^esc to cancel(?:\s*·\s*ctrl\+x ctrl\+k twice to stop background agents)?$/i); + if (prompt.responder === "claude-confirm") return ends(CLAUDE_CONFIRM_HINT_RE); + // its parser already found the hint over the input box with only the footer under it + if (prompt.responder === "claude-held") return true; + // so has omp's handoff status: only omp's status bar and editor under it + if (prompt.responder === "omp-handoff") return true; + // the goal dialog ends the screen with its own dismiss hint + if (prompt.responder === "claude-goal") return ends(/esc to dismiss$/i); + if (prompt.responder === "claude-model") return claudeModelListWaits(screen); + if (prompt.responder === "claude-effort") return ends(CLAUDE_EFFORT_HINT_RE); + if (prompt.responder === "codex-model") return ends(/(?:^|\s)enter select\s*·\s*esc back$|(?:^|\s)enter (?:default|apply)\s*·\s*s session\s*·\s*esc back$/i); + if (prompt.responder === "omo-question" || prompt.responder === "omo-review" || prompt.responder === "omo-typing") { + // The form is live only with OmO's footer and extension status under its hint: blank lines, one + // rule, status widgets, then the footer's few lines (cwd, context, model). Anything else is the form's text + // in another program (printed in a shell, quoted in a transcript over an input box), where + // an answer's keys would be typed into that program. + const hint = { "omo-question": OMO_OPTIONS_HINT_RE, "omo-review": OMO_REVIEW_HINT_RE, "omo-typing": OMO_TYPING_HINT_RE }[prompt.responder]; + const at = findLastIndex(cleanLines, (line, index) => line !== "" && hint.test(wrapped(cleanLines, index, OMO_HINT_LINES))); + if (at < 0) return false; + let end = at; + while (end < at + OMO_HINT_LINES && !OMO_HINT_END_RE.test(cleanLines.slice(at, end + 1).join(" ").trim())) end += 1; + if (end >= at + OMO_HINT_LINES) return false; + return omoFooterIsActive(cleanLines.slice(end + 1)); + } + if (prompt.responder === "omo-pending") { + // The widget is live over OmO's input box, other widgets maybe between them: the box (a rule, + // the box's line, empty, a rule) and then OmO's extension status and native footer. + const at = findLastIndex(cleanLines, (line, index) => line !== "" && OMO_PENDING_HINT_RE.test(wrapped(cleanLines, index))); + const box = cleanLines.findIndex((line, index) => index > at && OMO_EMPTY_BOX_RE.test(line)); + if (at < 0 || box < 0 || box - at > 60 || !cleanLines[box - 1]!.startsWith("─")) return false; + return omoFooterIsActive(cleanLines.slice(box + 1)); + } + // pi keeps its footer under every dialog — the pane's folder, then its context meter — so the + // hint sits near the end without being it. A dialog already answered leaves its hint far + // above whatever came after, which is what keeps this window narrow. + if (prompt.responder === "pi-model") return hintAtEnd(shown, PI_MODEL_HINT_AT_END_RE, PI_FOOTER_LINES); + if (prompt.responder === "omo-model") return omoModelListWaits(screen); + // its reader already found the picker ending the screen, which only an open picker does + if (prompt.responder === "omp-model") return true; + if (prompt.responder === "pi-question" || prompt.responder === "pi-confirm" || prompt.responder === "pi-input") { + return hintAtEnd(shown, PI_MENU_HINT_AT_END_RE, PI_FOOTER_LINES, 4) + || hintAtEnd(shown, PI_INPUT_HINT_AT_END_RE, PI_FOOTER_LINES, 4); + } + // Command Code's permission footer names its keys at the dialog's end and wraps on a narrow + // pane: `↑/↓ navigate · enter select · ctrl+e explain · Run cmd --yolo to bypass all …` + if (prompt.responder === "cmd-question") return cmdFooterEnds(shown); + // its footer ends the screen, wrapped or not (dialogs/opencode.ts) + if (prompt.responder === "opencode-v1-question") return openCodeV1QuestionActive(screen); + return ends(/ctrl\+g to edit|shift\+tab to approve with this feedback/i); +} +/** + * The same two hints told from the end of the window. A phone leaves pi a pane barely wide enough + * for its hint, which wraps it, and pi keeps its footer underneath: the wrapped hint then sits + * further from the bottom than a three-line window reaches, while a wider pane still has it as the + * last thing. Anchored, because the footer's own lines complete a join that merely starts with the + * hint's words, which would keep an answered dialog offering to press keys into the pane. + */ +const PI_MENU_HINT_AT_END_RE = new RegExp(`${PI_MENU_HINT_RE.source}$`, "i"); +const PI_INPUT_HINT_AT_END_RE = new RegExp(`${PI_INPUT_HINT_RE.source}$`, "i"); +/** Command Code's permission footer: `↑/↓ navigate · enter select · ctrl+e explain · Run cmd --yolo …` */ +const CMD_HINT_RE = /↑\/↓ navigate.*enter select/i; +const CMD_FOOTER_WORDS = new Set(["·", "ctrl+e", "explain", "run", "cmd", "--yolo", "to", "bypass", "all", "permissions", "(docs", "↗)", "↗", "(docs↗)"]); +/** + * The footer ends the screen: after its key hint only the footer's own words may follow, wrapped or + * not. A line typed below a quoted or stale dialog (`❯ my next message`) means it is no live one. + */ +function cmdFooterEnds(shown: string[]): boolean { + const at = findLastIndex(shown, (line) => CMD_HINT_RE.test(line)); + if (at < 0 || shown.length - at > 3) return false; + const rest = [shown[at]!.replace(/^.*?enter select/i, ""), ...shown.slice(at + 1)].join(" "); + return rest.split(/\s+/).filter(Boolean).every((word) => CMD_FOOTER_WORDS.has(word.toLowerCase())); +} +/** The same hint told from the end, so lines that follow it cannot complete a match of their own. */ +const PI_MODEL_HINT_AT_END_RE = new RegExp(`${PI_MODEL_HINT_RE.source}$`, "i"); +/** + * pi's own footer, under `/model` and under every dialog alike: the pane's folder, then its + * context meter. A hint is allowed this many lines of it before the bottom of the screen. + */ +const PI_FOOTER_LINES = 2; + +/** + * Whether a hint is the last thing before an agent's footer, allowing for a phone's pane being + * too narrow to hold it on one line: a TUI hard-wraps, so the hint arrives split over two or + * three lines with nothing marking the break. Anchored to the end of the joined window, because + * a hint's words also match a join that merely starts with them — the footer's own lines read as + * a hint that way, which would keep an answered, buried list offering a switch into whatever the + * pane shows by then. + */ +function hintAtEnd(shown: string[], atEnd: RegExp, footerLines: number, span = 3): boolean { + for (let end = shown.length - 1; end >= Math.max(0, shown.length - 1 - footerLines); end -= 1) { + for (let size = 1; size <= span; size += 1) { + const from = end - size + 1; + if (from < 0) break; + if (atEnd.test(shown.slice(from, end + 1).join(" "))) return true; + } + } + return false; +} + +export function parsePrompt(agent: string, screen: string, omoAsk: OmoAsk | null = null, omoTrusted = true, omoOpen: OmoAsk[] = [], sent: string | null = null, working = false): ParsedPrompt | null { + const ctx = { agent, omoAsk, omoTrusted, omoOpen, sent, working }; + // a result reader reads only below a send's own echo (outcome.ts), never a card on the whole screen + const candidates = registry.readersFor(agent).filter((reader) => reader.kind !== "result").map((reader) => reader.parse(screen, ctx)); + return candidates.find((candidate): candidate is ParsedPrompt => candidate !== null && promptTailIsActive(candidate, screen)) ?? null; +} + +/** + * The pane's live screen as text: herdr's bottom buffer, whatever part of the history the pane's + * viewport shows. A pane scrolled up (a drag or the wheel in a terminal) stays scrolled while the + * agent draws its next menu at the bottom, out of that viewport. + */ +export async function liveScreen(paneId: string): Promise { + return (await paneRead({ paneId, source: "detection", format: "text" })).text; +} diff --git a/server/prompt.ts b/server/prompt.ts index 6473918c48..7ac6985531 100644 --- a/server/prompt.ts +++ b/server/prompt.ts @@ -12,15 +12,13 @@ import { claudePlanFile } from "./claude-plan.ts"; import { listPendingQuestionForms, openCodeService, replyOpenCodeForm } from "./opencode-service.ts"; import { opencodeSessionId } from "./opencode.ts"; import { registry } from "./dialogs/registry.ts"; -import { ANSI_RE, type AnswerStep, KEY, type OmoAsk, PI_MODEL_HINT_RE, type ParsedPrompt, SOLID_RULE_RE, cleanLine, findLastIndex, finishPrompt, isDivider, keySteps, navigationKeys, parsedByPublicPrompt, publicPrompt, wrapped } from "./dialogs/shared.ts"; +import { ANSI_RE, type AnswerStep, KEY, type OmoAsk, type ParsedPrompt, finishPrompt, keySteps, navigationKeys, parsedByPublicPrompt, publicPrompt } from "./dialogs/shared.ts"; import { executeInteractionPlan } from "./dialogs/interaction-plan.ts"; -import { openCodeV1QuestionActive } from "./dialogs/opencode.ts"; -import { CODEX_ASK_HINT_RE, CODEX_ASYNC_ASK_HINT_RE, CODEX_CONTINUE_HINT_RE, codexModelListWaits } from "./dialogs/codex.ts"; -import { OMO_FOOTER_LINES, OMO_HINT_LINES, OMO_NOT_FOOTER_RE, OMO_OPTIONS_HINT_RE, OMO_PENDING_HINT_RE, OMO_REVIEW_HINT_RE, OMO_TYPING_HINT_RE, omoModelListWaits, sameText } from "./dialogs/omo.ts"; -import { CLAUDE_ASK_HINT_RE, CLAUDE_CONFIRM_HINT_RE, CLAUDE_EFFORT_GUARD_RE, CLAUDE_EFFORT_HINT_RE, INVISIBLE_CHAR_RE, claudeModelListWaits, withoutClaudeTasks } from "./dialogs/claude.ts"; -import { OMP_CLIPPED_SINGLE_HINT_RE, OMP_MULTI_HINT_RE, OMP_REVIEW_HINT_RE, OMP_SINGLE_CLIPPED_HINT_RE, OMP_SINGLE_HINT_RE } from "./dialogs/omp.ts"; -import { HERMES_HINT_RE } from "./dialogs/hermes.ts"; -import { PI_INPUT_HINT_RE, PI_MENU_HINT_RE, parsePiDialog } from "./dialogs/pi.ts"; +import { liveScreen, parsePrompt, promptTailIsActive } from "./dialogs/live.ts"; +import { codexModelListWaits } from "./dialogs/codex.ts"; +import { sameText } from "./dialogs/omo.ts"; +import { CLAUDE_EFFORT_GUARD_RE, INVISIBLE_CHAR_RE, claudeModelListWaits } from "./dialogs/claude.ts"; +import { parsePiDialog } from "./dialogs/pi.ts"; import { parseFallbackPrompt } from "./dialogs/fallback.ts"; import { SUGGESTION_READ_MS, claudeHeldIsGrey, claudeInputDraft, claudeRestoredDraft, parseClaudeSuggestion, viewportShowsLive } from "./claude-input.ts"; import { OMO_FORM_RE, omoAsksFor } from "./omo-questions.ts"; @@ -37,22 +35,6 @@ export { openOmoAsks, pendingOmoAsk } from "./omo-questions.ts"; export { codexQueuedPrompt } from "./codex-queue.ts"; export type { QueueFront } from "./codex-queue.ts"; -/** - * A row of OmO's omo-task widget under the input box (omo-ai plugin/extensions/omo-task.js): a live row - * ` · · [ · ]` with the route `agent:X`, `category:X` or `task`, - * a finished row `| `, or the `+N more` line after five rows. - */ -const OMO_TASK_ROW_RE = /^(?:[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏‖] .+? · (?:agent:|category:|task(?: ·|$))|[^|]+\|(?:agent:|category:|task\b)|\+\d+ more$)/u; -/** a spinner row with no task route: another program's output, never the widget */ -const OMO_SPINNER_ROW_RE = /^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏‖] /u; -/** a narrow pane wraps a task row's last fields (ending in the elapsed time) onto one more short line */ -const OMO_TASK_TAIL_RE = /^(?:[^\u00b7]+ \u00b7 )*\d+[hms](?: \d+[hms])*$/u; -const OMO_TASK_WRAP_MAX = 32; -/** where each of the form's hints ends, however a narrow pane wraps it */ -const OMO_HINT_END_RE = /\besc (?:cancel|back|discard)$/; -/** OmO's apparently empty input box; never sufficient evidence for sending text into it. */ -const OMO_EMPTY_BOX_RE = /^[❯›>]$/; - /** What the chat last sent to each pane, while it carried invisible characters (noteSubmitted). */ const heldCandidates = new Map(); /** Long enough to read the card and answer it; a message kept past this is the user's own business */ @@ -354,174 +336,6 @@ export function heldCandidate(paneId: string): string | null { export function modelListWaits(agent: string, screen: string): boolean { return registry.waits(agent, screen); } -/** the last row of Claude's review of several answers, which ends it in place of a hint */ -const CLAUDE_SUBMIT_TAIL_RE = /^(?:[›>❯]\s*)?\d+\.\s+Cancel$/i; - -/** The rule below an OmO form or composer, then extension task status and the native footer. */ -function omoFooterIsActive(lines: string[]): boolean { - let rules = 0; - let footer = 0; - let widget = false; - let tail = false; - for (const line of lines) { - if (!line) continue; - if (OMO_SPINNER_ROW_RE.test(line) && !OMO_TASK_ROW_RE.test(line)) return false; - if (SOLID_RULE_RE.test(line)) { - if (rules > 0 || footer > 0) return false; - rules = 1; - } else if (rules > 0 && footer === 0 && ( - /^(?:[▶◌✗✓] .*\bcategory:|▶ .* \bwave \d+\/\d+\b)/.test(line) - || (widget && /^(?:\d+[hms]\s*)+$/.test(line)) - )) { - tail = false; - // A duration may wrap off the task row. Only these status rows precede the native footer; - // arbitrary output, a second rule, or another program's input still invalidates the form. - widget = true; - } else if (rules > 0 && footer === 0 && !OMO_NOT_FOOTER_RE.test(line) && ( - OMO_TASK_ROW_RE.test(line) - || (tail && line.length <= OMO_TASK_WRAP_MAX && OMO_TASK_TAIL_RE.test(line)) - )) { - // a task row, or the one short line it wrapped onto - tail = OMO_TASK_ROW_RE.test(line); - } else if (rules === 0 || ++footer > OMO_FOOTER_LINES || OMO_NOT_FOOTER_RE.test(line)) return false; - } - return rules === 1; -} - -function promptTailIsActive(prompt: ParsedPrompt, screen: string): boolean { - const cleanLines = screen.replace(ANSI_RE, "").split(/\r?\n/).map(cleanLine); - const visible = cleanLines.filter((line) => line && !isDivider(line)); - // Claude's review has no hint of its own: its last row ends the panel, the session's rule and task list come after it - const shown = prompt.responder === "claude-submit" ? withoutClaudeTasks(visible, (lines, end) => CLAUDE_SUBMIT_TAIL_RE.test(lines[end - 1] ?? "")) - : prompt.responder.startsWith("claude-") ? withoutClaudeTasks(visible) : visible; - const last = shown.at(-1) ?? ""; - // The menu is still at the bottom. A narrow pane wraps its hint, so the last line alone can - // be the hint's tail (`cancel`): the lines before it count only when the match runs into - // the last one, never for a hint that ended above later output (an answered, stale menu). - const ends = (re: RegExp): boolean => [1, 2, 3].some((span) => - re.test(shown.slice(-span).join(" ")) && (span === 1 || !re.test(shown.slice(-span, -1).join(" ")))); - if (prompt.responder === "omp-question") return ends(OMP_SINGLE_HINT_RE) || ends(OMP_CLIPPED_SINGLE_HINT_RE) || ends(OMP_SINGLE_CLIPPED_HINT_RE) || ends(OMP_MULTI_HINT_RE); - if (prompt.responder === "omp-submit") return ends(OMP_REVIEW_HINT_RE); - // Hermes keeps its status line (and maybe the hint's wrapped tail) under the hint; an answered - // question leaves the screen, so a hint among the last few lines is the live one - if (prompt.responder === "hermes-question" || prompt.responder === "hermes-input") return shown.slice(-4).some((line) => HERMES_HINT_RE.test(line)); - if (prompt.responder === "codex-menu") return ends(CODEX_CONTINUE_HINT_RE); - if (prompt.responder === "codex-question") return ends(CODEX_ASK_HINT_RE); - if (prompt.responder === "codex-async-question") return cleanLines.slice(-4).some((line) => CODEX_ASYNC_ASK_HINT_RE.test(line)) || ends(CODEX_ASYNC_ASK_HINT_RE); - if (prompt.responder === "claude-question") return ends(CLAUDE_ASK_HINT_RE); - if (prompt.responder === "claude-submit") return CLAUDE_SUBMIT_TAIL_RE.test(last); - // the last row carries the cursor once a move has put it there - // Codex's folder trust prompt ends "enter continue · esc back" or, from 0.163, "· esc quit". - // Only those two: its update prompt ends "· esc skip" and must not read as an approval. - if (prompt.responder === "codex-approval") return ends(/press enter to confirm|esc to cancel|enter continue\s*·\s*esc (?:back|quit)$|^(?:[›>❯]\s*)?\d+\.\s+(?:No|Reject|Cancel|Deny)\b/i); - if (prompt.responder === "omp-approval") return ends(/^(?:[›>❯•]\s*)?(?:Approve|Deny)$|esc.*cancel/i); - // Claude Code 2.1.29x ends with "Esc to cancel", optionally followed by its stop-agents - // chord. Anchor that suffix so arbitrary trailing text cannot keep an old approval live. - if (prompt.responder === "claude-approval") return ends(/esc to cancel.*(?:tab|ctrl\+e)|ctrl\+e to explain|^esc to cancel(?:\s*·\s*ctrl\+x ctrl\+k twice to stop background agents)?$/i); - if (prompt.responder === "claude-confirm") return ends(CLAUDE_CONFIRM_HINT_RE); - // its parser already found the hint over the input box with only the footer under it - if (prompt.responder === "claude-held") return true; - // so has omp's handoff status: only omp's status bar and editor under it - if (prompt.responder === "omp-handoff") return true; - // the goal dialog ends the screen with its own dismiss hint - if (prompt.responder === "claude-goal") return ends(/esc to dismiss$/i); - if (prompt.responder === "claude-model") return claudeModelListWaits(screen); - if (prompt.responder === "claude-effort") return ends(CLAUDE_EFFORT_HINT_RE); - if (prompt.responder === "codex-model") return ends(/(?:^|\s)enter select\s*·\s*esc back$|(?:^|\s)enter (?:default|apply)\s*·\s*s session\s*·\s*esc back$/i); - if (prompt.responder === "omo-question" || prompt.responder === "omo-review" || prompt.responder === "omo-typing") { - // The form is live only with OmO's footer and extension status under its hint: blank lines, one - // rule, status widgets, then the footer's few lines (cwd, context, model). Anything else is the form's text - // in another program (printed in a shell, quoted in a transcript over an input box), where - // an answer's keys would be typed into that program. - const hint = { "omo-question": OMO_OPTIONS_HINT_RE, "omo-review": OMO_REVIEW_HINT_RE, "omo-typing": OMO_TYPING_HINT_RE }[prompt.responder]; - const at = findLastIndex(cleanLines, (line, index) => line !== "" && hint.test(wrapped(cleanLines, index, OMO_HINT_LINES))); - if (at < 0) return false; - let end = at; - while (end < at + OMO_HINT_LINES && !OMO_HINT_END_RE.test(cleanLines.slice(at, end + 1).join(" ").trim())) end += 1; - if (end >= at + OMO_HINT_LINES) return false; - return omoFooterIsActive(cleanLines.slice(end + 1)); - } - if (prompt.responder === "omo-pending") { - // The widget is live over OmO's input box, other widgets maybe between them: the box (a rule, - // the box's line, empty, a rule) and then OmO's extension status and native footer. - const at = findLastIndex(cleanLines, (line, index) => line !== "" && OMO_PENDING_HINT_RE.test(wrapped(cleanLines, index))); - const box = cleanLines.findIndex((line, index) => index > at && OMO_EMPTY_BOX_RE.test(line)); - if (at < 0 || box < 0 || box - at > 60 || !cleanLines[box - 1]!.startsWith("─")) return false; - return omoFooterIsActive(cleanLines.slice(box + 1)); - } - // pi keeps its footer under every dialog — the pane's folder, then its context meter — so the - // hint sits near the end without being it. A dialog already answered leaves its hint far - // above whatever came after, which is what keeps this window narrow. - if (prompt.responder === "pi-model") return hintAtEnd(shown, PI_MODEL_HINT_AT_END_RE, PI_FOOTER_LINES); - if (prompt.responder === "omo-model") return omoModelListWaits(screen); - // its reader already found the picker ending the screen, which only an open picker does - if (prompt.responder === "omp-model") return true; - if (prompt.responder === "pi-question" || prompt.responder === "pi-confirm" || prompt.responder === "pi-input") { - return hintAtEnd(shown, PI_MENU_HINT_AT_END_RE, PI_FOOTER_LINES, 4) - || hintAtEnd(shown, PI_INPUT_HINT_AT_END_RE, PI_FOOTER_LINES, 4); - } - // Command Code's permission footer names its keys at the dialog's end and wraps on a narrow - // pane: `↑/↓ navigate · enter select · ctrl+e explain · Run cmd --yolo to bypass all …` - if (prompt.responder === "cmd-question") return cmdFooterEnds(shown); - // its footer ends the screen, wrapped or not (dialogs/opencode.ts) - if (prompt.responder === "opencode-v1-question") return openCodeV1QuestionActive(screen); - return ends(/ctrl\+g to edit|shift\+tab to approve with this feedback/i); -} -/** - * The same two hints told from the end of the window. A phone leaves pi a pane barely wide enough - * for its hint, which wraps it, and pi keeps its footer underneath: the wrapped hint then sits - * further from the bottom than a three-line window reaches, while a wider pane still has it as the - * last thing. Anchored, because the footer's own lines complete a join that merely starts with the - * hint's words, which would keep an answered dialog offering to press keys into the pane. - */ -const PI_MENU_HINT_AT_END_RE = new RegExp(`${PI_MENU_HINT_RE.source}$`, "i"); -const PI_INPUT_HINT_AT_END_RE = new RegExp(`${PI_INPUT_HINT_RE.source}$`, "i"); -/** Command Code's permission footer: `↑/↓ navigate · enter select · ctrl+e explain · Run cmd --yolo …` */ -const CMD_HINT_RE = /↑\/↓ navigate.*enter select/i; -const CMD_FOOTER_WORDS = new Set(["·", "ctrl+e", "explain", "run", "cmd", "--yolo", "to", "bypass", "all", "permissions", "(docs", "↗)", "↗", "(docs↗)"]); -/** - * The footer ends the screen: after its key hint only the footer's own words may follow, wrapped or - * not. A line typed below a quoted or stale dialog (`❯ my next message`) means it is no live one. - */ -function cmdFooterEnds(shown: string[]): boolean { - const at = findLastIndex(shown, (line) => CMD_HINT_RE.test(line)); - if (at < 0 || shown.length - at > 3) return false; - const rest = [shown[at]!.replace(/^.*?enter select/i, ""), ...shown.slice(at + 1)].join(" "); - return rest.split(/\s+/).filter(Boolean).every((word) => CMD_FOOTER_WORDS.has(word.toLowerCase())); -} -/** The same hint told from the end, so lines that follow it cannot complete a match of their own. */ -const PI_MODEL_HINT_AT_END_RE = new RegExp(`${PI_MODEL_HINT_RE.source}$`, "i"); -/** - * pi's own footer, under `/model` and under every dialog alike: the pane's folder, then its - * context meter. A hint is allowed this many lines of it before the bottom of the screen. - */ -const PI_FOOTER_LINES = 2; - -/** - * Whether a hint is the last thing before an agent's footer, allowing for a phone's pane being - * too narrow to hold it on one line: a TUI hard-wraps, so the hint arrives split over two or - * three lines with nothing marking the break. Anchored to the end of the joined window, because - * a hint's words also match a join that merely starts with them — the footer's own lines read as - * a hint that way, which would keep an answered, buried list offering a switch into whatever the - * pane shows by then. - */ -function hintAtEnd(shown: string[], atEnd: RegExp, footerLines: number, span = 3): boolean { - for (let end = shown.length - 1; end >= Math.max(0, shown.length - 1 - footerLines); end -= 1) { - for (let size = 1; size <= span; size += 1) { - const from = end - size + 1; - if (from < 0) break; - if (atEnd.test(shown.slice(from, end + 1).join(" "))) return true; - } - } - return false; -} - -function parsePrompt(agent: string, screen: string, omoAsk: OmoAsk | null = null, omoTrusted = true, omoOpen: OmoAsk[] = [], sent: string | null = null, working = false): ParsedPrompt | null { - const ctx = { agent, omoAsk, omoTrusted, omoOpen, sent, working }; - // a result reader reads only below a send's own echo (outcome.ts), never a card on the whole screen - const candidates = registry.readersFor(agent).filter((reader) => reader.kind !== "result").map((reader) => reader.parse(screen, ctx)); - return candidates.find((candidate): candidate is ParsedPrompt => candidate !== null && promptTailIsActive(candidate, screen)) ?? null; -} /** * Codex 0.156 holds the questions it asked with request_user_input_async in a queue above @@ -776,15 +590,6 @@ async function readPrompt(paneId: string, codexHome?: string, openCodeStateDir?: return { agent, status, prompt, pane, panes, screen }; } -/** - * The pane's live screen as text: herdr's bottom buffer, whatever part of the history the pane's - * viewport shows. A pane scrolled up (a drag or the wheel in a terminal) stays scrolled while the - * agent draws its next menu at the bottom, out of that viewport. - */ -async function liveScreen(paneId: string): Promise { - return (await paneRead({ paneId, source: "detection", format: "text" })).text; -} - async function readKnownPrompt( paneId: string, pane: { cwd?: string | null; agent_status?: string; agent_session?: HerdrPane["agent_session"] },