diff --git a/web/src/fixtures/panes/README.md b/web/src/fixtures/panes/README.md index 2b5570baf..8b5feaa57 100644 --- a/web/src/fixtures/panes/README.md +++ b/web/src/fixtures/panes/README.md @@ -80,6 +80,8 @@ host, key or token remains; `/tmp/pi110-lab/work` is the stock pi sandbox. | `pi--omo-v5130-panel-picker-tree.txt` | 40 rows, `/tree` with the panel beside its header | Escape card | | `pi--v110-stock-picker-resume.txt` | Stock pi 1.1.0 `/resume` picker | Escape card | | `pi--v110-stock-picker-tree.txt` | Stock pi 1.1.0 `/tree` picker | Escape card | +| `pi--trust-prompt.txt` | pi 1.2.0 started in an untrusted folder holding a project skill: `Trust project folder?`, the folder, the explanation, five unnumbered options with `→` on Trust (the parent folder wrapped under its option) and `↑↓ navigate enter select escape/ctrl+c cancel`, between two rules. Read with a throwaway `PI_CODING_AGENT_DIR`; both paths became one invented sandbox path of the same length, split where the real one wrapped | lifts (pi/trust.ts), a walked trust card plus Cancel (Escape) | +| `pi--trust-prompt-moved.txt` | The same prompt after one Down: `→` on Trust parent folder. Its walk pair | lifts | ## pi 1.1.0 corpus, PR 397 (captured 2026-10-10 by @raffaelenatale, pi 1.1.0 with a pi question extension, Linux sandbox panes) @@ -405,6 +407,8 @@ hostname or session id is on the kept rows. | Fixture | State / what's in it | Herdr status | |---|---|---| | `codex--v0159-task-title.txt` | `Reply with only OK.` answered `OK`, the empty composer, and the status row ` gpt-5.6-sol low · /tmp/collie-canary-project · Generate task title`. `composerReady` must be TRUE | `idle` | +| `codex--resume-picker.txt` | Codex 0.159.3 `/resume`: `Resume a previous session`, filter rows, ` › 2d ago Reply with OK` highlighted, the two-row `enter resume … esc exit …` / `… ↑/↓ browse` footer. Lifts as a menu: Up/Down, Resume (Enter), Exit (Escape). Visible-screen read 2026-10-15 in a throwaway Herdr session; the sessions listed are earlier canary runs | `blocked` | +| `codex--resume-picker-moved.txt` | Same picker after Down: the highlight on the second session, so the menu signature differs | `blocked` | ## Codex reporter capture (#294, 2026-09-27, macOS, Herdr 0.9.1, SCRUBBED) @@ -1858,6 +1862,8 @@ signature. | `muse--approval-ls-147.txt` | Muse Code 1.4.7 `ls` approval, `⟩` on option 1 (#424) | `blocked` | | `muse--approval-date-147.txt` | Muse Code 1.4.7 `date` approval, `⟩` on option 2 (#424) | `blocked` | | `muse--trust-prompt-147.txt` | Muse Code 1.4.7 workspace trust prompt (#424) | `blocked` | +| `muse--resume-picker.txt` | Muse Code 1.4.8 `/resume`: the same picker as Codex's, the highlighted session led by `⟩` at column 0 (the search field's ` ⟩ Search` is indented), footer ending `↑/↓ browse pgup/pgdn page`. Lifts as a menu: Up/Down, Resume, Exit. Visible-screen read 2026-10-15; the sessions are earlier canary runs | `blocked` | +| `muse--resume-picker-moved.txt` | Same picker after Down: the highlight on the second session | `blocked` | **Nothing was approved blindly.** The one approved command was `ls -la` on the empty sandbox (output verified); the trust prompt covered a throwaway `/tmp` @@ -1899,6 +1905,7 @@ wrap, Tab does nothing, Enter confirms, Escape declines, no digit). | `oc--slash-palette.txt` | `/` typed: the command list painted inside the box, above the input row. The composer still holds the keyboard | `idle` | | `oc--command-palette.txt` | The ctrl+p palette, `Commands … esc` over `Search`, over the middle of the screen; on 1.18.32 it also cuts through the rule | `idle` | | `oc--agents-picker.txt` | The `/agents` picker, `Select agent … esc` over `Search`, floating over the splash while the composer's tail stays intact underneath. The picker-shape check refuses it | `idle` | +| `oc--update-available.txt` | opencode 1.18.34 at launch with a newer release out: the `Update Available … esc` confirm dialog, two body rows, and `Skip Confirm` with Confirm highlighted, over the splash. No `Search` row, so it is not a picker; `confirmDialogUp` refuses the composer and the card offers Escape, which dismissed it live (2026-10-15). Recent-buffer read in a throwaway Herdr session; the wrapped cwd row became `/tmp/collie-oc-sandbox:master` at the same width and its second row blank, and the model and provider labels became `mini demo host`. Enter (Confirm) was never pressed | `blocked` | | `oc--command-palette-query.txt` | The ctrl+p palette with the filter `mod` typed, which stands where `Search` was. A known gap, pinned as `it.fails`: `composerReady` answers true here | `done` | | `oc--permission-bash.txt` | The bash permission dialog: `△ Permission required`, the heading `# Shell command`, the command `$ echo fixture-corpus-probe`, chips `Allow once` / `Allow always` / `Reject` with the pointer on `Allow once` | `blocked` | | `oc--permission-bash--moved.txt` | After one `Right`: pointer on `Allow always`. The body does not change | `blocked` | diff --git a/web/src/fixtures/panes/codex--resume-picker-moved.txt b/web/src/fixtures/panes/codex--resume-picker-moved.txt new file mode 100644 index 000000000..8abe399b9 --- /dev/null +++ b/web/src/fixtures/panes/codex--resume-picker-moved.txt @@ -0,0 +1,39 @@ + Resume a previous session + + Filter:  Cwd  All Status:  Active  Archived Sort:  Updated  Created + + Type to search + +  2d ago Reply with OK  + › 2d ago Generate task title  + + + + + + + + + + + + + + + + + + + + + + + + + + + + +───────────────────────────────────────────────────────────────────────────────────────────────────────── 2 / 2 · 100% ─ + enter resume ctrl+a archive esc exit ctrl+c exit tab focus sort/filter ←/→ change option + ctrl+o comfortable view ctrl+t transcript ctrl+e expand ↑/↓ browse \ No newline at end of file diff --git a/web/src/fixtures/panes/codex--resume-picker.txt b/web/src/fixtures/panes/codex--resume-picker.txt new file mode 100644 index 000000000..165823733 --- /dev/null +++ b/web/src/fixtures/panes/codex--resume-picker.txt @@ -0,0 +1,39 @@ + Resume a previous session + + Filter:  Cwd  All Status:  Active  Archived Sort:  Updated  Created + + Type to search + + › 2d ago Reply with OK  +  2d ago Generate task title + + + + + + + + + + + + + + + + + + + + + + + + + + + + +───────────────────────────────────────────────────────────────────────────────────────────────────────── 1 / 2 · 100% ─ + enter resume ctrl+a archive esc exit ctrl+c exit tab focus sort/filter ←/→ change option + ctrl+o comfortable view ctrl+t transcript ctrl+e expand ↑/↓ browse \ No newline at end of file diff --git a/web/src/fixtures/panes/muse--resume-picker-moved.txt b/web/src/fixtures/panes/muse--resume-picker-moved.txt new file mode 100644 index 000000000..7c3640a22 --- /dev/null +++ b/web/src/fixtures/panes/muse--resume-picker-moved.txt @@ -0,0 +1,40 @@ + + Resume a previous session + ⟩ Search Name / Description + Filter: [Cwd] All Claude Codex Sort: [Updated] Created + Other workspaces are hidden + 1h ago oak-thebe · Reply with only OK. +⟩ 2d ago tawny-albiorix · Reply with only OK. + 2d ago sky-crater · Reply with only OK. + 2d ago seaglass-apogee · Reply with only OK. + 2d ago chalk-libration · Reply with only OK. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 2 / 5 · 40% enter resume esc exit ctrl+c exit tab focus sort/filter ←/→ change option + ctrl+o comfortable view ctrl+t transcript ctrl+e expand ↑/↓ browse pgup/pgdn page \ No newline at end of file diff --git a/web/src/fixtures/panes/muse--resume-picker.txt b/web/src/fixtures/panes/muse--resume-picker.txt new file mode 100644 index 000000000..744fc107b --- /dev/null +++ b/web/src/fixtures/panes/muse--resume-picker.txt @@ -0,0 +1,40 @@ + + Resume a previous session + ⟩ Search Name / Description + Filter: [Cwd] All Claude Codex Sort: [Updated] Created + Other workspaces are hidden +⟩ 1h ago oak-thebe · Reply with only OK. + 2d ago tawny-albiorix · Reply with only OK. + 2d ago sky-crater · Reply with only OK. + 2d ago seaglass-apogee · Reply with only OK. + 2d ago chalk-libration · Reply with only OK. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 / 5 · 20% enter resume esc exit ctrl+c exit tab focus sort/filter ←/→ change option + ctrl+o comfortable view ctrl+t transcript ctrl+e expand ↑/↓ browse pgup/pgdn page \ No newline at end of file diff --git a/web/src/fixtures/panes/oc--update-available.txt b/web/src/fixtures/panes/oc--update-available.txt new file mode 100644 index 000000000..3cd56a1f3 --- /dev/null +++ b/web/src/fixtures/panes/oc--update-available.txt @@ -0,0 +1,40 @@ +  +  +  +  +  +  +  +  +  +  +    +  Update Available esc   +    +  A new release v1.19.0 is available. Would you like to    +  update now?   +    +    +  Skip  Confirm    + ┃     + ┃ Ask anything… "Fix broken tests"   + ┃   + ┃ Build · mini demo host   + ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀  + tab agents ctrl+p commands  +  +  +  + ● Tip Use opencode --continue to resume the last session  +  +  +  +  +  +  +  +  +  + /tmp/collie-oc-sandbox:master  1.18.34  +    +  \ No newline at end of file diff --git a/web/src/fixtures/panes/pi--trust-prompt-moved.txt b/web/src/fixtures/panes/pi--trust-prompt-moved.txt new file mode 100644 index 000000000..4b5c3003f --- /dev/null +++ b/web/src/fixtures/panes/pi--trust-prompt-moved.txt @@ -0,0 +1,19 @@ +─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + Trust project folder? + /tmp/collie-pi-sandbox/a-folder-name-long-enough-to-wrap-the-way-the-captured-path-did-then-some-padding-again--/sand  + boxdir/trust-probe + + This allows pi to load .pi settings and resources, install missing project packages, and execute project extensions. + + Trust + → Trust parent folder  + (/tmp/collie-pi-sandbox/a-folder-name-long-enough-to-wrap-the-way-the-captured-path-did-then-some-padding-again--/san  + dboxdir) + Trust (this session only) + Do not trust + Do not trust (this session only) + + ↑↓ navigate enter select escape/ctrl+c cancel + +─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── \ No newline at end of file diff --git a/web/src/fixtures/panes/pi--trust-prompt.txt b/web/src/fixtures/panes/pi--trust-prompt.txt new file mode 100644 index 000000000..f7596c71f --- /dev/null +++ b/web/src/fixtures/panes/pi--trust-prompt.txt @@ -0,0 +1,19 @@ +─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + Trust project folder? + /tmp/collie-pi-sandbox/a-folder-name-long-enough-to-wrap-the-way-the-captured-path-did-then-some-padding-again--/sand  + boxdir/trust-probe + + This allows pi to load .pi settings and resources, install missing project packages, and execute project extensions. + + → Trust + Trust parent folder + (/tmp/collie-pi-sandbox/a-folder-name-long-enough-to-wrap-the-way-the-captured-path-did-then-some-padding-again--/san + dboxdir) + Trust (this session only) + Do not trust + Do not trust (this session only) + + ↑↓ navigate enter select escape/ctrl+c cancel + +─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── \ No newline at end of file diff --git a/web/src/lib/harness/codex.test.ts b/web/src/lib/harness/codex.test.ts index 4e8130e34..4de1462af 100644 --- a/web/src/lib/harness/codex.test.ts +++ b/web/src/lib/harness/codex.test.ts @@ -44,6 +44,8 @@ const PINNED = [ "codex--fresh-idle.txt", "codex--queue-context-inline.txt", "codex--reporter-294-busy-agents-hint.txt", + "codex--resume-picker-moved.txt", + "codex--resume-picker.txt", "codex--submitted-fill-labelled-rule.txt", "codex--trust-prompt.txt", "codex--v0150-custom-status.txt", @@ -84,6 +86,8 @@ const DIALOG = [ "codex--ask-fruit.txt", "codex--ask-wizard-q1.txt", "codex--ask-wizard-q2.txt", + "codex--resume-picker-moved.txt", + "codex--resume-picker.txt", "codex--trust-prompt.txt", "codex--v0156-approval-exec-2opt.txt", "codex--v0156-approval-exec-wrapped-50.txt", @@ -1363,3 +1367,25 @@ describe("Codex mobile display cleanup", () => { expect(decorated.map(lineText)).toEqual(lines.map(lineText)); }); }); + +describe("codex: the /resume session picker lifts as a menu (0.159)", () => { + const menuOf = (name: string) => { + const block = codexAdapter.buildBlocks(fixtureLines(name)).find((b) => b.kind === "menu"); + return block?.kind === "menu" ? block.menu : null; + }; + + it("offers Up/Down, Resume and Exit, and nothing that archives or quits", () => { + const menu = menuOf("codex--resume-picker.txt"); + expect(menu?.title).toBe("Resume a previous session"); + expect(menu?.actions.map((a) => [a.label, a.keys.join("+")])).toEqual([ + ["Resume", "Enter"], + ["Exit", "Escape"], + ]); + expect(menu?.nav.upDown).toBe(true); + expect(codexAdapter.composerReady!(fixtureLines("codex--resume-picker.txt"))).toBe(false); + }); + + it("changes its signature when the highlight moves, so a stale tap is refused", () => { + expect(menuOf("codex--resume-picker-moved.txt")?.signature).not.toBe(menuOf("codex--resume-picker.txt")?.signature); + }); +}); diff --git a/web/src/lib/harness/codex/index.ts b/web/src/lib/harness/codex/index.ts index 033c62ee0..52267dfb7 100644 --- a/web/src/lib/harness/codex/index.ts +++ b/web/src/lib/harness/codex/index.ts @@ -29,6 +29,8 @@ import { detectApprovalRegion } from "./approval"; import { detectAskRegion } from "./ask"; import { detectTrustRegion } from "./trust"; import { decorateCodexDisplay } from "./display"; +import { regionSignature, rstrip } from "./markers"; +import { detectSessionPicker } from "../session-picker"; import { codexDraftCarriesSend } from "./paste"; function raw(lines: StyledLine[]): Block { @@ -67,6 +69,17 @@ export function codexBuildBlocks(lines: StyledLine[]): Block[] { return blocks; } + // The highlighted session row is indented two columns: ` › 2d ago Reply with OK`. + const texts = lines.map((l) => rstrip(lineText(l))); + const picker = detectSessionPicker(texts, /^\s*› \S/, (from, to) => regionSignature(lines, from, to + 1)); + if (picker) { + const before = trimTrailingBlank(lines.slice(0, picker.startLine)); + const blocks: Block[] = []; + if (before.length > 0) blocks.push(raw(before)); + blocks.push({ kind: "menu", menu: picker.model, lines: lines.slice(picker.startLine) }); + return blocks; + } + return [raw(stripChrome(lines))]; } diff --git a/web/src/lib/harness/muse.test.ts b/web/src/lib/harness/muse.test.ts index 005e7d45e..f45b90e28 100644 --- a/web/src/lib/harness/muse.test.ts +++ b/web/src/lib/harness/muse.test.ts @@ -63,6 +63,8 @@ const PINNED = [ "muse--palette-exact.txt", "muse--palette-partial.txt", "muse--quoted-dialogs-bare.txt", + "muse--resume-picker-moved.txt", + "muse--resume-picker.txt", "muse--tasks-popup-approval.txt", "muse--tasks-popup-draft.txt", "muse--tasks-popup.txt", @@ -95,6 +97,8 @@ const LIFTED = [ "muse--ask-toppings-checked.txt", "muse--ask-toppings-review.txt", "muse--ask-toppings.txt", + "muse--resume-picker-moved.txt", + "muse--resume-picker.txt", "muse--tasks-popup-approval.txt", "muse--trust-prompt-147.txt", "muse--trust-prompt.txt", @@ -594,3 +598,24 @@ describe("muse: a focused tasks popup that names x to stop refuses the composer" ); }); +describe("muse: the /resume session picker lifts as a menu (1.4.8)", () => { + const lines = (name: string) => splitLines(parseAnsi(readFileSync(join(PANES_DIR, name), "utf8"))); + const menuOf = (name: string) => { + const block = museAdapter.buildBlocks(lines(name)).find((b) => b.kind === "menu"); + return block?.kind === "menu" ? block.menu : null; + }; + + it("offers Up/Down, Resume and Exit, and nothing that quits", () => { + const menu = menuOf("muse--resume-picker.txt"); + expect(menu?.title).toBe("Resume a previous session"); + expect(menu?.actions.map((a) => [a.label, a.keys.join("+")])).toEqual([ + ["Resume", "Enter"], + ["Exit", "Escape"], + ]); + expect(menu?.nav.upDown).toBe(true); + }); + + it("changes its signature when the highlight moves", () => { + expect(menuOf("muse--resume-picker-moved.txt")?.signature).not.toBe(menuOf("muse--resume-picker.txt")?.signature); + }); +}); diff --git a/web/src/lib/harness/muse/index.ts b/web/src/lib/harness/muse/index.ts index f7b1ed729..2b78e3b99 100644 --- a/web/src/lib/harness/muse/index.ts +++ b/web/src/lib/harness/muse/index.ts @@ -65,10 +65,11 @@ import { regionReachesTail, stripChrome, } from "./chrome"; -import { askHeaderDirectlyAbove, boxHoldsNoDraft, isVoiceRule, locateTail, rstrip } from "./markers"; +import { askHeaderDirectlyAbove, boxHoldsNoDraft, isVoiceRule, locateTail, regionSignature, rstrip } from "./markers"; import { museDraftIsOpaque, musePasteCarriesSend } from "./paste"; import { detectQuestionRegion } from "./question"; import { detectTrustRegion } from "./trust"; +import { detectSessionPicker } from "../session-picker"; /** * Muse's pane → blocks. The four dialog arms run in the build order of the dialog notes (approval → @@ -127,6 +128,17 @@ export function museBuildBlocks(lines: StyledLine[]): Block[] { lines: lines.slice(trust.startLine), }); } + // The highlighted session row starts at column 0 (`⟩ 1h ago oak-thebe · …`); the search + // field's ` ⟩ Search` is indented, so it never counts as a session. + const texts = lines.map((l) => rstrip(lineText(l))); + const picker = detectSessionPicker(texts, /^⟩ \S/, (from, to) => regionSignature(texts, from, to)); + if (picker !== null) { + return lift(lines, picker.startLine, picker.model.signature, { + kind: "menu", + menu: picker.model, + lines: lines.slice(picker.startLine), + }); + } return unlifted(lines); } diff --git a/web/src/lib/harness/opencode.test.ts b/web/src/lib/harness/opencode.test.ts index ae87a77b6..8433a2445 100644 --- a/web/src/lib/harness/opencode.test.ts +++ b/web/src/lib/harness/opencode.test.ts @@ -1540,3 +1540,22 @@ describe("opencode question dialog chrome", () => { } }); }); + +describe("opencode: the Update Available confirm dialog holds the keyboard (1.18.34)", () => { + // `Confirm` is the highlighted button there, so Enter would install the update: a reply must never + // be typed into this screen. Escape dismisses it (live 2026-10-15), which the card offers. + const lines = () => loadLines("oc--update-available.txt"); + + it("refuses the composer and binds nothing", () => { + expect(hasComposer(lines())).toBe(false); + expect(composerPrompt(lines())).toBeNull(); + }); + + it("counts as a modal, so the card offers Escape", () => { + expect(modalOnScreen(lines())).toBe(true); + }); + + it("is not a picker: no Search row", () => { + expect(pickerOverlayUp(lines())).toBe(false); + }); +}); diff --git a/web/src/lib/harness/opencode/chrome.ts b/web/src/lib/harness/opencode/chrome.ts index 6734477af..e84e7a14e 100644 --- a/web/src/lib/harness/opencode/chrome.ts +++ b/web/src/lib/harness/opencode/chrome.ts @@ -323,11 +323,12 @@ export function extractInputDraft(lines: StyledLine[]): string | null { * * A picker paints OVER the middle of the screen and can leave the composer's own tail intact — so * the tail shape alone would answer `true` on a screen whose keyboard the picker owns. - * `pickerOverlayUp` is the predicate that says so. + * `pickerOverlayUp` is the predicate that says so, and `confirmDialogUp` says the same of a confirm + * dialog. */ export function hasComposer(lines: StyledLine[]): boolean { if (locateComposer(lines) === null) return false; - return !pickerOverlayUp(lines); + return !overlayUp(lines); } /** A row cut into its 2+-space-separated tokens, each with the display column it starts at. */ @@ -376,6 +377,44 @@ export function pickerOverlayUp(lines: StyledLine[]): boolean { return false; } +/** How far below a confirm dialog's title row its buttons may sit: three on the update prompt (two + * body rows and a blank), with room for a longer body. */ +const MAX_TITLE_TO_BUTTONS = 6; +/** How far left of the `esc` hint's end the `Confirm` label may end: the highlighted button pads its + * label with one space inside its own background. */ +const BUTTON_PAD = 2; + +/** + * Whether one of opencode's confirm dialogs is up: the `Update Available` prompt, measured on 1.18.34 + * (2026-10-15). It shares the pickers' title row (`Update Available … esc`) but has no search field: + * a body, then a button row whose `Confirm` ends in the `esc` hint's column, `Skip Confirm`, with + * Confirm highlighted. Enter there installs the update, so a reply must never be typed into it; + * Escape dismisses it. Keyed on columns for the reason `pickerOverlayUp` is: the screen under the box + * shows through on both sides. + */ +export function confirmDialogUp(lines: StyledLine[]): boolean { + const rows = lines.map((l) => columnTokens(rstrip(lineText(l)))); + for (let i = 0; i < rows.length; i++) { + const row = rows[i]!; + for (let k = 1; k < row.length; k++) { + if (row[k]!.text !== "esc" || row[k - 1]!.text === "esc") continue; + const escEnd = row[k]!.col + displayWidth("esc"); + for (let j = i + 1; j < rows.length && j - i <= MAX_TITLE_TO_BUTTONS; j++) { + const confirm = rows[j]!.find((t) => t.text.endsWith("Confirm")); + if (confirm === undefined) continue; + const end = confirm.col + displayWidth(confirm.text); + if (end <= escEnd && escEnd - end <= BUTTON_PAD) return true; + } + } + } + return false; +} + +/** A picker or a confirm dialog floats over the screen and owns the keyboard. */ +function overlayUp(lines: StyledLine[]): boolean { + return pickerOverlayUp(lines) || confirmDialogUp(lines); +} + /** How many non-blank rows from the tail a dialog footer may sit: the permission steps paint one * bare bar row under it. The same window the dialog lift allows (dialog.ts). */ const MODAL_FOOTER_WINDOW = 3; @@ -384,15 +423,15 @@ const MODAL_FOOTER_WINDOW = 3; * Positive evidence that one of opencode's own modals is up — the fifth condition of the * unread-dialog card (.adr/0053, addendum 2026-09-26). `composerReady` answering false says only * that no composer is there, which is also what the shell looks like while opencode starts and - * after it exits; the card must not offer Escape there. Three shapes count: a picker - * (`pickerOverlayUp`), a permission dialog painted in the bar run, whose footer — a bar row + * after it exits; the card must not offer Escape there. Four shapes count: a picker + * (`pickerOverlayUp`), a confirm dialog (`confirmDialogUp`), a permission dialog painted in the bar run, whose footer — a bar row * carrying `⇆ select` and `enter confirm` — sits at the tail (1.18.32), and a question dialog, * whose footer is a bar row carrying `esc dismiss` after `enter submit|toggle|confirm` (1.18.33). * The question footer counts on EVERY question screen, the ones the grammar refuses included (a tab * bar, a long list): the card and the composer lock must work on exactly those. */ export function modalOnScreen(lines: StyledLine[]): boolean { - if (pickerOverlayUp(lines)) return true; + if (overlayUp(lines)) return true; let seen = 0; for (let i = lines.length - 1; i >= 0 && seen < MODAL_FOOTER_WINDOW; i--) { const text = rstrip(lineText(lines[i]!)); @@ -457,7 +496,7 @@ export function composerPrompt(lines: StyledLine[]): string | null { const tail = locateComposer(lines); // The same screens `composerReady` refuses bind nothing: a sweep never runs there, and naming a // region would hand the conformance leg a binding the pre-flight will not type into. - if (tail === null || pickerOverlayUp(lines)) return null; + if (tail === null || overlayUp(lines)) return null; const rows = lines.slice(tail.modelRow, tail.rule + 1).map((l) => rstrip(lineText(l))); return rows[0]!.length === 0 ? null : rows.join("\n"); } diff --git a/web/src/lib/harness/pi.test.ts b/web/src/lib/harness/pi.test.ts index b1530c39d..196401956 100644 --- a/web/src/lib/harness/pi.test.ts +++ b/web/src/lib/harness/pi.test.ts @@ -37,7 +37,7 @@ describeAdapterConformance(piAdapter, { describe("pi adapter routing", () => { it("lifts every question screen the corpus proves, and no fewer", () => { - expect(lifted).toHaveLength(40); + expect(lifted).toHaveLength(42); }); it.each([ @@ -206,3 +206,54 @@ describe("pi adapter routing", () => { expect(block?.kind === "raw" ? block.lines.length : 0).toBeLessThan(lines.length); }); }); + +describe("pi: the folder trust prompt lifts as a walked trust card (1.2.0)", () => { + const promptOf = (lines: ReturnType) => { + const block = piAdapter.buildBlocks(lines).find((b) => b.kind === "prompt-select"); + return block?.kind === "prompt-select" ? block.prompt : null; + }; + + it("walks from the pointer to each option, and Escape cancels, as the footer names them", () => { + const prompt = promptOf(load("pi--trust-prompt.txt")); + expect(prompt?.family).toBe("trust"); + expect(prompt?.question).toBe("Trust project folder?"); + expect(prompt?.options.map((o) => [o.label, o.keys.join("+")])).toEqual([ + ["Trust", "Enter"], + ["Trust parent folder", "Down+Enter"], + ["Trust (this session only)", "Down+Down+Enter"], + ["Do not trust", "Down+Down+Down+Enter"], + ["Do not trust (this session only)", "Down+Down+Down+Down+Enter"], + ["Cancel", "Escape"], + ]); + expect(piAdapter.composerReady?.(load("pi--trust-prompt.txt"))).toBe(false); + }); + + it("names the parent folder, wrapped on screen, as that option's description", () => { + const parent = promptOf(load("pi--trust-prompt.txt"))?.options[1]; + expect(parent?.description).toMatch(/^\(\/tmp\/collie-pi-sandbox\/\S+\/sandboxdir\)$/); + }); + + it("walks up from a moved pointer; the signature moves and the core does not", () => { + const first = promptOf(load("pi--trust-prompt.txt")); + const moved = promptOf(load("pi--trust-prompt-moved.txt")); + expect(moved?.options.slice(0, 3).map((o) => o.keys.join("+"))).toEqual(["Up+Enter", "Enter", "Down+Enter"]); + expect(moved?.signature).not.toBe(first?.signature); + expect(moved?.coreSignature).toBe(first?.coreSignature); + }); + + it("starts the card at the first option, so the folder and the explanation stay in the mirror", () => { + const [raw, card] = piAdapter.buildBlocks(load("pi--trust-prompt.txt")); + expect(card?.kind === "prompt-select" && card.lines.map((l) => l.segments.map((g) => g.text).join(""))[0]?.trim()).toBe( + "→ Trust", + ); + const mirror = raw?.kind === "raw" ? raw.lines.map((l) => l.segments.map((g) => g.text).join("")).join("\n") : ""; + expect(mirror).toContain("This allows pi to load .pi settings"); + }); + + it("does not lift a list holding an option it does not know", () => { + const rows = load("pi--trust-prompt.txt").map((l) => + l.segments.map((g) => g.text).join("").replace("Do not trust (this session only)", "Trust everything forever "), + ); + expect(promptOf(splitLines(parseAnsi(rows.join("\n"))))).toBeNull(); + }); +}); diff --git a/web/src/lib/harness/pi/ask.test.ts b/web/src/lib/harness/pi/ask.test.ts index 246845aa7..1914e4a6a 100644 --- a/web/src/lib/harness/pi/ask.test.ts +++ b/web/src/lib/harness/pi/ask.test.ts @@ -67,6 +67,9 @@ const LIFTS_397 = new Map([ // The same question beside OmO's side panel: read from the rows cut at the panel, and bound to // those columns (panel.ts, index.ts). ["pi--omo-v5130-panel-question.txt", "digit"], + // pi 1.2.0's folder trust prompt: not a question, read by pi/trust.ts, plan (a) from its footer. + ["pi--trust-prompt.txt", "walk"], + ["pi--trust-prompt-moved.txt", "walk"], ]); /** Screens with a pi modal up that stay raw: the Escape card's evidence. */ const RAW_MODALS = new Set([ diff --git a/web/src/lib/harness/pi/index.ts b/web/src/lib/harness/pi/index.ts index d65b2ac7c..d609b4d02 100644 --- a/web/src/lib/harness/pi/index.ts +++ b/web/src/lib/harness/pi/index.ts @@ -13,6 +13,7 @@ import type { HarnessAdapter } from "../types"; import { detectPiQuestion, piModalOnScreen, type PiQuestion } from "./ask"; import { composerDraft, composerPrompt, composerStatusLines, locateComposer } from "./composer"; import { piView, type PiView } from "./panel"; +import { detectPiTrust } from "./trust"; export function piBuildBlocks(lines: StyledLine[]): Block[] { const view = piView(lines); @@ -29,6 +30,14 @@ export function piBuildBlocks(lines: StyledLine[]): Block[] { ); return blocks; } + // The trust prompt fills the screen before any editor or side panel exists, so it is read whole. + const trust = view.column === null ? detectPiTrust(lines) : null; + if (trust !== null) { + const before = trimTrailingBlank(lines.slice(0, trust.startLine)); + const blocks: Block[] = before.length > 0 ? [{ kind: "raw", lines: before }] : []; + blocks.push({ kind: "prompt-select", prompt: trust.model, lines: lines.slice(trust.startLine) }); + return blocks; + } // The editor comes off the mirror only when it is the editor: a modal can fill the same rules with // rows the stock layout would accept (pi's `/model` picker opens with its `>` search row, // pi--v110-core-selector.txt), and the mirror must keep it. @@ -84,5 +93,6 @@ export const piAdapter: HarnessAdapter = { // (pi--v110-core-selector.txt). Offered by name: on an answer editor it steps back, it does not end // the question. cancelKey: "Escape", - modalOnScreen: (lines) => piModalOnScreen(piView(lines).lines), + // The trust prompt ends at its bottom rule with no status rows under it, so it is not a pi frame. + modalOnScreen: (lines) => piModalOnScreen(piView(lines).lines) || detectPiTrust(lines) !== null, }; diff --git a/web/src/lib/harness/pi/trust.ts b/web/src/lib/harness/pi/trust.ts new file mode 100644 index 000000000..c1c3f59f8 --- /dev/null +++ b/web/src/lib/harness/pi/trust.ts @@ -0,0 +1,131 @@ +// pi's folder trust prompt, the whole screen pi 1.2.0 paints before its editor in a folder that holds +// project resources it would load (live 2026-10-15): +// +// ────────────────────────────────────── top rule +// Trust project folder? +// /the/folder/that/wraps/at/the/pane's/ +// width a long path is hard-wrapped, mid-word +// This allows pi to load .pi settings … one row, cut at the width +// → Trust +// Trust parent folder +// (/the/parent/that/also/wraps/at/the/ the parent, wrapped under its option at column 1 +// width) +// Trust (this session only) +// Do not trust +// Do not trust (this session only) +// ↑↓ navigate enter select escape/ctrl+c cancel +// ────────────────────────────────────── bottom rule, the last row +// +// The options are not numbered, so the question reader (ask.ts) declines it. The plan is ask.ts's plan +// (a), taken from the footer the same way (.adr/0098): Up/Down named as moving and Enter as select make +// a walked pointer list (ADR 0080), and the footer's Escape verb is the last button. Measured on that +// build: Down moved `→` one row, Escape went on with the folder untrusted for the session and wrote +// nothing, and Enter on an option applied it. Every label must be one this prompt is known to print a +// form of, so a changed prompt stays raw. Pure; no pane access. +import { lineText, type StyledLine } from "../../blocks"; +import { pointerWalk } from "../menu-hints"; +import type { PromptModel, PromptOption } from "../prompt-model"; +import { parsePiFooter } from "./footer"; + +export interface PiTrustRegion { + model: PromptModel; + startLine: number; +} + +const RULE = /^─{8,}$/; +const TITLE = /^ (Trust project folder\?)$/; +// The pointer stands at column 1 and every label at column 3; a row wrapped from the one above starts +// at column 1 with no pointer. +const OPTION = /^ (→| ) (\S.*)$/; +const CONTINUATION = /^ [^\s→]/; +const LABEL = /^(?:Trust|Do not trust)(?: parent folder| \(this session only\))?$/; +// The title, the folder (wrapped), a blank, the explanation and a blank. +const MAX_HEADER_ROWS = 12; +// Blank rows between the options, the footer and the bottom rule: one each in the capture. +const MAX_GAP = 2; + +interface Row { + row: number; + label: string; + pointed: boolean; + description: string; +} + +/** The trust prompt at the tail, or null. */ +export function detectPiTrust(lines: StyledLine[]): PiTrustRegion | null { + const texts = lines.map((l) => lineText(l).trimEnd()); + let last = texts.length - 1; + while (last >= 0 && texts[last] === "") last--; + if (last < 0 || !RULE.test(texts[last]!)) return null; + + const footerRow = aboveGap(texts, last - 1); + if (footerRow < 0) return null; + const footer = parsePiFooter(texts[footerRow]!); + if (footer === null || !footer.arrowsMove || footer.enter !== "select" || footer.escapeVerb === null) return null; + + const bottom = aboveGap(texts, footerRow - 1); + if (bottom < 0) return null; + let first = bottom; + while (first > 0 && texts[first - 1] !== "") first--; + + const rows: Row[] = []; + for (let i = first; i <= bottom; i++) { + const option = OPTION.exec(texts[i]!); + if (option !== null) { + rows.push({ row: i, label: option[2]!, pointed: option[1] === "→", description: "" }); + continue; + } + const previous = rows.at(-1); + if (previous === undefined || !CONTINUATION.test(texts[i]!)) return null; + // pi hard-wraps at the pane's width, mid-word, so the pieces join with nothing between them. + previous.description += texts[i]!.slice(1); + } + if (rows.length < 2 || !rows.every((r) => LABEL.test(r.label))) return null; + const pointed = rows.findIndex((r) => r.pointed); + if (pointed < 0 || rows.filter((r) => r.pointed).length !== 1) return null; + + let title = -1; + for (let i = first - 1; i >= 0 && first - i <= MAX_HEADER_ROWS; i--) { + if (TITLE.test(texts[i]!)) { + title = i; + break; + } + } + if (title < 0) return null; + const top = aboveGap(texts, title - 1); + if (top < 0 || !RULE.test(texts[top]!)) return null; + + const options: PromptOption[] = rows.map((r, i) => { + const option: PromptOption = { label: r.label, keys: pointerWalk(pointed, i), keyLabel: i === pointed ? "→" : "" }; + if (r.description !== "") option.description = r.description; + return option; + }); + const verb = footer.escapeVerb; + options.push({ label: verb.charAt(0).toUpperCase() + verb.slice(1), keys: ["Escape"], keyLabel: "Esc" }); + + const region = texts.slice(top, last + 1); + return { + // The card prints the question alone, so it starts at the first option: the folder and the + // explanation stay in the mirror above it rather than vanish from both. + startLine: first, + model: { + question: TITLE.exec(texts[title]!)![1]!, + options, + family: "trust", + signature: region.join("\n"), + // The pointer is the only thing a walk changes; the rest is the prompt's identity. + coreSignature: region.map((t) => t.replace(/^ → /, " ")).join("\n"), + }, + }; +} + +/** The nearest non-blank row at or above `from`, across at most MAX_GAP blank rows, or -1. */ +function aboveGap(texts: string[], from: number): number { + let i = from; + let gap = 0; + while (i >= 0 && texts[i] === "") { + i--; + if (++gap > MAX_GAP) return -1; + } + return i; +} diff --git a/web/src/lib/harness/session-picker.ts b/web/src/lib/harness/session-picker.ts new file mode 100644 index 000000000..fe68f20df --- /dev/null +++ b/web/src/lib/harness/session-picker.ts @@ -0,0 +1,89 @@ +// The SESSION PICKER that Codex and Muse Code both paint for `/resume` (Codex 0.159, Muse 1.4.8, live +// 2026-10-15): a `Resume a previous session` title, filter rows, one row per session with the pointer +// on the highlighted one (`›` in Codex, `⟩` in Muse), and a two-row footer of ` ` hints +// joined by runs of spaces: +// +// enter resume ctrl+a archive esc exit ctrl+c exit tab focus sort/filter ←/→ change option +// ctrl+o comfortable view ctrl+t transcript ctrl+e expand ↑/↓ browse +// +// It lifts as a generic `menu` (menu-model.ts) carrying only the keys a phone may fire safely: Up and +// Down to move, Enter to resume, Escape to leave. `ctrl+a archive` takes a session away and +// `ctrl+c exit` can quit the agent, so neither becomes a button; the view toggles stay keyboard-only. +// Shared here because the two adapters paint the same picker; each passes its own pointer and its +// own region signature, so neither imports the other. +import type { MenuModel } from "./menu-model"; +import { capitaliseMenuLabel } from "./menu-hints"; + +/** The detection result buildBlocks needs: the model plus the title row everything above stays raw. */ +export interface SessionPickerRegion { + model: MenuModel; + startLine: number; +} + +const TITLE = /^\s*Resume a previous session\s*$/; +// Each hint is a whole segment: the run of spaces (or the row's edge) on both sides is what keeps +// `enter resume` from matching inside a session's title. +const hint = (text: string) => new RegExp(String.raw`(?:^\s*|\s{2,})${text}(?=\s{2,}|\s*$)`); +const RESUME = hint("enter resume"); +const EXIT = hint("esc exit"); +const BROWSE = hint(String.raw`↑/↓ browse`); +// The title sits above the filter rows and at most a screenful of sessions. +const TITLE_SCAN_ROWS = 40; +// Non-blank rows above the browse row that may still belong to the footer. +const FOOTER_ROWS = 2; + +/** + * Detect the session picker at the tail of `texts`, or null. `pointer` matches the highlighted session + * row, `signature(from, to)` is the adapter's region signature over rows `[from, to]`. + * + * Fail-closed: the last two non-blank rows must name `enter resume`, `esc exit` and `↑/↓ browse`, the + * title must sit above them, and a session row must be highlighted. An empty list has no pointer, so + * it stays raw rather than offering Resume over nothing. + */ +export function detectSessionPicker( + texts: readonly string[], + pointer: RegExp, + signature: (from: number, to: number) => string, +): SessionPickerRegion | null { + let last = texts.length - 1; + while (last >= 0 && texts[last]!.trim() === "") last--; + if (last < 0 || !BROWSE.test(texts[last]!)) return null; + // The resume row is the footer's first row; a narrow pane can wrap the footer onto a third row, so + // it may sit up to two non-blank rows above the browse row. + let above = -1; + let seen = 0; + for (let i = last - 1; i >= 0 && seen < FOOTER_ROWS; i--) { + if (texts[i]!.trim() === "") continue; + seen++; + if (RESUME.test(texts[i]!) && EXIT.test(texts[i]!)) { + above = i; + break; + } + } + if (above < 0) return null; + + let top = -1; + for (let i = above - 1; i >= 0 && above - i <= TITLE_SCAN_ROWS; i--) { + if (TITLE.test(texts[i]!)) { + top = i; + break; + } + } + if (top < 0) return null; + if (!texts.slice(top + 1, above).some((row) => pointer.test(row))) return null; + + const region = signature(top, last); + if (region === "") return null; + return { + model: { + title: texts[top]!.trim(), + actions: [ + { label: capitaliseMenuLabel("resume"), keys: ["Enter"] }, + { label: capitaliseMenuLabel("exit"), keys: ["Escape"], cancel: true }, + ], + nav: { upDown: true }, + signature: region, + }, + startLine: top, + }; +} diff --git a/web/src/lib/harness/unread-dialog.test.ts b/web/src/lib/harness/unread-dialog.test.ts index 16da6c560..74dc7c9a1 100644 --- a/web/src/lib/harness/unread-dialog.test.ts +++ b/web/src/lib/harness/unread-dialog.test.ts @@ -268,13 +268,15 @@ const CARD_FIXTURES = { // tab: question.ts and question-tabs.ts, issue 329) lift their buttons and get no card. What stays // raw is a real modal with `esc dismiss` in its footer, so it gets the card (ADR 0053): a // multi-select whose free-text input is open (a digit would be typed as text) and a list too long - // for a digit (fourteen options). + // for a digit (fourteen options). The Update Available confirm dialog is a modal too: Confirm is the + // highlighted button, so the composer is refused, and Escape dismisses it. opencode: { modals: [ "oc--agents-picker.txt", "oc--command-palette.txt", "oc--question--multi--free-text.txt", "oc--question--tall14.txt", + "oc--update-available.txt", ], notModals: [], }, diff --git a/web/src/lib/harness/walk-pairs.ts b/web/src/lib/harness/walk-pairs.ts index 71f62430a..96232f735 100644 --- a/web/src/lib/harness/walk-pairs.ts +++ b/web/src/lib/harness/walk-pairs.ts @@ -115,6 +115,8 @@ export const WALK_PAIRS: readonly WalkPair[] = [ ["pi", "pi--v110-questionnaire-tabs-second.txt", "pi--v110-questionnaire-tabs-second-moved.txt"], // The PR 397 question extension (pi 1.1.0): its one captured pair. ["pi", "pi--v110-single-desc.txt", "pi--v110-single-moved.txt"], + // pi 1.2.0's folder trust prompt (pi/trust.ts), before and after one Down. + ["pi", "pi--trust-prompt.txt", "pi--trust-prompt-moved.txt"], ]; /**