From bcb6febabed166fffe8606f04326472886a97db9 Mon Sep 17 00:00:00 2001 From: Altan Sarisin Date: Wed, 15 Jul 2026 23:11:30 +0200 Subject: [PATCH 1/3] feat: pluggable harness adapters + native multiSelect AskUserQuestion (0.11.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single Claude-only block-grammar gate with a HarnessAdapter registry so open-source contributors can add codex/pi/opencode. Claude's detectors move from lib/grammar/ to lib/harness/claude/; the model-generic race-guard engine (lib/harness/guard.ts) is the ONLY module allowed to touch the network, enforced by an import fence (fence.test.ts) plus a per-adapter conformance suite (conformance.ts). Contributor guide in HARNESS_CONTRIBUTING.md. Add native multiSelect AskUserQuestion (the checkbox form): options up-level to tappable checkbox rows with the terminal as the single source of truth (a digit is an XOR, no optimistic local state). Submit is a closed-loop macro that walks the pointer onto the "Submit" row and verifies a fresh read before Enter — it never blind-sends — then the review/confirm screen. Hardening from a 4-pass Fable review: - serialize actions per pane so two overlapping Submit macros can't both auto-confirm past the review screen (module-scoped in-flight lock) - the mid-walk identity check includes checkbox state, so an external flip aborts rather than shipping a set the user never saw - toggle digits validate against the model; the detector requires a real "Submit" row and bails past 9 options; the incomplete-answers warning is role="alert" UI: interactive prompts render in a bordered panel lifted off the terminal mirror (elevated rows, key-digit badges); Keys/Quick menus dock in-flow above the controls row and a ResizeObserver re-pins the shrunk mirror to the bottom so the prompt stays visible; the "Sent" toast moves to a slim row below the header; the build stamp marks a dirty tree (-dirty). Fix a numbered-list-in-body detection bug in prompt-select + wizard: the menu is taken as the trailing 1..m run, so plan-approval prompts up-level correctly. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 17 + HARNESS_CONTRIBUTING.md | 95 +++++ herdr-plugin.toml | 2 +- package.json | 2 +- web/eslint.config.mjs | 46 +++ web/package.json | 2 +- web/src/components/agent-chat.tsx | 81 ++++- web/src/components/ansi-output.tsx | 25 +- web/src/components/composer.test.tsx | 132 ++++++- web/src/components/composer.tsx | 86 +++-- .../components/multi-select-block.test.tsx | 93 +++++ web/src/components/multi-select-block.tsx | 188 ++++++++++ web/src/components/nav-tray.tsx | 2 +- web/src/components/option-button.tsx | 139 ++++++++ .../components/preview-select-block.test.tsx | 6 +- web/src/components/preview-select-block.tsx | 39 ++- .../components/prompt-select-block.test.tsx | 7 +- web/src/components/prompt-select-block.tsx | 72 ++-- web/src/components/quick-actions.tsx | 23 +- web/src/components/wizard-block.test.tsx | 7 +- web/src/components/wizard-block.tsx | 48 ++- web/src/fixtures/panes/README.md | 4 + .../claude--plan-approval--numbered-body.txt | 49 +++ .../claude--select-multiselect-checked.txt | 49 +++ .../claude--select-multiselect-review.txt | 47 +++ .../claude--select-multiselect-single.txt | 49 +++ web/src/hooks/use-auto-scroll.test.tsx | 82 +++++ web/src/hooks/use-auto-scroll.ts | 16 + web/src/lib/blocks.test.ts | 3 +- web/src/lib/blocks.ts | 131 +++---- web/src/lib/grammar/agents.test.ts | 21 -- web/src/lib/grammar/agents.ts | 16 - .../claude}/chrome.test.ts | 6 +- .../lib/{grammar => harness/claude}/chrome.ts | 2 +- web/src/lib/harness/claude/index.ts | 85 +++++ .../claude}/markers.test.ts | 4 +- .../{grammar => harness/claude}/markers.ts | 2 +- .../lib/harness/claude/multi-select.test.ts | 220 ++++++++++++ web/src/lib/harness/claude/multi-select.ts | 330 ++++++++++++++++++ .../claude}/preview-select.test.ts | 7 +- .../claude}/preview-select.ts | 2 +- .../claude}/prompt-select.test.ts | 34 +- .../claude}/prompt-select.ts | 55 ++- .../claude}/wizard.test.ts | 6 +- .../lib/{grammar => harness/claude}/wizard.ts | 30 +- web/src/lib/harness/conformance.test.ts | 65 ++++ web/src/lib/harness/conformance.ts | 253 ++++++++++++++ web/src/lib/harness/fence.test.ts | 73 ++++ web/src/lib/harness/guard.ts | 147 ++++++++ web/src/lib/harness/index.ts | 21 ++ web/src/lib/harness/registry.test.ts | 32 ++ web/src/lib/harness/registry.ts | 28 ++ web/src/lib/harness/types.ts | 26 ++ web/src/lib/multi-select-action.test.ts | 324 +++++++++++++++++ web/src/lib/multi-select-action.ts | Bin 0 -> 12404 bytes web/src/lib/preview-action.test.ts | 2 +- web/src/lib/preview-action.ts | 128 ++----- web/src/lib/prompt-action.ts | 37 +- web/src/lib/wizard-action.ts | 22 +- web/vite.config.ts | 27 +- 60 files changed, 3107 insertions(+), 440 deletions(-) create mode 100644 HARNESS_CONTRIBUTING.md create mode 100644 web/eslint.config.mjs create mode 100644 web/src/components/multi-select-block.test.tsx create mode 100644 web/src/components/multi-select-block.tsx create mode 100644 web/src/components/option-button.tsx create mode 100644 web/src/fixtures/panes/claude--plan-approval--numbered-body.txt create mode 100644 web/src/fixtures/panes/claude--select-multiselect-checked.txt create mode 100644 web/src/fixtures/panes/claude--select-multiselect-review.txt create mode 100644 web/src/fixtures/panes/claude--select-multiselect-single.txt create mode 100644 web/src/hooks/use-auto-scroll.test.tsx delete mode 100644 web/src/lib/grammar/agents.test.ts delete mode 100644 web/src/lib/grammar/agents.ts rename web/src/lib/{grammar => harness/claude}/chrome.test.ts (97%) rename web/src/lib/{grammar => harness/claude}/chrome.ts (99%) create mode 100644 web/src/lib/harness/claude/index.ts rename web/src/lib/{grammar => harness/claude}/markers.test.ts (97%) rename web/src/lib/{grammar => harness/claude}/markers.ts (98%) create mode 100644 web/src/lib/harness/claude/multi-select.test.ts create mode 100644 web/src/lib/harness/claude/multi-select.ts rename web/src/lib/{grammar => harness/claude}/preview-select.test.ts (97%) rename web/src/lib/{grammar => harness/claude}/preview-select.ts (99%) rename web/src/lib/{grammar => harness/claude}/prompt-select.test.ts (82%) rename web/src/lib/{grammar => harness/claude}/prompt-select.ts (78%) rename web/src/lib/{grammar => harness/claude}/wizard.test.ts (98%) rename web/src/lib/{grammar => harness/claude}/wizard.ts (91%) create mode 100644 web/src/lib/harness/conformance.test.ts create mode 100644 web/src/lib/harness/conformance.ts create mode 100644 web/src/lib/harness/fence.test.ts create mode 100644 web/src/lib/harness/guard.ts create mode 100644 web/src/lib/harness/index.ts create mode 100644 web/src/lib/harness/registry.test.ts create mode 100644 web/src/lib/harness/registry.ts create mode 100644 web/src/lib/harness/types.ts create mode 100644 web/src/lib/multi-select-action.test.ts create mode 100644 web/src/lib/multi-select-action.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d55dcdd..b870ba9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ All notable changes to Collie are recorded here. The format follows `version` in `herdr-plugin.toml`, `package.json`, and `web/package.json` (enforced by `scripts/check-version.sh`). See [`CLAUDE.md`](./CLAUDE.md) → *Versioning* for the bump policy. +## [0.11.0] - 2026-07-15 + +### Added +- Pluggable harness-adapter architecture: a `HarnessAdapter` registry replaces the single Claude-only gate, Claude's detectors move to `lib/harness/claude/`, and a core race-guard engine (`lib/harness/guard.ts`) is the only module that may touch the network — an import fence (enforced by `fence.test.ts` under `bun run test`) + a conformance suite let contributors add codex/pi/opencode (see `HARNESS_CONTRIBUTING.md`) +- multiSelect AskUserQuestion support: checkbox options up-level to tappable checkbox rows (terminal is source of truth), with a closed-loop Submit that navigates the pointer to Submit and verifies before Enter (never blind-sends), plus the review/confirm screen +- Prompt overlay: interactive prompts render in a bordered `bg-card` panel that lifts the whole dialog off the terminal mirror, with elevated option rows, leading key-digit badges, and a family-aware caption + +### Changed +- Keys and Quick menus dock in-flow above the controls row instead of a fixed overlay, so the terminal mirror shrinks and re-pins to the bottom (ResizeObserver) — the prompt/cursor stays visible; both buttons are toggles +- Prompt option rows compacted (tighter padding, snug line-height) so a multi-option dialog fits the phone viewport +- "Sent" status toast moved from a bottom overlay (which covered the terminal tail) to a slim in-flow row below the header +- Build stamp marks a dirty working tree (`-dirty`), so the footer no longer claims HEAD when the build carries uncommitted work +- multiSelect Submit is ~2s instead of ~15s: the pointer walk re-reads the actual position each step and stops on "Submit", instead of polling for the bottom row after every key (which timed out ~2.8s per step) + +### Fixed +- Prompt-select + wizard grammars: a numbered list in a dialog body (e.g. a plan's steps) no longer breaks menu detection — the menu is taken as the trailing `1..m` run, so plan-approval prompts up-level correctly + ## [0.10.3] - 2026-07-12 ### Fixed diff --git a/HARNESS_CONTRIBUTING.md b/HARNESS_CONTRIBUTING.md new file mode 100644 index 0000000..06bab39 --- /dev/null +++ b/HARNESS_CONTRIBUTING.md @@ -0,0 +1,95 @@ +# Adding a harness adapter + +Collie up-levels an agent's terminal dialogs (permission prompts, AskUserQuestion menus, plan +approvals, …) into native phone buttons. The per-agent knowledge that makes this safe lives in a +**harness adapter**. Claude Code is the one verified adapter today; this is how you add another +(codex, pi, opencode, …). + +Read first: [`ARCHITECTURE.md`](./ARCHITECTURE.md) (the interaction loop + security model), +[`HERDR_API.md`](./HERDR_API.md) (the verified socket + `pane.send_keys` key grammar), and +[`web/src/fixtures/panes/README.md`](./web/src/fixtures/panes/README.md) (the fixture corpus). + +## Architecture in one paragraph + +An adapter is a [`HarnessAdapter`](./web/src/lib/harness/types.ts) — +`{ agent, buildBlocks, extractStatusLine, extractInputDraft }` — registered by its Herdr `agent` +string in [`web/src/lib/harness/registry.ts`](./web/src/lib/harness/registry.ts). The registry is the +single decision site for "which agents get grammars"; every agent absent from it keeps the universal +raw terminal mirror. Claude is the reference adapter, under +[`web/src/lib/harness/claude/`](./web/src/lib/harness/claude/): its detectors (prompt-select, wizard, +preview-select, chrome, markers) are **pure functions over `StyledLine[]`** — no pane access, no +network. Detection only says "this dialog is on screen"; the keystroke recipes and the race-guard +that actually types live elsewhere. [`guard.ts`](./web/src/lib/harness/guard.ts) is the **only** module +in `harness/` allowed to touch the network (it re-fetches the pane before a guarded keystroke) — a +**capability fence** (see below) enforces that every other harness module stays I/O-pure, because a +socket call types into a live terminal. + +## Fixtures-first workflow + +Detectors are developed and gated entirely against **byte-faithful pane captures** — never guessed +from screenshots. The loop: + +1. In a **sandbox pane** (a scratch agent, never a real work session), drive the agent into the dialog + state you want to lift. +2. Capture it byte-for-byte: + ```sh + scripts/capture-fixture.sh # paneIds: GET /api/snapshot + ``` + The capture is real terminal output and **this repo is public** — review every file for secrets + before `git add` (`less -R`), per + [`web/src/fixtures/panes/README.md`](./web/src/fixtures/panes/README.md). +3. Write a **pure detector** over `StyledLine[]` and test it against the fixture through the real + `parseAnsi → splitLines` pipeline (copy the shape of + [`claude/prompt-select.test.ts`](./web/src/lib/harness/claude/prompt-select.test.ts)). Anchor + detection on the **buffer tail** — a dialog that has scrolled up (real output below it) must not + match. That tail invariant is the core false-positive guard. + +## The capability tier ladder + +An adapter earns capability incrementally. Ship a lower tier first; each is independently useful. + +- **Tier 0 — raw mirror.** Every agent gets this for free: the colored terminal mirror + slash palette + + special-keys pad. No adapter needed. It already works. +- **Tier 1 — read-only lift.** Chrome/status/draft extraction (`extractStatusLine`, + `extractInputDraft`) plus **detection of a NEW, not-yet-wired block kind** — recognised and drawn, + but with no keystroke recipe behind it, so taps send **no keystrokes**. Mergeable **from fixtures + alone**: a mis-parse only costs cosmetics because there is no send path to fire into a terminal. + **Caveat:** this holds only for a brand-new kind. If your adapter emits an EXISTING interactive kind + (`prompt-select` / `wizard` / `multi-select`), its keystroke recipe is already live, so those taps + go hot the moment your detector matches — that is automatically **Tier 2** and must clear the full + Tier-2 bar below (corpus, notes, conformance, live-verification), not the read-only one. +- **Tier 2 — interactive.** Wiring taps to keystrokes (the buttons go hot). This is the bar that types + into a real shell, so it requires **all** of: + - a **dated fixture corpus** covering the dialog's states, + - a **choreography notes file** documenting the verified keystroke recipe (à la + [`web/src/lib/grammar/WIZARD_NOTES.md`](./web/src/lib/grammar/WIZARD_NOTES.md)), + - a green **`describeAdapterConformance`** run (the CI gate, below), and + - **maintainer live-verification against a real pane** before the send path is enabled. + +### The fail-closed contract (non-negotiable) + +**A detector MUST return `null` on anything it does not confidently recognise.** A partial lift is a +bug, not a nicety — it types a keystroke into a live terminal. When in doubt, fall back to the raw +mirror; the user can always drive Tier 0 by hand. Never up-level a dialog you can't fully model (e.g. +a menu numbered past 9, whose option would need the unsendable key `"10"` — bail to raw instead). + +## The two gates + +- **CI gate — the conformance suite.** + [`web/src/lib/harness/conformance.ts`](./web/src/lib/harness/conformance.ts) exports + `describeAdapterConformance(adapter, { ownFixtures, foreignFixtures, neutralFixtures })`. Call it + from your adapter's `*.test.ts` (see + [`conformance.test.ts`](./web/src/lib/harness/conformance.test.ts)). It asserts three invariants: + conservative detection (raw-only on foreign + neutral buffers), tail-anchoring (a dialog lifts only + at the tail), and key-grammar validity (every emittable keystroke passes `isValidHerdrKey` — the + verified `pane.send_keys` grammar: single-digit only, `ctrl+c` not `C-c`, no + `PageUp`/`Home`/`End`/`Delete`). +- **Safety gate — the capability fence.** The live enforcement is + [`web/src/lib/harness/fence.test.ts`](./web/src/lib/harness/fence.test.ts): it fails the build if any + module under `harness/` except `guard.ts` imports the network API (`@/lib/api` or a relative + `…/api`), matching the specifier anywhere in the file so a Prettier line-wrapped import can't slip + through. It runs under `bun run test` (which the pre-push hook runs). The `no-restricted-imports` + rule in [`web/eslint.config.mjs`](./web/eslint.config.mjs) encodes the same fence but is + **aspirational** — no ESLint runner is wired yet, so it does not execute; the test is the real gate. + +Run both — and the full suite — with `cd web && bun run test`; typecheck with `bunx tsc --noEmit`. diff --git a/herdr-plugin.toml b/herdr-plugin.toml index 5d92c95..9749c5c 100644 --- a/herdr-plugin.toml +++ b/herdr-plugin.toml @@ -1,6 +1,6 @@ id = "herdr.collie" name = "Collie" -version = "0.10.3" +version = "0.11.0" min_herdr_version = "0.7.0" description = "Mobile web UI to monitor and reply to your agent herd, served over Tailscale" platforms = ["linux", "macos"] diff --git a/package.json b/package.json index 5e70ef0..9b297f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "collie", - "version": "0.10.3", + "version": "0.11.0", "private": true, "license": "MIT", "description": "Collie — a mobile web UI to monitor and reply to your Herdr agent herd over Tailscale", diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs new file mode 100644 index 0000000..12494cd --- /dev/null +++ b/web/eslint.config.mjs @@ -0,0 +1,46 @@ +// ESLint flat config — currently a single, targeted rule: the harness network-API fence. +// +// STATUS: this repo does not yet wire an ESLint runner. `eslint` (and a TypeScript parser such as +// `typescript-eslint`) are NOT in web/package.json devDependencies, and there is no `lint` script, so +// this config does not execute in CI today — it is ASPIRATIONAL. The REAL, live enforcement of this +// fence is `src/lib/harness/fence.test.ts`, which re-implements the same rule as a Vitest test and +// runs under `bun run test` (the pre-push hook). This config is declared here so the architectural +// fence lands WITH the harness refactor and takes over the moment a runner is added: +// bun add -d eslint typescript-eslint +// # web/package.json: "lint": "eslint src" +// To actually parse the .ts/.tsx files below, the runner must supply a TypeScript parser (e.g. wrap +// this export in typescript-eslint's `tseslint.config(...)`, or set `languageOptions.parser`). The +// fence rule itself is parser-agnostic — it only inspects `import ... from "…"` specifiers. +// +// THE FENCE: nothing under src/lib/harness/** may import the network API (@/lib/api, or a relative +// ../api / ../../api / ./api path). A socket call types into a REAL terminal; the harness layer is +// DETECTION only and must stay pure of I/O. The SOLE exception is src/lib/harness/guard.ts — the +// model-generic race-guard engine, which legitimately re-fetches the pane (fetchPane) before a +// guarded keystroke. The block below re-allows the network API for guard.ts and nothing else. + +const NETWORK_API_FENCE_MESSAGE = + "harness/ is detection-only — the network API (@/lib/api) is off-limits here. Only harness/guard.ts (the race-guard engine) may re-fetch the pane."; + +const noNetworkApi = { + paths: [{ name: "@/lib/api", message: NETWORK_API_FENCE_MESSAGE }], + patterns: [ + { + group: ["**/api", "**/lib/api", "./api", "../api", "../../api", "../../../api"], + message: NETWORK_API_FENCE_MESSAGE, + }, + ], +}; + +export default [ + { + files: ["src/lib/harness/**/*.{ts,tsx}"], + rules: { "no-restricted-imports": ["error", noNetworkApi] }, + }, + { + // The race-guard engine is the ONE harness module allowed to touch the network (fetchPane). A + // later, more specific config object wins under flat-config merge, so this turns the fence off + // for guard.ts alone. + files: ["src/lib/harness/guard.ts"], + rules: { "no-restricted-imports": "off" }, + }, +]; diff --git a/web/package.json b/web/package.json index 6debe04..624b1a4 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "collie-web", - "version": "0.10.3", + "version": "0.11.0", "private": true, "license": "MIT", "type": "module", diff --git a/web/src/components/agent-chat.tsx b/web/src/components/agent-chat.tsx index f8bc0c5..3c7af99 100644 --- a/web/src/components/agent-chat.tsx +++ b/web/src/components/agent-chat.tsx @@ -12,8 +12,7 @@ import { CollieHome } from "@/components/collie-home"; import { AnsiOutput } from "@/components/ansi-output"; import { parseAnsi } from "@/lib/ansi"; import { splitLines } from "@/lib/blocks"; -import { extractInputDraft, extractStatusLine } from "@/lib/grammar/chrome"; -import { hasBlockGrammar } from "@/lib/grammar/agents"; +import { adapterFor } from "@/lib/harness"; import { FindBar } from "@/components/find-bar"; import { Composer, type ComposerHandle } from "@/components/composer"; import { ThreadSidebar } from "@/components/agent-sidebar"; @@ -27,13 +26,20 @@ import * as api from "@/lib/api"; import { submitPromptOption } from "@/lib/prompt-action"; import { submitWizardKeys } from "@/lib/wizard-action"; import { submitPreviewKeys, submitPreviewNote, submitPreviewOption } from "@/lib/preview-action"; +import { submitMultiSelectIntent, type MultiSelectIntent } from "@/lib/multi-select-action"; import type { PreviewBlockAction } from "@/components/preview-select-block"; import { canGrowRequestedLines, growRequestedLines } from "@/lib/loaders"; import { shortCwd } from "@/lib/format"; import { spacePath } from "@/lib/nav"; import { isReadOnly } from "@/lib/types"; import type { AgentView, DeviceAuth, TabView } from "@/lib/types"; -import type { PreviewSelectModel, PromptModel, PromptOption, WizardModel } from "@/lib/blocks"; +import type { + MultiSelectModel, + PreviewSelectModel, + PromptModel, + PromptOption, + WizardModel, +} from "@/lib/blocks"; interface AgentChatProps { paneId: string; @@ -144,14 +150,14 @@ export function AgentChat({ // The agent's own statusline (model · ctx% · cwd · branch · tokens) is stripped off the mirror by // stripChrome so it doesn't duplicate the composer — but it carries real context (the branch, most // notably), so we re-surface that one line as app chrome just above the composer, where it sat in - // the TUI. Gated by the SAME `hasBlockGrammar` predicate as the chrome-stripping in buildBlocks - // (Claude-only), so the two can't drift; null when a menu is up / no box at the tail, in which case - // the strip is hidden. A second parse of `display`, but memoised on it, so it only recomputes when - // the buffer content changes — off the render hot path. + // the TUI. Routed through the SAME adapter (adapterFor) whose buildBlocks strips the chrome, so the + // two can't drift; null when there's no adapter for the agent, a menu is up, or no box at the tail, + // in which case the strip is hidden. A second parse of `display`, but memoised on it, so it only + // recomputes when the buffer content changes — off the render hot path. const statusLine = useMemo( () => - grammarsOn && hasBlockGrammar(agent?.agent) - ? extractStatusLine(splitLines(parseAnsi(display))) + grammarsOn + ? adapterFor(agent?.agent)?.extractStatusLine(splitLines(parseAnsi(display))) ?? null : null, [display, agent?.agent, grammarsOn], ); @@ -160,12 +166,12 @@ export function AgentChat({ // then recalled, which persists across turns. stripChrome peels the box off the mirror so it goes // invisible, and (worse) pane.send_text appends to it, corrupting the next send. We surface it to // the composer, which offers a one-tap "Edit here" recovery (clear the terminal line, adopt the - // text locally). Same parse source + same Claude-only gate as the statusline, so the two can't - // drift; null when raw-terminal is on, no box is at the tail, or the line is empty/a placeholder. + // text locally). Same parse source + same adapter as the statusline, so the two can't drift; null + // when raw-terminal is on, there's no adapter, no box is at the tail, or the line is empty/a placeholder. const terminalDraft = useMemo( () => - grammarsOn && hasBlockGrammar(agent?.agent) - ? extractInputDraft(splitLines(parseAnsi(display))) + grammarsOn + ? adapterFor(agent?.agent)?.extractInputDraft(splitLines(parseAnsi(display))) ?? null : null, [display, agent?.agent, grammarsOn], ); @@ -361,6 +367,40 @@ export function AgentChat({ [readOnly, paneId, session, requestedLines, shown.revision, revalidator], ); + // Tap a multi-select control (toggle a checkbox, Submit, the "Chat about this" escape, or the + // review screen's confirm/cancel). Same guard-first shape as the wizard handler — the guard + // re-derives the dialog from a FRESH read; toggle sends one digit, Submit drives the closed-loop + // Down→Up→verify→Enter macro (see lib/multi-select-action.ts). gate: claude-only (multi-select + // blocks only ever exist for a Claude pane, buildBlocks gates on ctx.agent). + const handleMultiSelectAction = useCallback( + async (action: MultiSelectIntent, multi: MultiSelectModel) => { + if (readOnly) { + setStatus("Read-only — device not authorised", "error"); + return; + } + const result = await submitMultiSelectIntent({ + paneId, + session, + requestedLines, + detectedRevision: shown.revision, + multi, + intent: action, + }); + if (result.status === "sent") { + setStatus("Sent", "success"); + setFollowing(true); + revalidator.revalidate(); + listRef.current?.scrollToBottom(); + } else if (result.status === "changed") { + setStatus("Selection changed — refreshing", "warn"); + revalidator.revalidate(); + } else { + setStatus(result.error || "Send failed", "error"); + } + }, + [readOnly, paneId, session, requestedLines, shown.revision, revalidator], + ); + // NOTE: the composer is deliberately NOT auto-focused on open/switch — that would pop the Android // keyboard and cover the output. You read the pane first, then tap the input to type. (Explicit // actions inside the composer still focus it; the mirror tap focuses it via composerRef.) @@ -490,6 +530,12 @@ export function AgentChat({ {/* Content region below the header — the mirror inside is the scroller. */}
+ {/* Status line — a slim row pinned directly below the header (NOT the scrolling mirror), so a + "Sent" / "changed" notice reads at the top instead of floating over the terminal tail + (prompt/cursor + up-levelled prompt buttons) it used to cover. Renders nothing — no + reserved space — when idle; auto-dismisses. */} + + {/* Read-only notice when this device isn't allowlisted (the composer below is disabled too). */} @@ -559,6 +605,7 @@ export function AgentChat({ onPromptAction={handlePromptAction} onWizardAction={handleWizardAction} onPreviewAction={handlePreviewAction} + onMultiSelectAction={handleMultiSelectAction} promptDisabled={readOnly || gone} /> @@ -568,12 +615,10 @@ export function AgentChat({
- {/* Bottom region: the status line floats as a slim overlay above the tray + composer, so it - tells you what last happened then vanishes — never pushing or shifting content. */} + {/* Bottom region: the pane-switch handle + composer. The status line USED to float here as an + overlay just above the composer, but it covered the terminal tail (the prompt/cursor and + up-levelled prompt buttons) — it now lives as a slim row just below the header. */}
-
- -
{/* Swipe-up / tap handle for the quick pane switcher — the sheet that switches AND closes panes (each row has a ✕). A tall, full-width hit area so the swipe is easy to land (and a diff --git a/web/src/components/ansi-output.tsx b/web/src/components/ansi-output.tsx index 295f53f..03131b7 100644 --- a/web/src/components/ansi-output.tsx +++ b/web/src/components/ansi-output.tsx @@ -3,20 +3,23 @@ import type { CSSProperties } from "react"; import { cn } from "@/lib/utils"; import { parseAnsi, type AnsiSegment } from "@/lib/ansi"; +import { buildBlocks } from "@/lib/harness"; import { - buildBlocks, splitLines, type Block, + type MultiSelectModel, type PreviewSelectModel, type PromptModel, type PromptOption, type WizardModel, } from "@/lib/blocks"; -import { lineText } from "@/lib/grammar/markers"; +import { lineText } from "@/lib/harness/claude/markers"; import { findMatches, splitSegment, type FindMatch } from "@/lib/find"; import { PromptSelectBlock } from "@/components/prompt-select-block"; import { WizardBlock } from "@/components/wizard-block"; import { PreviewSelectBlock, type PreviewBlockAction } from "@/components/preview-select-block"; +import { MultiSelectBlock } from "@/components/multi-select-block"; +import type { MultiSelectIntent } from "@/lib/multi-select-action"; /** A raw block, narrowed off the Block union (the highlight/offset paths only touch these). */ type RawBlock = Extract; @@ -26,6 +29,8 @@ type PromptBlock = Extract; type WizBlock = Extract; /** The (at most one) preview-select block — tail, mutually exclusive with the other two. */ type PrevBlock = Extract; +/** The (at most one) multi-select block — tail, mutually exclusive with the other dialog blocks. */ +type MultiBlock = Extract; export interface AnsiOutputProps { text: string; @@ -52,7 +57,10 @@ export interface AnsiOutputProps { /** Injected handler for a preview-dialog tap (option / note / step-nav intents — the race-guarded * choreography lives in lib/preview-action.ts). Same presentational contract as onPromptAction. */ onPreviewAction?: (action: PreviewBlockAction, preview: PreviewSelectModel) => void | Promise; - /** Disable the prompt-select/wizard/preview buttons (read-only device / gone pane). */ + /** Injected handler for a multi-select tap (toggle / submit / escape / confirm / cancel — the + * race-guarded choreography lives in lib/multi-select-action.ts). Same presentational contract. */ + onMultiSelectAction?: (action: MultiSelectIntent, multi: MultiSelectModel) => void | Promise; + /** Disable the prompt-select/wizard/preview/multi-select buttons (read-only device / gone pane). */ promptDisabled?: boolean; } @@ -100,6 +108,7 @@ export const AnsiOutput = memo(function AnsiOutput({ onPromptAction, onWizardAction, onPreviewAction, + onMultiSelectAction, promptDisabled, }: AnsiOutputProps) { const segments = useMemo(() => parseAnsi(text), [text]); @@ -121,6 +130,10 @@ export const AnsiOutput = memo(function AnsiOutput({ () => blocks.find((b): b is PrevBlock => b.kind === "preview-select") ?? null, [blocks], ); + const multiBlock = useMemo( + () => blocks.find((b): b is MultiBlock => b.kind === "multi-select") ?? null, + [blocks], + ); // Find offsets live over the *raw* mirror text (raw blocks joined by "\n", lines joined by "\n"). // The join only runs while actually searching, so the idle polling path pays nothing. @@ -168,6 +181,12 @@ export const AnsiOutput = memo(function AnsiOutput({ disabled={promptDisabled || !onPreviewAction} onAction={(action) => onPreviewAction?.(action, previewBlock.preview)} /> + ) : multiBlock ? ( + onMultiSelectAction?.(action, multiBlock.multi)} + /> ) : null; // Fast path — not searching. No global offsets, no splitSegment: one plain span per segment, with diff --git a/web/src/components/composer.test.tsx b/web/src/components/composer.test.tsx index 6d22d15..4a47a91 100644 --- a/web/src/components/composer.test.tsx +++ b/web/src/components/composer.test.tsx @@ -271,10 +271,140 @@ describe("Composer — quick keys / image attach", () => { await user.click(attach); // clickable without throwing (opens the hidden file input) }); - it("does not render digit shortcut buttons in the composer (they live on the Keys sheet's 123 tab)", () => { + it("does not render digit shortcut buttons in the composer (they live on the Keys dock's 123 tab)", () => { renderComposer(); for (const d of ["1", "2", "3", "4", "5"]) { expect(screen.queryByRole("button", { name: d })).not.toBeInTheDocument(); } }); }); + +describe("Composer — keys dock (in-flow, not an overlay)", () => { + it("tapping Keys docks the NavTray in the normal flow (no fixed overlay) and toggles it closed", async () => { + const user = userEvent.setup(); + renderComposer(); + + const keys = screen.getByRole("button", { name: "Keys" }); + expect(keys).toHaveAttribute("aria-expanded", "false"); + // Closed by default — the tray isn't mounted. + expect(screen.queryByRole("button", { name: "Esc" })).not.toBeInTheDocument(); + + await user.click(keys); + expect(keys).toHaveAttribute("aria-expanded", "true"); + + // The NavTray is now mounted (its Esc key is a good witness)… + const esc = screen.getByRole("button", { name: "Esc" }); + expect(esc).toBeInTheDocument(); + // …and it is IN-FLOW, not inside a fixed overlay/dialog (the BottomSheet's covering role="dialog"). + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(esc.closest('[aria-modal="true"]')).toBeNull(); + expect(esc.closest(".fixed")).toBeNull(); + + // Tapping Keys again closes the dock (single-valued drawer toggle). + await user.click(keys); + expect(keys).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByRole("button", { name: "Esc" })).not.toBeInTheDocument(); + }); + + it("the dock's own X close button dismisses it", async () => { + const user = userEvent.setup(); + renderComposer(); + + await user.click(screen.getByRole("button", { name: "Keys" })); + expect(screen.getByRole("button", { name: "Esc" })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Close Keys" })); + expect(screen.queryByRole("button", { name: "Esc" })).not.toBeInTheDocument(); + }); + + it("routes a docked key press through pane.send_keys", async () => { + const user = userEvent.setup(); + let sentKeys: string[] | null = null; + server.use( + http.post(/\/api\/pane\/[^/]+\/keys$/, async ({ request }) => { + const body = (await request.json()) as { keys: string[] }; + sentKeys = body.keys; + return HttpResponse.json({ ok: true }); + }), + ); + renderComposer(); + + await user.click(screen.getByRole("button", { name: "Keys" })); + await user.click(screen.getByRole("button", { name: "Esc" })); + + await waitFor(() => expect(sentKeys).toEqual(["Escape"])); + }); +}); + +describe("Composer — quick dock (in-flow, matches the keys dock)", () => { + it("tapping Quick docks the reply grids in the normal flow (no fixed overlay) and toggles it closed", async () => { + const user = userEvent.setup(); + renderComposer(); + + const quick = screen.getByRole("button", { name: "Quick" }); + expect(quick).toHaveAttribute("aria-expanded", "false"); + // Closed by default — none of the quick replies are mounted. + expect(screen.queryByRole("button", { name: "yes" })).not.toBeInTheDocument(); + + await user.click(quick); + expect(quick).toHaveAttribute("aria-expanded", "true"); + + // The reply grid is now mounted ("yes" is a good witness)… + const yes = screen.getByRole("button", { name: "yes" }); + expect(yes).toBeInTheDocument(); + // …and it is IN-FLOW like the keys dock, not inside a BottomSheet's covering role="dialog". + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(yes.closest('[aria-modal="true"]')).toBeNull(); + expect(yes.closest(".fixed")).toBeNull(); + + // Tapping Quick again closes the dock (single-valued drawer toggle). + await user.click(quick); + expect(quick).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByRole("button", { name: "yes" })).not.toBeInTheDocument(); + }); + + it("opening Quick closes an open Keys dock (shared single-valued drawer)", async () => { + const user = userEvent.setup(); + renderComposer(); + + await user.click(screen.getByRole("button", { name: "Keys" })); + expect(screen.getByRole("button", { name: "Esc" })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Quick" })); + // Keys unmounts, Quick mounts — only one dock at the single placement site. + expect(screen.queryByRole("button", { name: "Esc" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "yes" })).toBeInTheDocument(); + }); + + it("the dock's own X close button dismisses it", async () => { + const user = userEvent.setup(); + renderComposer(); + + await user.click(screen.getByRole("button", { name: "Quick" })); + expect(screen.getByRole("button", { name: "yes" })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Close Quick" })); + expect(screen.queryByRole("button", { name: "yes" })).not.toBeInTheDocument(); + }); + + it("a quick-action tap sends its text through the reply path and closes the dock", async () => { + const user = userEvent.setup(); + let replyText: string | null = null; + server.use( + http.post(/\/api\/pane\/[^/]+\/reply$/, async ({ request }) => { + const body = (await request.json()) as { text: string }; + replyText = body.text; + return HttpResponse.json({ ok: true }); + }), + ); + const props = renderComposer(); + + await user.click(screen.getByRole("button", { name: "Quick" })); + await user.click(screen.getByRole("button", { name: "continue" })); + + await waitFor(() => expect(replyText).toBe("continue")); + expect(props.onSent).toHaveBeenCalled(); + // fire() closes the dock after sending. + expect(screen.queryByRole("button", { name: "continue" })).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/components/composer.tsx b/web/src/components/composer.tsx index 670727c..b8e7b31 100644 --- a/web/src/components/composer.tsx +++ b/web/src/components/composer.tsx @@ -1,5 +1,5 @@ import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"; -import type { ChangeEvent } from "react"; +import type { ChangeEvent, ReactNode } from "react"; import { useRevalidator } from "react-router"; import { AArrowDown, AArrowUp, Check, ImagePlus, Keyboard, Loader2, Search, Send, Slash, Terminal, WrapText, X, Zap } from "lucide-react"; @@ -8,10 +8,9 @@ import { usePendingConfirm } from "@/hooks/use-pending-confirm"; import { setStatus } from "@/lib/status"; import { Button } from "@/components/ui/button"; import { ChatInput } from "@/components/ui/chat/chat-input"; -import { BottomSheet } from "@/components/ui/sheet"; import { NavTray } from "@/components/nav-tray"; import { CommandPalette } from "@/components/command-palette"; -import { QuickActions } from "@/components/quick-actions"; +import { QuickActionsContent } from "@/components/quick-actions"; import { SectionLabel } from "@/components/ui/section-label"; import * as api from "@/lib/api"; import { commandsFor } from "@/lib/agent-commands"; @@ -63,6 +62,40 @@ type ComposerDrawer = "quick" | "cmd" | "keys" | null; // Pause after clearing a stranded terminal draft so the TUI settles before pane.send_text. const TUI_SETTLE_MS = 350; +// Shared in-flow dock chrome for Keys/Quick — an IN-FLOW panel (never an overlay), so the terminal +// mirror's flex-1 box shrinks and its tail stays visible while the dock is open (a covering sheet +// hid exactly the prompt you were driving). Full-bleed top border + capped height keep the mirror +// usable on a phone. The header (title + Close X) is a NON-scrolling child of a flex column; only the +// body below it scrolls (max-h + overflow), so the Close X can never scroll out of reach on a short +// viewport with a tall tray. One wrapper so Keys and Quick can't drift apart. +function ComposerDock({ + title, + onClose, + children, +}: { + title: string; + onClose: () => void; + children: ReactNode; +}) { + return ( +
+
+ {title} + +
+
{children}
+
+ ); +} + export const Composer = forwardRef(function Composer( { paneId, session, agent, isShell, gone, readOnly, text, terminalDraft, prefs, setWrap, stepFontSize, setRawTerminal, onSent, onOpenFind }, ref, @@ -302,7 +335,7 @@ export const Composer = forwardRef(function Compo {/* File input stays mounted here (not inside the keyboard-only key row) so the picker callback survives the keyboard collapsing. Attach-image fires it from the reply-input row below (always visible, not gated behind the keyboard-open quick keys); structural commands - (New tab/space, Kill) and Stop (Esc, in the Keys sheet) live elsewhere. */} + (New tab/space, Kill) and Stop (Esc, in the Keys dock) live elsewhere. */} {/* Display prefs (wrap + font size) on their own compact, right-aligned row. Kept off the Keys/Quick/Agent action row below — three extra buttons there overflowed a narrow phone @@ -378,25 +411,49 @@ export const Composer = forwardRef(function Compo
+ {/* Keys / Quick dock — a single in-flow site ABOVE the Controls row (so the toggle you tapped + stays put and the panel grows over the mirror, not the input). Whichever of the mutually + exclusive drawers is active renders here via the shared ComposerDock chrome. Keys mounts + the NavTray (unmounts on close, so tab/queue reset each open); Quick mounts the two + one-tap reply grids. Agent stays a covering BottomSheet below (it's a palette, not a pad). */} + {drawer === "keys" && ( + + + + )} + {drawer === "quick" && ( + + send(t, false)} + onClose={closeDrawer} + disabled={locked || sending} + /> + + )} {/* Action row: Keys · Quick · Agent (Agent only when the pane's agent has commands). */}
Controls + {/* Keys and Quick are TOGGLES for the in-flow dock above (not overlays): tap to open, tap + again to close. aria-expanded ties each to the dock; secondary variant marks it pressed + while open. Both share the single-valued `drawer`, so opening one closes the other. */}
- {/* Quick actions */} - send(t, false)} - disabled={locked || sending} - /> - - {/* Keys — same bottom-sheet behaviour as Quick; stays open so you can press several keys */} - - - - {/* Slash-command palette */} readFileSync(join(PANES_DIR, name), "utf8"); +function fixtureModel(name: string): MultiSelectModel { + const model = detectMultiSelect(splitLines(parseAnsi(fixtureText(name)))); + if (!model) throw new Error(`fixture ${name} did not detect a multi-select dialog`); + return model; +} + +describe("MultiSelectBlock — checkbox screen", () => { + it("renders the question, each option as a checkbox with its key badge, and the checked state", () => { + const model = fixtureModel("claude--select-multiselect-checked.txt"); + render(); + + expect( + screen.getByRole("group", { name: "Which pizza toppings do you want?" }), + ).toBeInTheDocument(); + + // Four checkbox controls; the fixture has Mushrooms + Olives checked. + const boxes = screen.getAllByRole("checkbox"); + expect(boxes).toHaveLength(4); + expect(screen.getByRole("checkbox", { name: /Cheese/ })).toHaveAttribute("aria-checked", "false"); + expect(screen.getByRole("checkbox", { name: /Mushrooms/ })).toHaveAttribute("aria-checked", "true"); + expect(screen.getByRole("checkbox", { name: /Olives/ })).toHaveAttribute("aria-checked", "true"); + expect(screen.getByRole("checkbox", { name: /Peppers/ })).toHaveAttribute("aria-checked", "false"); + + // Each row leads with its terminal-menu digit (the KeyBadge affordance). + expect(within(screen.getByRole("checkbox", { name: /Cheese/ })).getByText("1")).toBeInTheDocument(); + expect(within(screen.getByRole("checkbox", { name: /Peppers/ })).getByText("4")).toBeInTheDocument(); + // Descriptions ride along as secondary text nodes. + expect(screen.getByText("Classic melted cheese topping.")).toBeInTheDocument(); + }); + + it("offers a Submit button and the de-emphasised 'Chat about this' escape", () => { + const model = fixtureModel("claude--select-multiselect-single.txt"); + render(); + expect(screen.getByRole("button", { name: /^Submit$/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Chat about this/ })).toHaveTextContent(/ends the questions/); + }); + + it("routes the right intent for a toggle, Submit, and the escape", async () => { + const onAction = vi.fn(); + const user = userEvent.setup(); + const model = fixtureModel("claude--select-multiselect-single.txt"); + render(); + + await user.click(screen.getByRole("checkbox", { name: /Olives/ })); + expect(onAction).toHaveBeenLastCalledWith({ kind: "toggle", n: 3 }); + + // A tap locks every control until the (awaited) handler resolves; vi.fn() resolves synchronously, + // so subsequent taps go through in order. + await user.click(screen.getByRole("button", { name: /^Submit$/ })); + expect(onAction).toHaveBeenLastCalledWith({ kind: "submit" }); + + await user.click(screen.getByRole("button", { name: /Chat about this/ })); + expect(onAction).toHaveBeenLastCalledWith({ kind: "escape" }); + }); + + it("disables every control when disabled (read-only device / gone pane)", () => { + const model = fixtureModel("claude--select-multiselect-single.txt"); + render(); + for (const button of screen.getAllByRole("button")) expect(button).toBeDisabled(); + for (const box of screen.getAllByRole("checkbox")) expect(box).toBeDisabled(); + }); +}); + +describe("MultiSelectBlock — review screen", () => { + it("shows the confirm prompt, the incomplete warning, and Submit/Cancel routing", async () => { + const onAction = vi.fn(); + const user = userEvent.setup(); + const model = fixtureModel("claude--select-multiselect-review.txt"); + render(); + + expect(screen.getByRole("group", { name: "Ready to submit your answers?" })).toBeInTheDocument(); + expect(screen.getByText(/not answered all questions/)).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /Submit answers/ })); + expect(onAction).toHaveBeenLastCalledWith({ kind: "confirm" }); + await user.click(screen.getByRole("button", { name: /^Cancel$/ })); + expect(onAction).toHaveBeenLastCalledWith({ kind: "cancel" }); + }); +}); diff --git a/web/src/components/multi-select-block.tsx b/web/src/components/multi-select-block.tsx new file mode 100644 index 0000000..5b783e4 --- /dev/null +++ b/web/src/components/multi-select-block.tsx @@ -0,0 +1,188 @@ +import { useState } from "react"; +import { AlertTriangle, Check, Loader2 } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import type { MultiSelectModel } from "@/lib/blocks"; +import type { MultiSelectIntent } from "@/lib/multi-select-action"; +import { KeyBadge, optionSurface, PromptPanel, QuestionHeading } from "@/components/option-button"; + +export interface MultiSelectBlockProps { + /** The detected multi-select dialog (checkbox screen or review screen). */ + multi: MultiSelectModel; + /** + * Injected send handler (from AgentChat). Presentational contract: this component NEVER touches + * the network — it maps taps to intents while the handler runs the race-guarded choreography + * (toggle = one digit; submit = the closed-loop Down→Up→verify→Enter macro). Returning/throwing + * simply clears the busy state. + */ + onAction: (action: MultiSelectIntent) => void | Promise; + /** Read-only device or a gone pane: everything renders (for context) but can't be pressed. */ + disabled?: boolean; +} + +// Native, tappable rendering of Claude's multiSelect AskUserQuestion. The terminal is the single +// source of truth for the checkbox state — a digit is an XOR (a double-fire would invert it), so +// there is NO optimistic local `checked`; each tap locks EVERY control (a `sending` state) until the +// round-trip re-derives the fresh state. That per-tap lock is an acknowledged v1 limitation (you +// can't queue toggles). Every visible string (labels, descriptions) is a React text node — the XSS +// boundary is unchanged. +export function MultiSelectBlock({ multi, onAction, disabled }: MultiSelectBlockProps) { + const [sending, setSending] = useState(null); + const locked = disabled || sending !== null; + + async function press(id: string, action: MultiSelectIntent) { + if (locked) return; + setSending(id); + try { + await onAction(action); + } finally { + setSending(null); + } + } + + if (multi.phase === "review") { + return ; + } + return ; +} + +const spinnerSm = ( + +); +const spinnerMd = ( + +); + +function CheckboxPhase({ + multi, + locked, + sending, + onPress, +}: { + multi: Extract; + locked: boolean; + sending: string | null; + onPress: (id: string, action: MultiSelectIntent) => void; +}) { + return ( + + {multi.question} +
+ {multi.options.map((option) => { + const id = `opt-${option.n}`; + const busy = sending === id; + const tone = busy ? "busy" : "default"; + return ( + + ); + })} +
+ + {/* Submit advances to the review screen — the handler drives the closed-loop macro. */} + + + {/* "Chat about this" ABORTS the tool — de-emphasised, apart from the answers (like the wizard). */} + {multi.escape ? ( + + ) : null} +
+ ); +} + +function ReviewPhase({ + incomplete, + locked, + sending, + onPress, +}: { + incomplete: boolean; + locked: boolean; + sending: string | null; + onPress: (id: string, action: MultiSelectIntent) => void; +}) { + return ( + + Ready to submit your answers? + {incomplete ? ( + // role="alert" so a screen reader announces the incomplete-answers warning when the review + // screen mounts — otherwise a user could confirm a partial set without ever hearing it. +
+ + You have not answered all questions +
+ ) : null} +
+ + +
+
+ ); +} diff --git a/web/src/components/nav-tray.tsx b/web/src/components/nav-tray.tsx index d6c4f97..6fbf14f 100644 --- a/web/src/components/nav-tray.tsx +++ b/web/src/components/nav-tray.tsx @@ -44,7 +44,7 @@ const DIGITS = ["1", "2", "3", "4", "5", "6", "7", "8", "9"]; // Two views behind a segmented toggle: the keys pad (arrows/Esc, Tab/Space/Enter, modifiers, Ctrl // presets) and a phone-dialer digit grid. Digits were a cramped nine-across sliver row; on their own // tab they get large, thumb-sized targets. The tab is component state only (resets to "keys" each -// open — the sheet unmounts the tray when closed), while the armed modifier, the key queue, and the +// open — the dock unmounts the tray when closed), while the armed modifier, the key queue, and the // Ctrl-expand persist across the toggle so a composed sequence survives switching to the digit pad. type Tab = "keys" | "digits"; diff --git a/web/src/components/option-button.tsx b/web/src/components/option-button.tsx new file mode 100644 index 0000000..a256e92 --- /dev/null +++ b/web/src/components/option-button.tsx @@ -0,0 +1,139 @@ +import type { ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +// The one shared visual language for an up-levelled dialog option, used by all three block +// renderers (prompt-select / wizard / preview-select) so they can't drift into three different flat +// styles again. The rows must read as OBVIOUSLY tappable controls sitting apart from the raw +// terminal mirror above them: a solid, elevated surface (secondary fill + full-opacity border + +// shadow) with generous corners and tap padding, a leading key badge mirroring the terminal menu's +// digit, and calm accent/active states. Theme tokens only — light and dark both derive from these. +// +// XSS boundary unchanged: labels/descriptions passed in are React text nodes; nothing is ever set as +// innerHTML. + +/** + * Surface tone of an option row: + * - `default` — a plain, selectable choice. + * - `selected` — the TUI's current/chosen row (primary accent tint). + * - `busy` — this option's keystroke is in flight: primary accent, and NOT dimmed even while the + * button is disabled (the caller renders a spinner in the trailing slot), so the + * pressed row stays visibly distinct while its siblings dim under `disabled`. + */ +export type OptionTone = "default" | "selected" | "busy"; + +/** Shared surface classes for a tappable option row. Applied verbatim in all three blocks. */ +export function optionSurface(tone: OptionTone): string { + return cn( + "flex w-full items-start gap-2 rounded-lg border px-2.5 py-1.5 text-left shadow-sm transition-all", + "active:scale-[0.99]", + tone === "busy" + ? "border-primary bg-primary/10" // in flight — accent, never dimmed (the spinner reads over it) + : tone === "selected" + ? "border-primary bg-primary/10 active:bg-primary/15 disabled:opacity-60" + : "border-border bg-secondary active:border-primary/50 active:bg-primary/5 disabled:opacity-60", + ); +} + +/** The small square key chip — the option's terminal digit, so the menu mapping is visible. + * `aria-hidden`: the digit is a visual affordance; the button is already named by its label. */ +export function KeyBadge({ children, tone = "default" }: { children: ReactNode; tone?: OptionTone }) { + return ( + + {children} + + ); +} + +/** + * The enclosing surface for an up-levelled dialog: a bordered, filled panel that lifts the WHOLE + * prompt off the raw terminal mirror behind it — the primary "these are controls, not output" + * signal, shared by all three block renderers so the separation can't drift. `bg-card` sits one + * layer above the page background in dark (the terminal is on `--background`, the option rows on the + * lighter `--secondary`, so the panel reads as a distinct middle layer); in light, where card == + * background, the border + shadow carry the separation. Owns `role="group"` + its aria label, so a + * block's outermost element IS this panel. + */ +export function PromptPanel({ ariaLabel, children }: { ariaLabel: string; children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +// The small primary tick that flags an option group as "the terminal is asking you something", +// separating the block from the raw mirror above it. Shared by both group headers below. +const accentTick = ; + +/** The compact caption above an option group — a short uppercase label, for the single-question + * dialogs whose actual question stays visible in the raw scrollback just above. Non-semantic (the + * group is already aria-labelled by its question). */ +export function OptionGroupCaption({ children }: { children: ReactNode }) { + return ( +
+ {accentTick} + + {children} + +
+ ); +} + +/** The readable question heading, for the wizard steps whose question is NOT in the raw mirror (the + * dialog block replaced it) — kept prominent, with the same accent tick as {@link OptionGroupCaption}. */ +export function QuestionHeading({ children }: { children: ReactNode }) { + return ( +
+ {accentTick} +
{children}
+
+ ); +} + +/** A full option row: leading key badge, label + optional description, and a trailing slot (spinner + * or a state mark). Shared by prompt-select and wizard answers, which are structurally identical; + * preview-select composes {@link optionSurface}/{@link KeyBadge} directly because its rows also + * carry the pointer chevron. */ +export function OptionButton({ + tone = "default", + keyLabel, + label, + description, + trailing, + disabled, + onClick, +}: { + tone?: OptionTone; + keyLabel?: ReactNode; + label: ReactNode; + description?: ReactNode; + trailing?: ReactNode; + disabled?: boolean; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/web/src/components/preview-select-block.test.tsx b/web/src/components/preview-select-block.test.tsx index 15a226f..4928211 100644 --- a/web/src/components/preview-select-block.test.tsx +++ b/web/src/components/preview-select-block.test.tsx @@ -1,12 +1,12 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { parseAnsi } from "@/lib/ansi"; import { splitLines, type PreviewSelectModel } from "@/lib/blocks"; -import { detectPreviewSelect } from "@/lib/grammar/preview-select"; +import { detectPreviewSelect } from "@/lib/harness/claude/preview-select"; import { PreviewSelectBlock } from "./preview-select-block"; // Presentational contract only — the choreography engine has its own tests (preview-action.test.ts) @@ -31,6 +31,8 @@ describe("PreviewSelectBlock — presentation", () => { for (const label of ["Boxy", "Rounded", "Minimal"]) { expect(screen.getByRole("button", { name: new RegExp(label) })).toBeInTheDocument(); } + // Each option leads with its terminal-menu digit (the KeyBadge affordance). + expect(within(screen.getByRole("button", { name: /Boxy/ })).getByText("1")).toBeInTheDocument(); // The preview pane, captioned with the pointed option, verbatim as text. expect(screen.getByText(/Preview · Boxy/)).toBeInTheDocument(); expect(screen.getByText(/WIDGET/)).toBeInTheDocument(); diff --git a/web/src/components/preview-select-block.tsx b/web/src/components/preview-select-block.tsx index f199e23..7f89822 100644 --- a/web/src/components/preview-select-block.tsx +++ b/web/src/components/preview-select-block.tsx @@ -11,8 +11,15 @@ import { import { cn } from "@/lib/utils"; import type { PreviewOption, PreviewSelectModel } from "@/lib/blocks"; +import { + KeyBadge, + OptionGroupCaption, + optionSurface, + PromptPanel, + QuestionHeading, +} from "@/components/option-button"; import { NOTE_MAX_LENGTH } from "@/lib/preview-action"; -import { WIZARD_BACK_KEYS, WIZARD_NEXT_KEYS } from "@/lib/grammar/wizard"; +import { WIZARD_BACK_KEYS, WIZARD_NEXT_KEYS } from "@/lib/harness/claude/wizard"; /** One tap's intent, resolved to keystrokes by the injected handler (preview-action.ts). */ export type PreviewBlockAction = @@ -75,7 +82,7 @@ export function PreviewSelectBlock({ preview, onAction, disabled }: PreviewSelec ); return ( -
+ {/* Wizard form only: the stepper chips + Left/Right navigation, exactly as in WizardBlock — a preview question is just one step of the same dialog. Single-question dialogs keep their question/chip line in the raw mirror above, so neither renders here. */} @@ -123,41 +130,43 @@ export function PreviewSelectBlock({ preview, onAction, disabled }: PreviewSelec
)} - {wizard &&
{preview.question}
} + {wizard && {preview.question}} + {!wizard && Choose an option} - {/* Options. Tapping one selects it outright (the handler drives digit → verify → Enter). - The pointed row — whose preview is shown below — is marked with the TUI's chevron. */} -
+ {/* Options. Tapping one selects it outright (the handler drives digit → verify → Enter). Each + row leads with the pointer chevron (whose preview shows below) then its terminal-menu digit + (KeyBadge), on the shared elevated option surface. */} +
{preview.options.map((option, i) => { const id = `opt-${i}`; + const busy = sending === id; + const tone = busy ? "busy" : option.chosen ? "selected" : "default"; return ( ); @@ -261,6 +270,6 @@ export function PreviewSelectBlock({ preview, onAction, disabled }: PreviewSelec Add a note to this answer )} -
+ ); } diff --git a/web/src/components/prompt-select-block.test.tsx b/web/src/components/prompt-select-block.test.tsx index a535d3e..bb05eec 100644 --- a/web/src/components/prompt-select-block.test.tsx +++ b/web/src/components/prompt-select-block.test.tsx @@ -1,6 +1,6 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -16,7 +16,7 @@ vi.mock("@/lib/api", () => ({ import { fetchPane, sendKeys } from "@/lib/api"; import { parseAnsi } from "@/lib/ansi"; import { splitLines, type PromptModel, type PromptOption } from "@/lib/blocks"; -import { detectPromptSelect } from "@/lib/grammar/prompt-select"; +import { detectPromptSelect } from "@/lib/harness/claude/prompt-select"; import { submitPromptOption } from "@/lib/prompt-action"; import { clearStatus, setStatus, useStatus } from "@/lib/status"; import { PromptSelectBlock } from "./prompt-select-block"; @@ -61,6 +61,9 @@ describe("PromptSelectBlock — presentation", () => { expect(screen.getByRole("button", { name: /Red/ })).toBeInTheDocument(); // Description renders as a secondary text node (not markup). expect(screen.getByText("A warm, high-energy theme")).toBeInTheDocument(); + // Each row leads with its terminal-menu digit (the KeyBadge affordance). + expect(within(screen.getByRole("button", { name: /Red/ })).getByText("1")).toBeInTheDocument(); + expect(within(screen.getByRole("button", { name: /Green/ })).getByText("2")).toBeInTheDocument(); buttons[0]!.focus(); expect(buttons[0]).toHaveFocus(); }); diff --git a/web/src/components/prompt-select-block.tsx b/web/src/components/prompt-select-block.tsx index 242661f..16d827d 100644 --- a/web/src/components/prompt-select-block.tsx +++ b/web/src/components/prompt-select-block.tsx @@ -1,8 +1,8 @@ import { useState } from "react"; import { Loader2 } from "lucide-react"; -import { cn } from "@/lib/utils"; -import type { PromptModel, PromptOption } from "@/lib/blocks"; +import type { PromptFamily, PromptModel, PromptOption } from "@/lib/blocks"; +import { OptionButton, OptionGroupCaption, PromptPanel } from "@/components/option-button"; export interface PromptSelectBlockProps { /** The detected dialog: question (screen-reader label) + selectable options as buttons. */ @@ -17,12 +17,22 @@ export interface PromptSelectBlockProps { disabled?: boolean; } +// Family-aware caption above the options — orients the reader ("the terminal is asking you +// something") without repeating the question, which stays in the raw scrollback just above. +const FAMILY_CAPTION: Record = { + select: "Choose an option", + permission: "Permission required", + trust: "Trust this folder?", + plan: "Review the plan", +}; + // Native, tappable rendering of a Claude single-choice dialog. Every visible string — the option // label and its description — is a React text node (the XSS boundary is unchanged; nothing is ever // set as innerHTML). Real - ); - })} -
+ + {FAMILY_CAPTION[prompt.family]} +
+ {prompt.options.map((option, index) => { + const busy = sending === index; + return ( + press(index, option)} + trailing={ + busy ? ( + + ) : null + } + /> + ); + })} +
+
); } diff --git a/web/src/components/quick-actions.tsx b/web/src/components/quick-actions.tsx index d34acb3..0dac397 100644 --- a/web/src/components/quick-actions.tsx +++ b/web/src/components/quick-actions.tsx @@ -1,16 +1,14 @@ -import { BottomSheet } from "@/components/ui/sheet"; import { Button } from "@/components/ui/button"; -// Common one-tap replies, grouped. Each sends its text and submits (then closes the sheet). +// Common one-tap replies, grouped. Each sends its text and submits (then closes the dock). // Numbered options live in the keyboard quick-key row, not here; the textual set is deduped to // distinct intents (no yes/ok/approve/go-ahead pile-up, no "stop" that just duplicates Esc). const CONFIRM = ["yes", "no"]; const COMMON = ["continue", "commit and push", "retry", "skip"]; -interface QuickActionsProps { - open: boolean; - onClose: () => void; +interface QuickActionsContentProps { onSend: (text: string) => void; + onClose: () => void; disabled?: boolean; } @@ -49,7 +47,10 @@ function Group({ ); } -export function QuickActions({ open, onClose, onSend, disabled }: QuickActionsProps) { +// The Quick-actions body — the two one-tap reply grids, no chrome of its own. Docked in-flow by the +// composer (same ComposerDock wrapper as Keys), so it never covers the mirror. `fire` sends then +// closes the dock; padding matches NavTray so both docks read identically. +export function QuickActionsContent({ onSend, onClose, disabled }: QuickActionsContentProps) { const fire = (text: string) => { if (disabled) return; onSend(text); @@ -57,11 +58,9 @@ export function QuickActions({ open, onClose, onSend, disabled }: QuickActionsPr }; return ( - -
- - -
-
+
+ + +
); } diff --git a/web/src/components/wizard-block.test.tsx b/web/src/components/wizard-block.test.tsx index 1f73c2e..69831c0 100644 --- a/web/src/components/wizard-block.test.tsx +++ b/web/src/components/wizard-block.test.tsx @@ -1,6 +1,6 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -16,7 +16,7 @@ vi.mock("@/lib/api", () => ({ import { fetchPane, sendKeys } from "@/lib/api"; import { parseAnsi } from "@/lib/ansi"; import { splitLines, type WizardModel } from "@/lib/blocks"; -import { detectWizard } from "@/lib/grammar/wizard"; +import { detectWizard } from "@/lib/harness/claude/wizard"; import { submitWizardKeys, wizardsEqual } from "@/lib/wizard-action"; import { clearStatus, setStatus, useStatus } from "@/lib/status"; import { WizardBlock } from "./wizard-block"; @@ -58,6 +58,9 @@ describe("WizardBlock — question step presentation", () => { expect(screen.getByRole("button", { name: new RegExp(label) })).toBeInTheDocument(); } expect(screen.getByText(/parsing logic/)).toBeInTheDocument(); + // Each answer leads with its terminal-menu digit (the KeyBadge affordance). + expect(within(screen.getByRole("button", { name: /Parser/ })).getByText("1")).toBeInTheDocument(); + expect(within(screen.getByRole("button", { name: /Tests/ })).getByText("3")).toBeInTheDocument(); const parser = screen.getByRole("button", { name: /Parser/ }); parser.focus(); expect(parser).toHaveFocus(); diff --git a/web/src/components/wizard-block.tsx b/web/src/components/wizard-block.tsx index de7de6e..5f24ff9 100644 --- a/web/src/components/wizard-block.tsx +++ b/web/src/components/wizard-block.tsx @@ -3,12 +3,13 @@ import { AlertTriangle, Check, ChevronLeft, ChevronRight, Loader2 } from "lucide import { cn } from "@/lib/utils"; import type { WizardModel, WizardOption } from "@/lib/blocks"; +import { OptionButton, PromptPanel, QuestionHeading } from "@/components/option-button"; import { WIZARD_BACK_KEYS, WIZARD_CANCEL_KEYS, WIZARD_NEXT_KEYS, WIZARD_SUBMIT_KEYS, -} from "@/lib/grammar/wizard"; +} from "@/lib/harness/claude/wizard"; export interface WizardBlockProps { /** The detected wizard step (question or Submit review) with its stepper state. */ @@ -52,11 +53,7 @@ export function WizardBlock({ wizard, onAction, disabled }: WizardBlockProps) { const busyIcon = ; return ( -
+ {/* Stepper: one chip per question plus the fixed Submit step, flanked by the same back/next navigation the TUI drives with ←/→ (each tap sends exactly that one key). */}
@@ -119,7 +116,7 @@ export function WizardBlock({ wizard, onAction, disabled }: WizardBlockProps) { ) : ( )} -
+
); } @@ -140,33 +137,28 @@ function QuestionStep({ const escapes = options.filter((o) => o.escape); return ( <> -
{question}
-
+ {question} +
{answers.map((option, i) => { const id = `opt-${i}`; + const busy = sendingId === id; return ( - + trailing={ + busy ? ( + + ) : option.chosen ? ( + + ) : null + } + /> ); })}
diff --git a/web/src/fixtures/panes/README.md b/web/src/fixtures/panes/README.md index 30df7df..c1b3241 100644 --- a/web/src/fixtures/panes/README.md +++ b/web/src/fixtures/panes/README.md @@ -29,6 +29,10 @@ pane over capturing real work sessions. | `claude--permission-edit.txt` | Edit permission: diff preview, "Do you want to create hello.txt?", `❯ 1. Yes / 2. Yes, allow all edits… (shift+tab) / 3. No`, "Esc to cancel · Tab to amend" | `blocked` | | `claude--permission-bash.txt` | Bash permission: command + explanation, "This command requires approval", "Do you want to proceed?", scoped don't-ask-again option, "… · ctrl+e to explain" | `blocked` | | `claude--plan-approval.txt` | ExitPlanMode: plan text, "…ready to execute. Would you like to proceed?", 4 options with hint sub-lines, "ctrl+g to edit in nano · " footer | `blocked` | +| `claude--plan-approval--numbered-body.txt` | Plan approval whose plan BODY lists numbered steps ("1. Title / 2. … / 5. TODO stub") inside the option-scan window: the menu is the trailing `1,2,3,4` suffix, body rows drop out (regression fixture for the body-list bug) | `blocked` | +| `claude--select-multiselect-single.txt` | **Single-question multiSelect** AskUserQuestion: checkbox `[ ]` options under a `← ☐ Toppings ✔ Submit →` stepper, "Enter to select · ↑/↓ · Esc" footer. Lifted to a `multi-select` block — the verified interaction is **DIGIT N toggles option N** (pointer-independent); the closed-loop Submit macro walks the pointer to Submit and confirms | `blocked` | +| `claude--select-multiselect-checked.txt` | Same dialog **mid-selection**: some boxes `[✔]` (Mushrooms, Olives), the stepper's question chip flipped to `☒` (answered). Exercises the checked-glyph lift (`[✔]`/`[x]`/`[✓]` → `checked: true`; terminal is source of truth) | `blocked` | +| `claude--select-multiselect-review.txt` | The multiSelect **review/confirm** screen: `← ☐ Toppings ✔ Submit →` stepper, "Ready to submit your answers?" over `❯ 1. Submit answers / 2. Cancel`, with a `⚠ You have not answered all questions` line (`incomplete`). Lifts the `review` phase (submit = key `1`, cancel = key `2`) | `blocked` | ## Wizard corpus (captured 2026-07-05, sandbox pane; choreography in `../../lib/grammar/WIZARD_NOTES.md`) diff --git a/web/src/fixtures/panes/claude--plan-approval--numbered-body.txt b/web/src/fixtures/panes/claude--plan-approval--numbered-body.txt new file mode 100644 index 0000000..65871e4 --- /dev/null +++ b/web/src/fixtures/panes/claude--plan-approval--numbered-body.txt @@ -0,0 +1,49 @@ + +╭─── Claude Code v2.1.210 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Ready to code? + + Here is Claude's plan: + ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ + Plan: Add README.md to biscuit + + Context + + /var/home/altan/playground/collie-demo-herd/biscuit is a completely empty directory (created 2026-07-09; no files, no dotfiles, no git). It sits in the collie-demo-herd + playground alongside four dog-named siblings (clover, comet, juniper, thistle), which suggests each subdirectory is a separate demo/sandbox. Since there is no code to + document, the README will be a short orientation/placeholder doc that gives the directory an identity until real content lands. + + Change + + Create one file: /var/home/altan/playground/collie-demo-herd/biscuit/README.md with: + + 1. Title — # biscuit + 2. One-line description — a demo/sandbox directory in the collie-demo-herd playground. + 3. Status — empty placeholder; no code yet. + 4. Context — sibling demos in the herd (clover, comet, juniper, thistle) for orientation. + 5. TODO stub — a short section to fill in purpose / setup / usage once the demo exists. + + Keep it under ~20 lines. No other files created or modified. + + Verification + + - ls the directory to confirm README.md is the sole file. + - Render-check the markdown (read it back / glance at formatting) — headings and list syntax valid. + ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ + + + + + + ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Claude has written up a plan and is ready to execute. Would you like to proceed? + + ❯ 1. Yes, and use auto mode + 2. Yes, manually approve edits + 3. No, refine with Ultraplan on Claude Code on the web + 4. Tell Claude what to change + shift+tab to approve with this feedback + + ctrl+g to edit in  nano  · ~/.claude/plans/make-a-short-plan-bubbly-neumann.md \ No newline at end of file diff --git a/web/src/fixtures/panes/claude--select-multiselect-checked.txt b/web/src/fixtures/panes/claude--select-multiselect-checked.txt new file mode 100644 index 0000000..35cb98e --- /dev/null +++ b/web/src/fixtures/panes/claude--select-multiselect-checked.txt @@ -0,0 +1,49 @@ + +╭─── Claude Code v2.1.210 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ Tips for getting started │ +│ Welcome back Altan! │ Run /init to create a CLAUDE.md file with instructions for Claude │ +│ │ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── │ +│ ▐▛███▜▌ │ What's new │ +│ ▝▜█████▛▘ │ Added a live elapsed-time counter to the collapsed tool summary line so long-running tool calls visibly tick instea… │ +│ ▘▘ ▝▝ │ Added a startup warning for `Write(path)`, `NotebookEdit(path)`, and `Glob(path)` permission rules — use `Edit(path… │ +│ Fable 5 with xhigh effort · Claude Max · │ Fixed `isolation: 'worktree'` subagents being able to run git-mutating commands against the main repo checkout inst… │ +│ altan@sportsight.de's Organization │ /release-notes for more │ +│ ~/playground/collie-demo-herd/juniper │ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + +❯ Create a file named SHEPHERD.md with a one-line description of a shepherd dog.  + +● Write(SHEPHERD.md) + ⎿  Error writing file + + Read 1 file  + +● User answered Claude's questions: + ⎿  · SHEPHERD.md already exists and contains a haiku about agents. How should I handle it? → Leave it alone + +● Understood — I've left SHEPHERD.md untouched. It still contains the original haiku, and no changes were made. + +✻ Brewed for 43s + +❯ Use AskUserQuestion with multiSelect enabled: ask me which pizza toppings I want, options Cheese, Mushrooms, Olives, Peppers, allowing multiple selections. Do nothing   + else.  +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +←  ☒ Toppings ✔ Submit → + +Which pizza toppings do you want? + +❯ 1. [ ] Cheese + Classic melted cheese topping. + 2. [✔] Mushrooms + Sliced mushrooms. + 3. [✔] Olives + Sliced olives. + 4. [ ] Peppers + Sliced peppers. + 5. [ ] Type something + Submit +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + 6. Chat about this + +Enter to select · ↑/↓ to navigate · Esc to cancel \ No newline at end of file diff --git a/web/src/fixtures/panes/claude--select-multiselect-review.txt b/web/src/fixtures/panes/claude--select-multiselect-review.txt new file mode 100644 index 0000000..e781fe6 --- /dev/null +++ b/web/src/fixtures/panes/claude--select-multiselect-review.txt @@ -0,0 +1,47 @@ + +✻ Brewed for 43s + +❯ Use AskUserQuestion with multiSelect enabled: ask me which pizza toppings I want, options Cheese, Mushrooms, Olives, Peppers, allowing multiple selections. Do nothing   + else.  + +● User declined to answer questions + ⎿  · Which pizza toppings do you want? (Cheese / Mushrooms / Olives / Peppers) + +● You selected all four toppings (Cheese, Mushrooms, Olives, Peppers), but it looks like you wanted to clarify something before finalizing — what would you like to clarify + or change about the question? + +✻ Worked for 14s + +❯ Use AskUserQuestion with multiSelect enabled: ask me which pizza toppings I want, options Cheese, Mushrooms, Olives, Peppers, allowing multiple selections. Do nothing   + else.  + +● User declined to answer questions + ⎿  · Which pizza toppings do you want? (Cheese / Mushrooms / Olives / Peppers) + +✻ Cooked for 1m 28s + +❯ 1  + +● Got it — taking "1" as the first option: Cheese. Noted as your topping selection. + +✻ Cooked for 8s + +❯ Use AskUserQuestion with multiSelect enabled: ask me which pizza toppings I want, options Cheese, Mushrooms, Olives, Peppers, allowing multiple selections. Do nothing   + else.  + +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +← ☐ Toppings  ✔ Submit  → + +Review your answers + +⚠ You have not answered all questions + +Ready to submit your answers? + +❯ 1. Submit answers + 2. Cancel + + + + + \ No newline at end of file diff --git a/web/src/fixtures/panes/claude--select-multiselect-single.txt b/web/src/fixtures/panes/claude--select-multiselect-single.txt new file mode 100644 index 0000000..8ec233b --- /dev/null +++ b/web/src/fixtures/panes/claude--select-multiselect-single.txt @@ -0,0 +1,49 @@ + +╭─── Claude Code v2.1.210 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ Tips for getting started │ +│ Welcome back Altan! │ Run /init to create a CLAUDE.md file with instructions for Claude │ +│ │ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── │ +│ ▐▛███▜▌ │ What's new │ +│ ▝▜█████▛▘ │ Added a live elapsed-time counter to the collapsed tool summary line so long-running tool calls visibly tick instea… │ +│ ▘▘ ▝▝ │ Added a startup warning for `Write(path)`, `NotebookEdit(path)`, and `Glob(path)` permission rules — use `Edit(path… │ +│ Fable 5 with xhigh effort · Claude Max · │ Fixed `isolation: 'worktree'` subagents being able to run git-mutating commands against the main repo checkout inst… │ +│ altan@sportsight.de's Organization │ /release-notes for more │ +│ ~/playground/collie-demo-herd/juniper │ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + +❯ Create a file named SHEPHERD.md with a one-line description of a shepherd dog.  + +● Write(SHEPHERD.md) + ⎿  Error writing file + + Read 1 file  + +● User answered Claude's questions: + ⎿  · SHEPHERD.md already exists and contains a haiku about agents. How should I handle it? → Leave it alone + +● Understood — I've left SHEPHERD.md untouched. It still contains the original haiku, and no changes were made. + +✻ Brewed for 43s + +❯ Use AskUserQuestion with multiSelect enabled: ask me which pizza toppings I want, options Cheese, Mushrooms, Olives, Peppers, allowing multiple selections. Do nothing   + else.  +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +←  ☐ Toppings ✔ Submit → + +Which pizza toppings do you want? + +❯ 1. [ ] Cheese + Classic melted cheese topping. + 2. [ ] Mushrooms + Sliced mushrooms. + 3. [ ] Olives + Sliced olives. + 4. [ ] Peppers + Sliced peppers. + 5. [ ] Type something + Submit +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + 6. Chat about this + +Enter to select · ↑/↓ to navigate · Esc to cancel \ No newline at end of file diff --git a/web/src/hooks/use-auto-scroll.test.tsx b/web/src/hooks/use-auto-scroll.test.tsx new file mode 100644 index 0000000..6d1ec50 --- /dev/null +++ b/web/src/hooks/use-auto-scroll.test.tsx @@ -0,0 +1,82 @@ +import { act, fireEvent, render } from "@testing-library/react"; + +import { useAutoScroll } from "./use-auto-scroll"; + +// use-auto-scroll's stickiness needs a real DOM ref (scrollRef attached to an element) plus a +// ResizeObserver — jsdom has neither the layout nor the observer, so we mount a tiny harness, pin +// the element's scroll metrics by hand, and drive a mocked ResizeObserver's callback ourselves. + +// The most-recently-constructed observer's callback — fire it to simulate a container resize. +let roCallback: ResizeObserverCallback | null = null; +class MockResizeObserver { + constructor(cb: ResizeObserverCallback) { + roCallback = cb; + } + observe() {} + unobserve() {} + disconnect() {} +} + +function setMetrics( + el: HTMLElement, + m: { scrollHeight: number; clientHeight: number; scrollTop: number }, +) { + Object.defineProperty(el, "scrollHeight", { value: m.scrollHeight, configurable: true }); + Object.defineProperty(el, "clientHeight", { value: m.clientHeight, configurable: true }); + Object.defineProperty(el, "scrollTop", { value: m.scrollTop, configurable: true, writable: true }); +} + +function Harness() { + const { scrollRef, onScroll } = useAutoScroll({ dep: "constant" }); + return
; +} + +describe("useAutoScroll — resize re-pin", () => { + // jsdom has no Element.scrollTo; the mount-time follow effect calls it, so keep a harmless default + // on the prototype and shadow it per-test with a spy on the specific element. + beforeAll(() => { + if (!Element.prototype.scrollTo) Element.prototype.scrollTo = () => {}; + }); + beforeEach(() => { + roCallback = null; + vi.stubGlobal("ResizeObserver", MockResizeObserver); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("re-pins to the bottom when the container resizes while following", () => { + const { getByTestId } = render(); + const el = getByTestId("scroll"); + // Content taller than the viewport; the observer fired on mount is a no-op until we drive it. + setMetrics(el, { scrollHeight: 500, clientHeight: 200, scrollTop: 300 }); + const scrollTo = vi.fn(); + el.scrollTo = scrollTo as unknown as HTMLElement["scrollTo"]; + + // Following by default (autoScroll = true), so a resize snaps the tail back into view — pinned + // to scrollHeight, NOT a recomputed at-bottom (the shrink already pushed the tail off-screen). + act(() => roCallback?.([], {} as ResizeObserver)); + + expect(scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" }); + }); + + it("does NOT yank the view down on resize when the user has scrolled up", () => { + const { getByTestId } = render(); + const el = getByTestId("scroll"); + // Scrolled up: 500 - 0 - 200 = 300px from the bottom, past the 24px threshold → not following. + setMetrics(el, { scrollHeight: 500, clientHeight: 200, scrollTop: 0 }); + fireEvent.scroll(el); // onScroll captures the scrolled-up intent (autoScroll = false) + + const scrollTo = vi.fn(); + el.scrollTo = scrollTo as unknown as HTMLElement["scrollTo"]; + act(() => roCallback?.([], {} as ResizeObserver)); + + expect(scrollTo).not.toHaveBeenCalled(); + }); + + it("no-ops without a ResizeObserver (jsdom / older browsers)", () => { + vi.stubGlobal("ResizeObserver", undefined); + // Mounting must not throw when the observer is absent — the effect bails on the typeof guard. + expect(() => render()).not.toThrow(); + }); +}); diff --git a/web/src/hooks/use-auto-scroll.ts b/web/src/hooks/use-auto-scroll.ts index 9a7f788..7e52e2a 100644 --- a/web/src/hooks/use-auto-scroll.ts +++ b/web/src/hooks/use-auto-scroll.ts @@ -51,5 +51,21 @@ export function useAutoScroll( }); }, [dep]); + // Re-pin when the container itself RESIZES while we're following — a shrinking viewport (the keys + // dock opening above the composer, or the on-screen keyboard) pushes the tail below the fold, so + // stickiness must snap it back. Keyed on the captured `autoScroll` intent, NOT a recomputed + // at-bottom (the shrink already moved the view off bottom); a scrolled-up user is left in place. + // Guarded for jsdom, which has no ResizeObserver. + useEffect(() => { + const el = scrollRef.current; + if (!el || typeof ResizeObserver === "undefined") return; + const ro = new ResizeObserver(() => { + if (!autoScroll.current) return; + el.scrollTo({ top: el.scrollHeight, behavior: "auto" }); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + return { scrollRef, isAtBottom, scrollToBottom, onScroll }; } diff --git a/web/src/lib/blocks.test.ts b/web/src/lib/blocks.test.ts index e3335ca..deb0cdb 100644 --- a/web/src/lib/blocks.test.ts +++ b/web/src/lib/blocks.test.ts @@ -3,7 +3,8 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { parseAnsi, type AnsiSegment } from "./ansi"; -import { buildBlocks, splitLines, type StyledLine } from "./blocks"; +import { splitLines, type StyledLine } from "./blocks"; +import { buildBlocks } from "./harness"; // Anchored on this file's directory (not `new URL(import.meta.url)`, which Vite rewrites to an asset). const PANES_DIR = join(import.meta.dirname, "..", "fixtures", "panes"); diff --git a/web/src/lib/blocks.ts b/web/src/lib/blocks.ts index a5d2707..a149f98 100644 --- a/web/src/lib/blocks.ts +++ b/web/src/lib/blocks.ts @@ -4,11 +4,13 @@ // further grammars (tool calls, …) are added as new `kind`s without disturbing these. // // The pipeline is: parseAnsi(text) → AnsiSegment[] → splitLines(segments) → StyledLine[] → -// buildBlocks(lines, ctx) → Block[]. These functions are PURE (no React) and, together with the -// parser, run once per unique text (memoised by the renderer), so they're off the hot polling path. -// The Claude-only grammars in buildBlocks (prompt-select detection, chrome stripping) live in -// ./grammar; the "which agents get them" decision is the single `hasBlockGrammar` predicate -// (./grammar/agents) — every other agent keeps pure raw output. +// buildBlocks(lines, ctx) → Block[]. splitLines (and the pure helpers below) live here; the +// block-BUILDING step is the harness DISPATCHER (harness/index) routing to a per-agent adapter, so +// this core module has NO dependency on the agent grammars — the edge is one-way, which is what lets +// an adapter import this AST without forming a cycle. These functions are PURE (no React) and, +// together with the parser, run once per unique text (memoised by the renderer), so they're off the +// hot polling path. The Claude grammars themselves (prompt-select detection, chrome stripping) live +// in harness/claude; which agents get them is decided by the adapter registry (harness/registry). // // Invariant (relied on by find-in-output): joining every RAW block's line text with "\n" reproduces // the visible mirror text character-for-character. Find operates on global character offsets over @@ -16,21 +18,24 @@ // (find covers the raw mirror only). import type { AnsiSegment } from "./ansi"; -import type { PromptModel } from "./grammar/prompt-select"; -import { detectPromptSelectRegion } from "./grammar/prompt-select"; -import type { WizardModel } from "./grammar/wizard"; -import { detectWizardRegion } from "./grammar/wizard"; -import type { PreviewSelectModel } from "./grammar/preview-select"; -import { detectPreviewSelectRegion } from "./grammar/preview-select"; -import { stripChrome } from "./grammar/chrome"; -import { hasBlockGrammar } from "./grammar/agents"; -import { isBlank, lineText } from "./grammar/markers"; - -// Re-export the prompt-select + wizard + preview models so consumers (the block components, the -// race guards) have one import site for the AST's typed payloads. -export type { PromptModel, PromptOption, PromptFamily } from "./grammar/prompt-select"; -export type { WizardModel, WizardOption, WizardStepChip, WizardAnswer } from "./grammar/wizard"; -export type { PreviewSelectModel, PreviewOption, PreviewNote } from "./grammar/preview-select"; +import type { PromptModel } from "./harness/claude/prompt-select"; +import type { WizardModel } from "./harness/claude/wizard"; +import type { PreviewSelectModel } from "./harness/claude/preview-select"; +import type { MultiSelectModel } from "./harness/claude/multi-select"; + +// Re-export the prompt-select + wizard + preview + multi-select models so consumers (the block +// components, the race guards) have one import site for the AST's typed payloads. These are +// TYPE-ONLY re-exports (erased under verbatimModuleSyntax), so they add no runtime edge into +// harness/ — the value dependency stays one-way (harness → blocks). +export type { PromptModel, PromptOption, PromptFamily } from "./harness/claude/prompt-select"; +export type { WizardModel, WizardOption, WizardStepChip, WizardAnswer } from "./harness/claude/wizard"; +export type { PreviewSelectModel, PreviewOption, PreviewNote } from "./harness/claude/preview-select"; +export type { + MultiSelectModel, + MultiSelectOption, + MultiSelectEscape, + MultiPointer, +} from "./harness/claude/multi-select"; /** One visual line: the styled segments that make it up, with the line-terminating "\n" removed. */ export interface StyledLine { @@ -77,11 +82,27 @@ export interface PreviewSelectBlock { lines: StyledLine[]; } +/** + * A multi-select AskUserQuestion (the checkbox form + its review screen) lifted out of the raw + * mirror and rendered as native checkboxes / a confirm screen. Like the other dialog blocks it + * REPLACES its region in place; `lines` is provenance only — not rendered, not searchable. + */ +export interface MultiSelectBlock { + kind: "multi-select"; + multi: MultiSelectModel; + lines: StyledLine[]; +} + /** * A semantic block. A discriminated union on `kind`; new members are added purely additively, so a * `switch (block.kind)` in the renderer stays exhaustive. */ -export type Block = RawBlock | PromptSelectBlock | WizardBlock | PreviewSelectBlock; +export type Block = + | RawBlock + | PromptSelectBlock + | WizardBlock + | PreviewSelectBlock + | MultiSelectBlock; /** * Split parsed segments into visual lines at "\n" boundaries. The newline characters become the @@ -124,65 +145,19 @@ export function splitLines(segments: AnsiSegment[]): StyledLine[] { return lines; } -/** - * Group lines into semantic blocks. This is the seam where Claude-Code TUI grammars run: when - * `hasBlockGrammar(ctx.agent)` (Claude today) we detect a tail prompt-select dialog (replacing it - * with a typed block and keeping everything above it raw) and otherwise strip trailing chrome. Any - * detection miss falls back to a single raw block — the universal T1 behaviour. - * - * Gating is conservative and centralised in {@link hasBlockGrammar}: every OTHER agent (and any - * unknown/absent one) keeps pure raw output until its own matchers exist, so a non-Claude pane is - * never mis-parsed. With no `ctx` (or a non-Claude agent) this is the trivial single-raw-block wrap - * it always was. - */ -export function buildBlocks(lines: StyledLine[], ctx?: { agent?: string }): Block[] { - // gate: claude-only — every grammar below (wizard, prompt-select, chrome) is Claude Code TUI - // specific; the "which agents get them" decision is the single hasBlockGrammar predicate, so it - // can't drift from agent-chat's status-strip gate. Every other agent keeps the pure raw mirror. - if (!hasBlockGrammar(ctx?.agent)) return [{ kind: "raw", lines }]; - - // The preview variant runs FIRST: its footer is the most specific anchor ("n to add notes"), - // and although the wizard/prompt-select detectors can't match its layout (their footer-gap - // guards fail on the tall preview pane), ordering by specificity keeps the arbitration obvious. - const previewRegion = detectPreviewSelectRegion(lines); - if (previewRegion) { - const before = trimTrailingBlank(lines.slice(0, previewRegion.startLine)); - const blocks: Block[] = []; - if (before.length > 0) blocks.push({ kind: "raw", lines: before }); - blocks.push({ - kind: "preview-select", - preview: previewRegion.model, - lines: lines.slice(previewRegion.startLine), - }); - return blocks; - } - - // The wizard runs before prompt-select: its question phase also carries a select footer, so - // prompt-select's detector would otherwise have to arbitrate (today it bails on the stepper - // header — that bail stays as a safety net for a wizard this detector misses). - const wizardRegion = detectWizardRegion(lines); - if (wizardRegion) { - const before = trimTrailingBlank(lines.slice(0, wizardRegion.startLine)); - const blocks: Block[] = []; - if (before.length > 0) blocks.push({ kind: "raw", lines: before }); - blocks.push({ kind: "wizard", wizard: wizardRegion.model, lines: lines.slice(wizardRegion.startLine) }); - return blocks; - } - - const region = detectPromptSelectRegion(lines); - if (region) { - const before = trimTrailingBlank(lines.slice(0, region.startLine)); - const blocks: Block[] = []; - if (before.length > 0) blocks.push({ kind: "raw", lines: before }); - blocks.push({ kind: "prompt-select", prompt: region.model, lines: lines.slice(region.startLine) }); - return blocks; - } - - return [{ kind: "raw", lines: stripChrome(lines) }]; +// The two generic StyledLine probes trimTrailingBlank needs. Kept LOCAL (harness/claude/markers has +// an identical pair for the grammars) so this core module imports nothing from harness/ — that is +// what keeps the harness → blocks edge one-way and cycle-free. +function lineText(line: StyledLine): string { + return line.segments.map((s) => s.text).join(""); +} +function isBlank(text: string): boolean { + return text.trim().length === 0; } -/** Drop a trailing run of blank lines (keeps the raw block above the buttons tight). */ -function trimTrailingBlank(lines: StyledLine[]): StyledLine[] { +/** Drop a trailing run of blank lines (keeps the raw block above the buttons tight). Exported for the + * harness adapters, whose pipelines tighten the raw region above a lifted dialog with it. */ +export function trimTrailingBlank(lines: StyledLine[]): StyledLine[] { let end = lines.length; while (end > 0 && isBlank(lineText(lines[end - 1]!))) end--; return end === lines.length ? lines : lines.slice(0, end); diff --git a/web/src/lib/grammar/agents.test.ts b/web/src/lib/grammar/agents.test.ts deleted file mode 100644 index fe04e25..0000000 --- a/web/src/lib/grammar/agents.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { hasBlockGrammar } from "./agents"; - -// The single source of truth for "which agents get the Claude-tuned block grammars". Both gates -// (buildBlocks and agent-chat's status strip) route through this, so it is worth pinning directly. -describe("hasBlockGrammar", () => { - it("is true only for Claude Code", () => { - expect(hasBlockGrammar("claude")).toBe(true); - }); - - it("is false for every non-Claude agent (unverified TUI ⇒ raw mirror)", () => { - for (const agent of ["codex", "opencode", "pi", "shell", "unknown"]) { - expect(hasBlockGrammar(agent)).toBe(false); - } - }); - - it("is false for an absent agent", () => { - expect(hasBlockGrammar(undefined)).toBe(false); - }); -}); diff --git a/web/src/lib/grammar/agents.ts b/web/src/lib/grammar/agents.ts deleted file mode 100644 index 6ccdfd9..0000000 --- a/web/src/lib/grammar/agents.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Which agents get the Claude-tuned block grammars? Exactly one today: Claude Code. -// -// The matchers in this directory (prompt-select detection, chrome stripping, the statusline -// re-surfacing) are shaped by the VERIFIED Claude Code TUI — its input box, its menu footers, its -// stepper header — via the fixture corpus in web/src/fixtures/panes/*.txt. Every OTHER agent (codex, -// opencode, pi, a bare shell, or an unknown/absent agent) has an unverified TUI shape, so running -// Claude's matchers on it could mis-lift a menu into the wrong buttons, strip real output as -// "chrome", or paint a bogus status strip. We therefore run the grammars ONLY where we know the -// shape; everyone else keeps the plain raw terminal mirror (the universal T1 fallback). -// -// This is the SINGLE decision site: both gates — `buildBlocks` (the render pipeline) and -// agent-chat's status-strip `useMemo` — call it, so the "Claude-only" policy can't drift between -// them, and adding a future verified agent is a one-line change here. -export function hasBlockGrammar(agent: string | undefined): boolean { - return agent === "claude"; -} diff --git a/web/src/lib/grammar/chrome.test.ts b/web/src/lib/harness/claude/chrome.test.ts similarity index 97% rename from web/src/lib/grammar/chrome.test.ts rename to web/src/lib/harness/claude/chrome.test.ts index 0132555..3b49753 100644 --- a/web/src/lib/grammar/chrome.test.ts +++ b/web/src/lib/harness/claude/chrome.test.ts @@ -2,13 +2,13 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { parseAnsi } from "../ansi"; -import { splitLines, type StyledLine } from "../blocks"; +import { parseAnsi } from "../../ansi"; +import { splitLines, type StyledLine } from "../../blocks"; import { extractInputDraft, extractStatusLine, stripChrome } from "./chrome"; import { lineText } from "./markers"; // Anchored on this file's directory (see prompt-select.test.ts for why not `new URL(import.meta.url)`). -const PANES_DIR = join(import.meta.dirname, "..", "..", "fixtures", "panes"); +const PANES_DIR = join(import.meta.dirname, "..", "..", "..", "fixtures", "panes"); // Synthesise the input-box shape: a top rule, the "❯ …" prompt line, a bottom rule, and an optional // statusline below it (matched by position, like the real captures). 40 box glyphs clear the diff --git a/web/src/lib/grammar/chrome.ts b/web/src/lib/harness/claude/chrome.ts similarity index 99% rename from web/src/lib/grammar/chrome.ts rename to web/src/lib/harness/claude/chrome.ts index 6353f1a..8696bf5 100644 --- a/web/src/lib/grammar/chrome.ts +++ b/web/src/lib/harness/claude/chrome.ts @@ -8,7 +8,7 @@ // T1 raw-mirror fallback). Pure; operates on parsed line text, so a user-configured statusline is // matched by POSITION (below the box's bottom border), never by its content strings. -import type { StyledLine } from "../blocks"; +import type { StyledLine } from "../../blocks"; import { isBlank, isBoxBorder, lineText } from "./markers"; // Lines allowed between the input box's bottom border and the tail: the statusline plus a hint line diff --git a/web/src/lib/harness/claude/index.ts b/web/src/lib/harness/claude/index.ts new file mode 100644 index 0000000..ed07f2b --- /dev/null +++ b/web/src/lib/harness/claude/index.ts @@ -0,0 +1,85 @@ +// The Claude Code adapter — the one agent whose TUI shape is VERIFIED (input box, menu footers, +// stepper header) against the fixture corpus in web/src/fixtures/panes/*.txt. Its detectors +// (prompt-select, wizard, preview-select, chrome, markers) live alongside this file; this module +// wires them into the two HarnessAdapter surfaces: the block pipeline (claudeBuildBlocks) and the +// chrome re-surfacing probes (extractStatusLine / extractInputDraft, re-exported from ./chrome). +// +// Every OTHER agent (codex, opencode, pi, a bare shell, or an unknown/absent agent) has an unverified +// TUI shape, so it has no adapter and keeps the plain raw terminal mirror — running Claude's matchers +// on it could mis-lift a menu, strip real output as "chrome", or paint a bogus status strip. + +import { trimTrailingBlank, type Block, type StyledLine } from "../../blocks"; +import type { HarnessAdapter } from "../types"; +import { detectPreviewSelectRegion } from "./preview-select"; +import { detectWizardRegion } from "./wizard"; +import { detectMultiSelectRegion } from "./multi-select"; +import { detectPromptSelectRegion } from "./prompt-select"; +import { stripChrome, extractStatusLine, extractInputDraft } from "./chrome"; + +/** + * Claude's block pipeline: detect a tail dialog (preview / wizard / prompt-select), replacing it with + * a typed block and keeping everything above it raw; otherwise strip trailing chrome. Any detection + * miss falls back to a single raw block — the universal T1 behaviour. The registry only ever hands + * this function a Claude pane, so there is no per-agent gate here. + */ +export function claudeBuildBlocks(lines: StyledLine[]): Block[] { + // The preview variant runs FIRST: its footer is the most specific anchor ("n to add notes"), + // and although the wizard/prompt-select detectors can't match its layout (their footer-gap + // guards fail on the tall preview pane), ordering by specificity keeps the arbitration obvious. + const previewRegion = detectPreviewSelectRegion(lines); + if (previewRegion) { + const before = trimTrailingBlank(lines.slice(0, previewRegion.startLine)); + const blocks: Block[] = []; + if (before.length > 0) blocks.push({ kind: "raw", lines: before }); + blocks.push({ + kind: "preview-select", + preview: previewRegion.model, + lines: lines.slice(previewRegion.startLine), + }); + return blocks; + } + + // The wizard runs before prompt-select: its question phase also carries a select footer, so + // prompt-select's detector would otherwise have to arbitrate (today it bails on the stepper + // header — that bail stays as a safety net for a wizard this detector misses). + const wizardRegion = detectWizardRegion(lines); + if (wizardRegion) { + const before = trimTrailingBlank(lines.slice(0, wizardRegion.startLine)); + const blocks: Block[] = []; + if (before.length > 0) blocks.push({ kind: "raw", lines: before }); + blocks.push({ kind: "wizard", wizard: wizardRegion.model, lines: lines.slice(wizardRegion.startLine) }); + return blocks; + } + + // Multi-select runs after the wizard, before prompt-select: its checkbox screen also carries a + // select footer, but its `[ ]`/`[✔]` rows + single-question stepper are unique to it (prompt-select + // bails on the multi-step glyph, wizard on the 2-chip stepper), so ordering keeps arbitration clear. + const multiRegion = detectMultiSelectRegion(lines); + if (multiRegion) { + const before = trimTrailingBlank(lines.slice(0, multiRegion.startLine)); + const blocks: Block[] = []; + if (before.length > 0) blocks.push({ kind: "raw", lines: before }); + blocks.push({ kind: "multi-select", multi: multiRegion.model, lines: lines.slice(multiRegion.startLine) }); + return blocks; + } + + const region = detectPromptSelectRegion(lines); + if (region) { + const before = trimTrailingBlank(lines.slice(0, region.startLine)); + const blocks: Block[] = []; + if (before.length > 0) blocks.push({ kind: "raw", lines: before }); + blocks.push({ kind: "prompt-select", prompt: region.model, lines: lines.slice(region.startLine) }); + return blocks; + } + + return [{ kind: "raw", lines: stripChrome(lines) }]; +} + +export { extractStatusLine, extractInputDraft }; + +export const claudeAdapter: HarnessAdapter = { + agent: "claude", + buildBlocks: claudeBuildBlocks, + extractStatusLine, + extractInputDraft, +}; diff --git a/web/src/lib/grammar/markers.test.ts b/web/src/lib/harness/claude/markers.test.ts similarity index 97% rename from web/src/lib/grammar/markers.test.ts rename to web/src/lib/harness/claude/markers.test.ts index e470c70..15cfc96 100644 --- a/web/src/lib/grammar/markers.test.ts +++ b/web/src/lib/harness/claude/markers.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { parseAnsi } from "../ansi"; -import { splitLines } from "../blocks"; +import { parseAnsi } from "../../ansi"; +import { splitLines } from "../../blocks"; import { classifyFooter, isBlank, diff --git a/web/src/lib/grammar/markers.ts b/web/src/lib/harness/claude/markers.ts similarity index 98% rename from web/src/lib/grammar/markers.ts rename to web/src/lib/harness/claude/markers.ts index b54b8e7..6170932 100644 --- a/web/src/lib/grammar/markers.ts +++ b/web/src/lib/harness/claude/markers.ts @@ -4,7 +4,7 @@ // *between* glyphs, so a regex over the raw buffer would miss (e.g. the `❯` pointer and the `1.` are // separate styled segments). Pure functions, no I/O, no React. -import type { StyledLine } from "../blocks"; +import type { StyledLine } from "../../blocks"; /** The visible text of a line: its segments' text concatenated (the "\n" separator lives between * lines, so a single line's text never contains one). */ diff --git a/web/src/lib/harness/claude/multi-select.test.ts b/web/src/lib/harness/claude/multi-select.test.ts new file mode 100644 index 0000000..6c7e3b5 --- /dev/null +++ b/web/src/lib/harness/claude/multi-select.test.ts @@ -0,0 +1,220 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { parseAnsi } from "../../ansi"; +import { splitLines, type StyledLine } from "../../blocks"; +import { detectMultiSelect, detectMultiSelectRegion } from "./multi-select"; +import { detectWizard } from "./wizard"; +import { detectPromptSelect } from "./prompt-select"; +import { detectPreviewSelect } from "./preview-select"; +import { lineText } from "./markers"; + +// The multi-select detector is developed and gated against byte-faithful sandbox captures of a real +// multiSelect AskUserQuestion (claude--select-multiselect-*.txt; interaction model probed live). +// Each fixture runs through the production parseAnsi → splitLines pipeline. Hard gates: the checkbox +// screen detects its question / options / checked state / escape / pointer; the review screen detects +// its incomplete flag; every OTHER grammar (prompt-select / wizard / preview) yields zero detections. + +// Anchored on this file's own directory (NOT `new URL(..., import.meta.url)`, which Vite rewrites). +const PANES_DIR = join(import.meta.dirname, "..", "..", "..", "fixtures", "panes"); +const fixtureText = (name: string) => readFileSync(join(PANES_DIR, name), "utf8"); +const fixtureLines = (name: string): StyledLine[] => splitLines(parseAnsi(fixtureText(name))); + +describe("detectMultiSelect — checkbox phase", () => { + it("all unchecked: question, options (labels + n + unchecked), escape, pointer", () => { + const model = detectMultiSelect(fixtureLines("claude--select-multiselect-single.txt")); + expect(model).not.toBeNull(); + if (model!.phase !== "checkbox") throw new Error("expected checkbox phase"); + expect(model!.question).toBe("Which pizza toppings do you want?"); + // "5. [ ] Type something" is a free-text row → stays with the composer, dropped from options. + expect(model!.options.map((o) => o.label)).toEqual(["Cheese", "Mushrooms", "Olives", "Peppers"]); + // The digit that toggles each row. + expect(model!.options.map((o) => o.n)).toEqual([1, 2, 3, 4]); + // Descriptions attach like the sibling grammars. + expect(model!.options[0]!.description).toBe("Classic melted cheese topping."); + // Nothing checked yet. + expect(model!.options.every((o) => !o.checked)).toBe(true); + // "6. Chat about this" is the escape (aborts the tool), kept out of options. + expect(model!.escape).toEqual({ n: 6, label: "Chat about this" }); + // The ❯ sits on the first option row. + expect(model!.pointer).toBe("option"); + }); + + it("some checked: the [✔] rows lift into checked=true (byte-faithful fixture)", () => { + const model = detectMultiSelect(fixtureLines("claude--select-multiselect-checked.txt")); + expect(model).not.toBeNull(); + if (model!.phase !== "checkbox") throw new Error("expected checkbox phase"); + // Mushrooms + Olives were hand-checked; Cheese + Peppers stay unchecked. + expect(model!.options.map((o) => [o.label, o.checked])).toEqual([ + ["Cheese", false], + ["Mushrooms", true], + ["Olives", true], + ["Peppers", false], + ]); + }); + + it("the checked screen's core signature is checkbox-independent (== the all-unchecked screen's)", () => { + // The pointer + option checkboxes + stepper answered-glyph are normalised out, so the two screens + // — same dialog, different checked state — share ONE identity signature (the drift key). The + // per-option `checked` is what still separates them (see multiSelectEquals). + const a = detectMultiSelect(fixtureLines("claude--select-multiselect-single.txt"))!; + const b = detectMultiSelect(fixtureLines("claude--select-multiselect-checked.txt"))!; + expect(a.signature).toBe(b.signature); + }); +}); + +describe("detectMultiSelect — review phase", () => { + it("detects the confirm screen and the incomplete (⚠) flag", () => { + const model = detectMultiSelect(fixtureLines("claude--select-multiselect-review.txt")); + expect(model).not.toBeNull(); + if (model!.phase !== "review") throw new Error("expected review phase"); + expect(model!.incomplete).toBe(true); + }); + + it("a complete review (no ⚠) is incomplete=false", () => { + const buf = [ + "← ☒ Toppings ✔ Submit →", + "", + "Review your answers", + "", + "Ready to submit your answers?", + "", + "❯ 1. Submit answers", + " 2. Cancel", + ].join("\n"); + const model = detectMultiSelect(splitLines(parseAnsi(buf))); + expect(model).not.toBeNull(); + if (model!.phase !== "review") throw new Error("expected review phase"); + expect(model!.incomplete).toBe(false); + }); +}); + +describe("detectMultiSelectRegion — render boundary", () => { + it("starts the checkbox region at the single-question stepper (raw scrollback stays above)", () => { + const lines = fixtureLines("claude--select-multiselect-single.txt"); + const region = detectMultiSelectRegion(lines); + expect(region).not.toBeNull(); + const first = lineText(lines[region!.startLine]!); + expect(first).toContain("Toppings"); + expect(first).toContain("Submit"); + expect(region!.model).toEqual(detectMultiSelect(lines)); + }); + + it("starts the review region at the stepper too", () => { + const lines = fixtureLines("claude--select-multiselect-review.txt"); + const region = detectMultiSelectRegion(lines); + expect(region).not.toBeNull(); + expect(lineText(lines[region!.startLine]!)).toContain("Submit"); + }); +}); + +describe("detectMultiSelect — false-positive gate", () => { + // The other grammars must never claim a multiSelect fixture (each of the three phases). + for (const name of [ + "claude--select-multiselect-single.txt", + "claude--select-multiselect-checked.txt", + "claude--select-multiselect-review.txt", + ]) { + it(`${name} is not a prompt-select / wizard / preview dialog`, () => { + const lines = fixtureLines(name); + expect(detectPromptSelect(lines), "prompt-select").toBeNull(); + expect(detectWizard(lines), "wizard").toBeNull(); + expect(detectPreviewSelect(lines), "preview").toBeNull(); + }); + } + + // Conversely, multi-select must never claim a NON-multiSelect dialog. + for (const name of [ + "claude--select-menu.txt", + "claude--wizard-q1.txt", + "claude--wizard-submit.txt", + "claude--permission-edit.txt", + "claude--trust-prompt.txt", + "claude--working.txt", + "claude--fresh-idle.txt", + ]) { + it(`${name} produces zero multi-select detections`, () => { + expect(detectMultiSelect(fixtureLines(name))).toBeNull(); + }); + } + + it("a checkbox screen scrolled up out of the tail does not match (output appended below)", () => { + const withTail = + fixtureText("claude--select-multiselect-single.txt") + "\n● Wrote the file\n ⎿ done\n"; + expect(detectMultiSelect(splitLines(parseAnsi(withTail)))).toBeNull(); + }); + + it("empty and whitespace-only buffers do not match", () => { + expect(detectMultiSelect(splitLines(parseAnsi("")))).toBeNull(); + expect(detectMultiSelect(splitLines(parseAnsi("\n\n \n")))).toBeNull(); + }); + + it("bails when the checkbox screen has no navigable Submit row (partial render → fail closed)", () => { + // A well-formed checkbox screen MINUS the standalone "Submit" row: the Submit macro would have no + // verified target to walk onto, so the detector must not lift it (falls to the raw mirror instead). + const buf = [ + "← ☐ Toppings ✔ Submit →", + "", + "Which pizza toppings do you want?", + "", + "❯ 1. [ ] Cheese", + " 2. [ ] Mushrooms", + " 3. [ ] Olives", + " 4. [ ] Peppers", + " 5. [ ] Type something", + "─".repeat(80), + " 6. Chat about this", + "", + "Enter to select · ↑/↓ to navigate · Esc to cancel", + ].join("\n"); + expect(detectMultiSelect(splitLines(parseAnsi(buf)))).toBeNull(); + }); + + it("bails when the checkbox menu exceeds 9 rows (a row ≥10 needs the unsendable digit \"10\")", () => { + // Ten checkbox options + the "Chat about this" escape = an 11-row menu. Option 10's toggle would + // need the two-key "10", which pane.send_keys can't express — so the detector fails closed to the + // raw mirror + keys pad rather than render a control it can't actuate. + const optRows = Array.from({ length: 10 }, (_, i) => ` ${i + 1}. [ ] Option ${i + 1}`); + const buf = [ + "← ☐ Pick ✔ Submit →", + "", + "Choose as many as you like", + "", + ...optRows, + " Submit", + "─".repeat(80), + " 11. Chat about this", + "", + "Enter to select · ↑/↓ to navigate · Esc to cancel", + ].join("\n"); + expect(detectMultiSelect(splitLines(parseAnsi(buf)))).toBeNull(); + }); +}); + +describe("wizard bails on a multiSelect step inside a multi-question wizard (v1 unsupported)", () => { + // A ≥3-chip stepper (2 questions + Submit) whose option rows carry the checkbox prefix: a + // multiSelect step of a multi-question wizard. The wizard grammar must bail (a digit there TOGGLES, + // not select-and-advance), and multi-select — which needs EXACTLY a single-question stepper — bails + // too, so it falls to the raw mirror + keys pad. + const checkboxWizard = [ + "← ☐ Focus ☐ Scope ✔ Submit →", + "", + "Which options should we use?", + "", + " 1. [ ] Alpha", + " 2. [✔] Beta", + "Enter to select · Esc to cancel", + ].join("\n"); + const plainWizard = checkboxWizard.replace("[ ] Alpha", "Alpha").replace("[✔] Beta", "Beta"); + + it("the plain (no-checkbox) control DOES detect as a wizard", () => { + expect(detectWizard(splitLines(parseAnsi(plainWizard)))).not.toBeNull(); + }); + + it("the checkbox variant makes the wizard bail — and multi-select doesn't claim it either", () => { + const lines = splitLines(parseAnsi(checkboxWizard)); + expect(detectWizard(lines)).toBeNull(); + expect(detectMultiSelect(lines)).toBeNull(); + }); +}); diff --git a/web/src/lib/harness/claude/multi-select.ts b/web/src/lib/harness/claude/multi-select.ts new file mode 100644 index 0000000..9a3eea6 --- /dev/null +++ b/web/src/lib/harness/claude/multi-select.ts @@ -0,0 +1,330 @@ +// Multi-select AskUserQuestion detection — the grammar that recognises Claude Code's `multiSelect` +// dialog (the CHECKBOX form of AskUserQuestion) at the TAIL of a pane buffer and lifts the current +// screen into a `MultiSelectModel` the UI renders natively. Two phases mirror the wizard's structure: +// +// - `checkbox`: numbered rows carry a `[ ]`/`[✔]` checkbox prefix (THE discriminator vs a single +// select), a navigable `Submit` row sits below them, and a single-question stepper +// (`← ☐ Toppings ✔ Submit →` — exactly ONE question chip + the Submit chip) anchors the top. +// A digit N TOGGLES option N on/off (pointer-independent); Enter activates the POINTED row. +// - `review`: activating Submit advances here — `Ready to submit your answers?` over +// `❯ 1. Submit answers` / `2. Cancel`, with the same single-question stepper. Digit 1 submits, +// 2 cancels; a `⚠ …not answered all questions` line means `incomplete`. +// +// This shape currently falls to the raw mirror because prompt-select bails on the multi-step glyph +// and wizard bails on its ≥3-chip stepper requirement — so this detector adds a new surface rather +// than re-arbitrating an existing one. Everything here is PURE over `StyledLine[]`, fixture-driven +// (web/src/fixtures/panes/claude--select-multiselect-*.txt), and never touches a pane or the network. + +import type { StyledLine } from "../../blocks"; +import { classifyFooter, isBlank, isHorizontalRule, lineText } from "./markers"; +import { checkboxState, isFreeTextLabel, parseOptionRow, trailingMenuRows } from "./prompt-select"; + +/** One checkable option of the current checkbox question. */ +export interface MultiSelectOption { + /** The option's digit — pressing it TOGGLES this row (pointer-independent). */ + n: number; + /** The visible label with the `[ ]`/`[✔]` prefix stripped (a React text node downstream). */ + label: string; + /** Secondary descriptive line(s), joined with spaces. Absent when none. */ + description?: string; + /** Lifted from the checkbox glyph: `[✔]`/`[x]`/`[✓]` = checked, `[ ]` = unchecked. The terminal is + * the single source of truth (a digit is an XOR — the UI never holds its own checked state). */ + checked: boolean; +} + +/** The unnumbered-in-spirit "Chat about this" escape (it carries a digit, but ABORTS the tool). */ +export interface MultiSelectEscape { + n: number; + label: string; +} + +/** Which KIND of row the `❯` pointer sits on — the Submit macro drives it to `submit` before Enter. + * Parsed SEPARATELY from the signature (which normalises the pointer out), so the macro's own + * Down/Up moves don't perturb the race-guard identity. */ +export type MultiPointer = "submit" | "chat" | "option" | "other" | null; + +/** + * The detected multi-select dialog, a union on `phase`: + * - `checkbox`: the question + its checkable options, the "Chat about this" escape, and where the + * pointer sits. A digit toggles; Submit advances (closed-loop macro — see multi-select-action.ts). + * - `review`: the confirm screen — submit = key `1`, cancel = key `2` (constants, off the model). + * + * `signature` is a byte-signature of the on-screen region (stepper → tail) with BOTH the `❯` pointer + * AND each `[✔]`/`[ ]` checkbox glyph normalised out: it captures the subject + labels only, so the + * Submit macro's pointer moves and a checkbox flip don't spuriously fail the race guard. The transient + * state (pointer, checked) is compared separately by multiSelectEquals via the options[]. Herdr's + * `revision` is a stub, so this content signature is the load-bearing freshness check (mirrors the + * sibling grammars' `signature`/`coreSignature`). + */ +export type MultiSelectModel = + | { + phase: "checkbox"; + question: string; + options: MultiSelectOption[]; + escape: MultiSelectEscape | null; + pointer: MultiPointer; + signature: string; + } + | { + phase: "review"; + incomplete: boolean; + signature: string; + }; + +/** Detection result for buildBlocks: the model plus the region's first line — the multi-select + * region is [`startLine` … tail], which the renderer replaces with the native block. */ +export interface MultiSelectRegion { + model: MultiSelectModel; + startLine: number; +} + +// Same bounded-window philosophy as the sibling grammars: options sit against the footer; the +// single-question stepper sits a few lines above the first option. +const OPTION_SCAN_WINDOW = 24; +const MAX_FOOTER_GAP = 3; +const STEPPER_SCAN_LIMIT = 10; +// The review screen lists its prompt + warning between the stepper and the submit rows, so the +// upward window from the "Ready…" prompt to the stepper is more generous. +const REVIEW_SCAN_WINDOW = 48; + +const CHAT_ESCAPE = /^chat about this\b/i; +// The navigable Submit row (no digit — Enter activates it), possibly carrying the pointer. +const SUBMIT_ROW = /^❯?\s*Submit$/; +const READY_PROMPT = /^ready to submit your answers\?/i; + +// --------------------------------------------------------------------------------------------- +// Single-question stepper +// --------------------------------------------------------------------------------------------- + +// One chip: a state glyph then its label, up to the next glyph / nav arrow. The multiSelect stepper +// carries exactly ONE question chip (`☐`/`☒`/`☑`) plus the fixed `✔ Submit` chip — two chips total, +// which is why the wizard's parseStepperLine (≥3 chips) never claims it. +const CHIP = /([☐☒☑✔✅])\s*([^☐☒☑✔✅←→]*)/g; +const SUBMIT_GLYPHS = "✔✅"; + +/** True when `line` is the multiSelect stepper: exactly one question chip + the `✔ Submit` chip. */ +function isSingleQuestionStepper(line: StyledLine): boolean { + const text = lineText(line); + const chips: { glyph: string; label: string }[] = []; + CHIP.lastIndex = 0; + for (let m = CHIP.exec(text); m !== null; m = CHIP.exec(text)) { + chips.push({ glyph: m[1]!, label: m[2]!.trim() }); + } + if (chips.length !== 2) return false; + const question = chips[0]!; + const submit = chips[1]!; + if (SUBMIT_GLYPHS.includes(question.glyph) || question.label.length === 0) return false; + return SUBMIT_GLYPHS.includes(submit.glyph) && /^submit$/i.test(submit.label); +} + +/** Scan upward from `start` down to `floor` for the single-question stepper, stopping at a horizontal + * rule (the dialog border — the stepper always sits below it). Returns its line index, or -1. */ +function findSingleStepper( + lines: StyledLine[], + texts: string[], + start: number, + floor: number, +): number { + for (let i = start; i >= 0 && i >= floor; i--) { + if (isHorizontalRule(texts[i]!)) return -1; + if (isSingleQuestionStepper(lines[i]!)) return i; + } + return -1; +} + +/** The region's pointer/checkbox-independent signature: lines [`from` … `to`] with the `❯` pointer, + * every `[✔]`/`[ ]` option checkbox, and the stepper's answered chip glyph (`☒`/`☑`→`☐`) normalised + * out — so it captures the SUBJECT + labels only, stable across the whole toggle + Submit-macro + * choreography (the pointer moves, boxes flip). The transient state (pointer, checked) is compared + * SEPARATELY by multiSelectEquals via the options[]. Pure of the buffer's absolute offset, so the + * frozen model and a fresh re-derivation of the SAME dialog produce byte-equal strings. */ +function coreSignature(texts: string[], from: number, to: number): string { + return texts + .slice(from, to + 1) + .map((t) => + t + .replace(/❯/g, " ") + .replace(/\[[ xX✔✓]\]/g, "[ ]") + .replace(/[☒☑]/g, "☐"), + ) + .join("\n"); +} + +/** Classify which row the `❯` pointer sits on across [`from` … `to`] (raw line text — the pointer is + * a leading glyph). `null` when no row carries it. */ +function pointerAt(texts: string[], from: number, to: number): MultiPointer { + for (let i = from; i <= to; i++) { + if (!/^\s*❯/.test(texts[i]!)) continue; + const trimmed = texts[i]!.trim(); + if (SUBMIT_ROW.test(trimmed)) return "submit"; + const parsed = parseOptionRow(trimmed); + if (!parsed) return "other"; + if (CHAT_ESCAPE.test(parsed.label)) return "chat"; + const cb = checkboxState(parsed.label); + if (cb && !isFreeTextLabel(cb.rest)) return "option"; + return "other"; // the free-text "Type something" row, or an unrecognised pointed row + } + return null; +} + +// --------------------------------------------------------------------------------------------- +// Detection +// --------------------------------------------------------------------------------------------- + +/** + * Detect a multi-select dialog at the tail of `lines`: either the checkbox screen (select footer + + * checkbox rows + single-question stepper) or the review screen (its `1. Submit answers / 2. Cancel` + * tail). Returns the model + its start line, or null when the tail is anything else. Pure; the caller + * owns pane access. + */ +export function detectMultiSelectRegion(lines: StyledLine[]): MultiSelectRegion | null { + const texts = lines.map(lineText); + + let fi = texts.length - 1; + while (fi >= 0 && isBlank(texts[fi]!)) fi--; + if (fi < 0) return null; + + return detectCheckboxPhase(lines, texts, fi) ?? detectReviewPhase(lines, texts, fi); +} + +/** Just the model (or null) — the race guard's re-derivation entry point. */ +export function detectMultiSelect(lines: StyledLine[]): MultiSelectModel | null { + return detectMultiSelectRegion(lines)?.model ?? null; +} + +/** The checkbox screen: a select-family footer at the tail, numbered checkbox rows above it, and the + * single-question stepper — the multiSelect discriminator — above the first row. */ +function detectCheckboxPhase( + lines: StyledLine[], + texts: string[], + fi: number, +): MultiSelectRegion | null { + if (classifyFooter(texts[fi]!) !== "select") return null; + + // Numbered rows near the footer — the trailing 1,2,…,m run (the checkbox options + the numbered + // "Chat about this" escape); a numbered body above it drops out (same hazard as prompt-select). + const from = Math.max(0, fi - OPTION_SCAN_WINDOW); + const rows: { index: number; n: number; label: string }[] = []; + for (let i = from; i < fi; i++) { + const parsed = parseOptionRow(texts[i]!); + if (parsed) rows.push({ index: i, ...parsed }); + } + if (rows.length < 2) return null; + const menu = trailingMenuRows(rows); + if (menu.length < 2) return null; + if (menu.length > 9) return null; // a row ≥10 needs the unsendable digit "10" + const firstOpt = menu[0]!.index; + if (fi - menu[menu.length - 1]!.index > MAX_FOOTER_GAP) return null; + + // The screen MUST carry a navigable "Submit" row (Enter on it advances to review). The Submit macro + // walks the pointer ONTO that row before it ever Enters; if the row is absent (a partial / garbled + // render), fail closed to the raw mirror rather than lift a dialog whose Submit the macro would hunt + // for by typing nav keys into the live pane. (The `✔ Submit` stepper CHIP is not this row — + // SUBMIT_ROW anchors the whole line, so the chip never satisfies it.) + if (!texts.slice(firstOpt, fi).some((t) => SUBMIT_ROW.test(t.trim()))) return null; + + const stepperIdx = findSingleStepper(lines, texts, firstOpt - 1, firstOpt - STEPPER_SCAN_LIMIT); + if (stepperIdx < 0) return null; + + // The question: every non-blank line between the stepper and the first option, joined. + const questionLines: string[] = []; + for (let i = stepperIdx + 1; i < firstOpt; i++) { + if (!isBlank(texts[i]!)) questionLines.push(texts[i]!.trim()); + } + if (questionLines.length === 0) return null; + const question = questionLines.join(" "); + + // Options: strip the checkbox prefix, lift `checked`, attach description sub-lines. "Chat about + // this" is captured as the escape; "Type something" stays with the composer. EVERY non-escape row + // must carry the checkbox prefix — a row without one means this is a single select, not multiSelect. + const options: MultiSelectOption[] = []; + let escape: MultiSelectEscape | null = null; + for (let r = 0; r < menu.length; r++) { + const row = menu[r]!; + if (CHAT_ESCAPE.test(row.label)) { + escape = { n: row.n, label: row.label }; + continue; + } + const cb = checkboxState(row.label); + if (!cb) return null; // the checkbox-prefix discriminator: bail to the single-select grammar + if (isFreeTextLabel(cb.rest)) continue; + const nextIdx = r + 1 < menu.length ? menu[r + 1]!.index : fi; + const desc: string[] = []; + for (let i = row.index + 1; i < nextIdx; i++) { + const t = texts[i]!; + if (isBlank(t) || isHorizontalRule(t) || parseOptionRow(t) || SUBMIT_ROW.test(t.trim())) continue; + desc.push(t.trim()); + } + options.push({ + n: row.n, + label: cb.rest, + description: desc.length ? desc.join(" ") : undefined, + checked: cb.checked, + }); + } + if (options.length === 0) return null; + + return { + model: { + phase: "checkbox", + question, + options, + escape, + pointer: pointerAt(texts, firstOpt, fi), + // Signature ends at the LAST menu row, NOT the footer: Claude's footer gains/loses a + // "· ctrl+g to edit in nano" hint depending on which row the ❯ sits on (present on the + // free-text/Submit/chat rows, absent on the checkbox rows). Since the Submit macro walks the + // pointer down the dialog, a footer-inclusive signature would mutate mid-macro and read as + // drift, aborting the submit. The footer is chrome anyway — classifyFooter gates detection. + signature: coreSignature(texts, stepperIdx, menu[menu.length - 1]!.index), + }, + startLine: stepperIdx, + }; +} + +/** The review screen. NO footer exists here (like the wizard's Submit step): the buffer tail is the + * "❯ 1. Submit answers / 2. Cancel" pair, with "Ready to submit your answers?" just above and the + * single-question stepper at the top of the region. */ +function detectReviewPhase( + lines: StyledLine[], + texts: string[], + fi: number, +): MultiSelectRegion | null { + // Tail anchor: the last non-blank line is exactly the Cancel row, the row above it Submit answers. + // Literal labels — Claude Code generates them (not user content). + const cancel = parseOptionRow(texts[fi]!); + if (!cancel || cancel.n !== 2 || cancel.label !== "Cancel") return null; + let si = fi - 1; + while (si >= 0 && isBlank(texts[si]!)) si--; + if (si < 0) return null; + const submit = parseOptionRow(texts[si]!); + if (!submit || submit.n !== 1 || submit.label !== "Submit answers") return null; + + // "Ready to submit your answers?" — the nearest non-blank line above the Submit row. + let ri = -1; + for (let i = si - 1, seen = 0; i >= 0 && seen < 4; i--, seen++) { + if (isBlank(texts[i]!)) continue; + if (READY_PROMPT.test(texts[i]!.trim())) ri = i; + break; // only the nearest non-blank line may be the prompt + } + if (ri < 0) return null; + + // The single-question stepper above the prompt — mutually exclusive with the wizard review (whose + // ≥3-chip stepper never matches isSingleQuestionStepper). + const stepperIdx = findSingleStepper(lines, texts, ri - 1, ri - REVIEW_SCAN_WINDOW); + if (stepperIdx < 0) return null; + + // `incomplete` = a ⚠ "not answered all questions" line sits between the stepper and the prompt. + let incomplete = false; + for (let i = stepperIdx + 1; i < ri; i++) { + if (texts[i]!.trim().startsWith("⚠")) { + incomplete = true; + break; + } + } + + return { + model: { phase: "review", incomplete, signature: coreSignature(texts, stepperIdx, fi) }, + startLine: stepperIdx, + }; +} diff --git a/web/src/lib/grammar/preview-select.test.ts b/web/src/lib/harness/claude/preview-select.test.ts similarity index 97% rename from web/src/lib/grammar/preview-select.test.ts rename to web/src/lib/harness/claude/preview-select.test.ts index 4b8f3c1..9ee5a21 100644 --- a/web/src/lib/grammar/preview-select.test.ts +++ b/web/src/lib/harness/claude/preview-select.test.ts @@ -2,15 +2,16 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { parseAnsi } from "../ansi"; -import { buildBlocks, splitLines, type StyledLine } from "../blocks"; +import { parseAnsi } from "../../ansi"; +import { splitLines, type StyledLine } from "../../blocks"; +import { buildBlocks } from ".."; import { detectPreviewSelect, detectPreviewSelectRegion } from "./preview-select"; import { detectPromptSelect } from "./prompt-select"; import { detectWizard } from "./wizard"; import { lineText } from "./markers"; // Anchored on this file's own directory (see prompt-select.test.ts for why not import.meta.url). -const PANES_DIR = join(import.meta.dirname, "..", "..", "fixtures", "panes"); +const PANES_DIR = join(import.meta.dirname, "..", "..", "..", "fixtures", "panes"); // The detector is developed and gated entirely against the byte-faithful preview-variant captures // (see NOTES_NOTES.md for the live-verified choreography they encode). Every fixture runs through diff --git a/web/src/lib/grammar/preview-select.ts b/web/src/lib/harness/claude/preview-select.ts similarity index 99% rename from web/src/lib/grammar/preview-select.ts rename to web/src/lib/harness/claude/preview-select.ts index a6892c5..51a4437 100644 --- a/web/src/lib/grammar/preview-select.ts +++ b/web/src/lib/harness/claude/preview-select.ts @@ -14,7 +14,7 @@ // Everything here is PURE over `StyledLine[]`, fixture-driven (web/src/fixtures/panes/ // claude--*preview*.txt), and never touches a pane or the network. -import type { StyledLine } from "../blocks"; +import type { StyledLine } from "../../blocks"; import { classifyFooter, isBlank, isHorizontalRule, lineText } from "./markers"; import { parseOptionRow } from "./prompt-select"; import { parseStepperLine, type WizardStepChip } from "./wizard"; diff --git a/web/src/lib/grammar/prompt-select.test.ts b/web/src/lib/harness/claude/prompt-select.test.ts similarity index 82% rename from web/src/lib/grammar/prompt-select.test.ts rename to web/src/lib/harness/claude/prompt-select.test.ts index c915610..4dcbee5 100644 --- a/web/src/lib/grammar/prompt-select.test.ts +++ b/web/src/lib/harness/claude/prompt-select.test.ts @@ -2,14 +2,14 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { parseAnsi } from "../ansi"; -import { splitLines, type StyledLine } from "../blocks"; +import { parseAnsi } from "../../ansi"; +import { splitLines, type StyledLine } from "../../blocks"; import { detectPromptSelect, detectPromptSelectRegion, type PromptFamily } from "./prompt-select"; import { lineText } from "./markers"; // Anchored on this file's own directory (NOT `new URL(..., import.meta.url)`, which Vite statically // rewrites into a root-relative asset path) so the fixtures resolve regardless of the run cwd. -const PANES_DIR = join(import.meta.dirname, "..", "..", "fixtures", "panes"); +const PANES_DIR = join(import.meta.dirname, "..", "..", "..", "fixtures", "panes"); // The detector is developed and gated entirely against the byte-faithful pane captures in // web/src/fixtures/panes/*.txt (real ESC bytes; see that README). Each fixture is run through the @@ -95,6 +95,26 @@ describe("detectPromptSelect — the five blocked-state fixtures", () => { }); }); +describe("detectPromptSelect — numbered dialog body above the menu (suffix extraction)", () => { + it("plan approval whose plan lists numbered steps still detects the real menu", () => { + // The plan body carries "1. Title / 2. … / 4. Context / 5. TODO stub" and the option-scan window + // catches the trailing "4./5." — so rows collect as [4,5,1,2,3,4]. The menu is the maximal + // trailing 1,2,…,m run ([1,2,3,4]); the body rows above it drop out. Before the fix the + // whole-collection "must be 1..k" check saw rows[0]=4 and bailed to the raw mirror. + const model = detectPromptSelect(fixtureLines("claude--plan-approval--numbered-body.txt")); + expect(model).not.toBeNull(); + expect(model!.family).toBe("plan"); + expect(model!.question).toContain("ready to execute"); + // "4. Tell Claude what to change" is a free-text escape row → dropped, leaving three buttons. + expect(model!.options.map((o) => o.label)).toEqual([ + "Yes, and use auto mode", + "Yes, manually approve edits", + "No, refine with Ultraplan on Claude Code on the web", + ]); + expect(model!.options.map((o) => o.keys)).toEqual([["1"], ["2"], ["3"]]); + }); +}); + describe("detectPromptSelect — family → keystroke recipe (regression guard)", () => { // Table-driven over all four families: `select` confirms on the digit THEN Enter; the confirm // families (trust/permission/plan) fire on the digit ALONE (a trailing Enter there would leak into @@ -141,6 +161,14 @@ describe("detectPromptSelect — false-positive gate (no menu at the tail)", () expect(detectPromptSelect(fixtureLines("claude--select-multi.txt"))).toBeNull(); }); + it("single-question multiSelect AskUserQuestion is not claimed by prompt-select", () => { + // A !wizard multiSelect (checkbox "[ ]" options under a "☐ Toppings ✔ Submit" stepper): + // prompt-select BAILS on the multi-step stepper glyph (its "☐ … ✔ Submit" line trips the + // multi-step-header bail — 2 step glyphs), so detectPromptSelect returns null here. The + // multi-select grammar (multi-select.ts) is what claims this dialog and lifts it natively. + expect(detectPromptSelect(fixtureLines("claude--select-multiselect-single.txt"))).toBeNull(); + }); + it("bails on a menu with more than 9 numbered rows (option 10 needs the unsendable key '10')", () => { // 10 consecutive rows 1..10 under a select footer would otherwise up-level, emitting a broken // keys:["10","Enter"] Herdr rejects. The >9 guard bails to the raw mirror instead. (Claude menus diff --git a/web/src/lib/grammar/prompt-select.ts b/web/src/lib/harness/claude/prompt-select.ts similarity index 78% rename from web/src/lib/grammar/prompt-select.ts rename to web/src/lib/harness/claude/prompt-select.ts index 3b4069c..20edb37 100644 --- a/web/src/lib/grammar/prompt-select.ts +++ b/web/src/lib/harness/claude/prompt-select.ts @@ -7,7 +7,7 @@ // backbone: the dialog's footer hint bar is the LAST non-blank line of the buffer, so a menu that // has scrolled up (with real output below it) simply doesn't match — the false-positive guard. -import type { StyledLine } from "../blocks"; +import type { StyledLine } from "../../blocks"; import { classifyFooter, isBlank, @@ -83,6 +83,21 @@ export function parseOptionRow(text: string): { n: number; label: string } | nul return { n: Number(m[1]), label: m[2]!.trim() }; } +// A Claude single-choice menu ALWAYS restarts its numbering at 1 directly above the footer, so the +// menu is the maximal SUFFIX of the collected rows reading 1,2,…,m; stray numbered lines from the +// dialog BODY (a plan's "1./2./3." steps, say) sit ABOVE it and are excluded. Walk up from the last +// row while each is exactly one less than the row below, then require the run to start at 1. +// Invariant: the menu's first number is 1 and the body row above it is ≥1, so `bodyLast === 0` is +// impossible — the descending walk always breaks exactly at the menu boundary. Empty when the tail +// isn't a real menu (first number ≠ 1). Generic over the two grammars' row shapes (both carry `n`). +// Shared with the wizard grammar (wizard.ts), which has the identical body-list hazard. +export function trailingMenuRows(rows: T[]): T[] { + if (rows.length === 0) return []; + let s = rows.length - 1; + while (s > 0 && rows[s - 1]!.n === rows[s]!.n - 1) s--; + return rows[s]!.n === 1 ? rows.slice(s) : []; +} + // Free-text escape rows are answered by TYPING, not a keystroke — the app's composer already covers // that — so they are never up-levelled into a button (spec T2). The two known phrases: // "Type something." (AskUserQuestion) and "Tell Claude what to change" (plan approval). @@ -91,6 +106,19 @@ export function isFreeTextLabel(label: string): boolean { return /^type something\b/i.test(label) || /^tell claude what to change\b/i.test(label); } +// A multiSelect checkbox prefix on an option label: "[ ] Cheese" (unchecked) / "[✔] Mushrooms" +// (checked; ✔ ✓ x X all count as checked). Its PRESENCE is the discriminator that separates a +// multiSelect dialog from a single-choice one — the prompt-select/wizard grammars leave a checkbox +// step to the multi-select grammar (wizard.ts bails when an option row carries it). Returns the +// checked state plus the label with the prefix stripped, or null when there's no checkbox prefix. +// Shared with wizard.ts (the bail) and multi-select.ts (the parse). +const CHECKBOX_PREFIX = /^\[[ xX✔✓]\]\s*/; +export function checkboxState(label: string): { checked: boolean; rest: string } | null { + const m = CHECKBOX_PREFIX.exec(label); + if (!m) return null; + return { checked: /[xX✔✓]/.test(m[0]), rest: label.slice(m[0].length) }; +} + // Options live within a couple dozen lines of the footer; scanning a bounded window keeps a stray // "N." far up in scrollback history from ever being mistaken for a menu row. const OPTION_SCAN_WINDOW = 24; @@ -127,8 +155,8 @@ export function detectPromptSelectRegion(lines: StyledLine[]): PromptRegion | nu const family = classifyFooter(texts[fi]!); if (!family) return null; - // 2. Numbered option rows just above the footer. Require ≥2 rows numbered exactly 1,2,…,k in - // order (a single-choice menu), so scattered "N." lines can't masquerade as a menu. + // 2. Numbered option rows just above the footer. The menu is the trailing 1,2,…,m run of them + // (see trailingMenuRows); scattered "N." lines from the dialog body sit above it and drop out. const from = Math.max(0, fi - OPTION_SCAN_WINDOW); const rows: OptionRow[] = []; for (let i = from; i < fi; i++) { @@ -136,16 +164,15 @@ export function detectPromptSelectRegion(lines: StyledLine[]): PromptRegion | nu if (parsed) rows.push({ index: i, n: parsed.n, label: parsed.label }); } if (rows.length < 2) return null; - for (let k = 0; k < rows.length; k++) { - if (rows[k]!.n !== k + 1) return null; - } + const menu = trailingMenuRows(rows); + if (menu.length < 2) return null; // ≥2 rows numbered 1,2,…,m — else not a single-choice menu tail. // A menu numbered past 9 would need a two-key digit ("10"), which Herdr's send_keys rejects — so // up-levelling it into buttons would emit an unsendable keystroke plan. Real Claude menus are ≤6 - // options; bail to the raw mirror + keys pad rather than render a broken button. (Rows are - // 1..k consecutive by the check above, so length > 9 == a row numbered ≥10.) - if (rows.length > 9) return null; - const firstOpt = rows[0]!.index; - const lastOpt = rows[rows.length - 1]!.index; + // options; bail to the raw mirror + keys pad rather than render a broken button. (menu is + // 1..m consecutive by construction, so length > 9 == a row numbered ≥10.) + if (menu.length > 9) return null; + const firstOpt = menu[0]!.index; + const lastOpt = menu[menu.length - 1]!.index; // The options must sit against the footer (only a hint sub-line / blank may separate them). if (fi - lastOpt > MAX_FOOTER_GAP) return null; @@ -179,10 +206,10 @@ export function detectPromptSelectRegion(lines: StyledLine[]): PromptRegion | nu // `keys` carries the option's ORIGINAL number, so pressing it still selects the right row even // though free-text rows are omitted from the rendered buttons. const options: PromptOption[] = []; - for (let r = 0; r < rows.length; r++) { - const row = rows[r]!; + for (let r = 0; r < menu.length; r++) { + const row = menu[r]!; if (isFreeTextLabel(row.label)) continue; - const nextIdx = r + 1 < rows.length ? rows[r + 1]!.index : fi; + const nextIdx = r + 1 < menu.length ? menu[r + 1]!.index : fi; const desc: string[] = []; for (let i = row.index + 1; i < nextIdx; i++) { const t = texts[i]!; diff --git a/web/src/lib/grammar/wizard.test.ts b/web/src/lib/harness/claude/wizard.test.ts similarity index 98% rename from web/src/lib/grammar/wizard.test.ts rename to web/src/lib/harness/claude/wizard.test.ts index 101a3f6..bb6d0c2 100644 --- a/web/src/lib/grammar/wizard.test.ts +++ b/web/src/lib/harness/claude/wizard.test.ts @@ -2,15 +2,15 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { parseAnsi } from "../ansi"; -import { splitLines, type StyledLine } from "../blocks"; +import { parseAnsi } from "../../ansi"; +import { splitLines, type StyledLine } from "../../blocks"; import { detectWizard, detectWizardRegion } from "./wizard"; import { detectPromptSelect } from "./prompt-select"; import { lineText } from "./markers"; // Anchored on this file's own directory (NOT `new URL(..., import.meta.url)`, which Vite statically // rewrites into a root-relative asset path) so the fixtures resolve regardless of the run cwd. -const PANES_DIR = join(import.meta.dirname, "..", "..", "fixtures", "panes"); +const PANES_DIR = join(import.meta.dirname, "..", "..", "..", "fixtures", "panes"); // The wizard detector is developed and gated against the byte-faithful sandbox captures of a real // multi-question AskUserQuestion flow (claude--wizard-*.txt; choreography in WIZARD_NOTES.md). diff --git a/web/src/lib/grammar/wizard.ts b/web/src/lib/harness/claude/wizard.ts similarity index 91% rename from web/src/lib/grammar/wizard.ts rename to web/src/lib/harness/claude/wizard.ts index 901f1a9..4347c4c 100644 --- a/web/src/lib/grammar/wizard.ts +++ b/web/src/lib/harness/claude/wizard.ts @@ -15,9 +15,9 @@ // reads segment STYLING (`AnsiSegment.bg`) as well as text: the current chip in the stepper is // marked only by a background highlight, not by a distinct glyph. -import type { StyledLine } from "../blocks"; +import type { StyledLine } from "../../blocks"; import { classifyFooter, isBlank, isHorizontalRule, lineText } from "./markers"; -import { isFreeTextLabel, parseOptionRow } from "./prompt-select"; +import { checkboxState, isFreeTextLabel, parseOptionRow, trailingMenuRows } from "./prompt-select"; /** One question chip in the stepper header (the Submit chip is implicit — see `WizardModel`). */ export interface WizardStepChip { @@ -209,7 +209,9 @@ function detectQuestionPhase( ): WizardRegion | null { if (classifyFooter(texts[fi]!) !== "select") return null; - // Numbered rows 1..k in order, near the footer (same shape as the single-question menu). + // Numbered rows near the footer (same shape as the single-question menu). The menu is the trailing + // 1,2,…,m run of them (see trailingMenuRows) — a numbered body above the current question's options + // sits above it and drops out, the same body-list hazard prompt-select guards against. const from = Math.max(0, fi - OPTION_SCAN_WINDOW); const rows: { index: number; n: number; label: string }[] = []; for (let i = from; i < fi; i++) { @@ -217,13 +219,19 @@ function detectQuestionPhase( if (parsed) rows.push({ index: i, ...parsed }); } if (rows.length < 2) return null; - if (rows.some((r, k) => r.n !== k + 1)) return null; + const menu = trailingMenuRows(rows); + if (menu.length < 2) return null; // Past 9 numbered rows, an option would need a two-key digit ("10") — the wizard sends the digit // ALONE to select+advance, and Herdr rejects "10", so this would mis-answer. Real wizard steps are - // ≤6 options; bail to the raw mirror. (Rows are 1..k consecutive above, so length > 9 == a row ≥10.) - if (rows.length > 9) return null; - const firstOpt = rows[0]!.index; - if (fi - rows[rows.length - 1]!.index > MAX_FOOTER_GAP) return null; + // ≤6 options; bail to the raw mirror. (menu is 1..m consecutive by construction, so length > 9 == a row ≥10.) + if (menu.length > 9) return null; + // A multiSelect step inside a multi-question wizard (its option rows carry the `[ ]`/`[✔]` checkbox + // prefix) is unsupported in v1: a wizard digit selects-AND-advances, but a checkbox digit TOGGLES, + // so rendering these as select-and-advance answers would mis-drive the dialog. Bail to the raw + // mirror + keys pad. (The multi-select grammar only claims the single-question checkbox form.) + if (menu.some((row) => checkboxState(row.label) !== null)) return null; + const firstOpt = menu[0]!.index; + if (fi - menu[menu.length - 1]!.index > MAX_FOOTER_GAP) return null; // The stepper header above the first option — THE wizard discriminator. Stop at a horizontal // rule (the dialog's top border) so the scan can't wander into scrollback. @@ -243,10 +251,10 @@ function detectQuestionPhase( // Options: description sub-lines attach like prompt-select's; free-text rows ("Type something.") // stay with the composer; "Chat about this" is kept but flagged — it aborts the whole wizard. const options: WizardOption[] = []; - for (let r = 0; r < rows.length; r++) { - const row = rows[r]!; + for (let r = 0; r < menu.length; r++) { + const row = menu[r]!; if (isFreeTextLabel(row.label)) continue; - const nextIdx = r + 1 < rows.length ? rows[r + 1]!.index : fi; + const nextIdx = r + 1 < menu.length ? menu[r + 1]!.index : fi; const desc: string[] = []; for (let i = row.index + 1; i < nextIdx; i++) { const t = texts[i]!; diff --git a/web/src/lib/harness/conformance.test.ts b/web/src/lib/harness/conformance.test.ts new file mode 100644 index 0000000..ea85cab --- /dev/null +++ b/web/src/lib/harness/conformance.test.ts @@ -0,0 +1,65 @@ +import { readdirSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { claudeAdapter } from "./claude"; +import { describeAdapterConformance, isValidHerdrKey } from "./conformance"; + +// The Claude adapter is the reference implementation the conformance suite gates. The fixture +// cohorts are derived from the byte-faithful corpus (web/src/fixtures/panes/claude--*.txt) by +// GLOBBING it, so a newly-captured dialog is covered the moment it lands — including the multiSelect +// captures whose block may still be wiring up (a not-yet-detecting own fixture is tolerated by the +// suite, see conformance.ts). Foreign = [] until a second adapter exists; the harness handles the +// empty cohort cleanly. + +const PANES_DIR = join(import.meta.dirname, "..", "..", "fixtures", "panes"); + +// The three neutral (no-dialog) Claude states: they must never lift an interactive block. +const NEUTRAL = ["claude--working.txt", "claude--fresh-idle.txt", "claude--done.txt"]; + +const allClaudeFixtures = readdirSync(PANES_DIR) + .filter((f) => f.startsWith("claude--") && f.endsWith(".txt")) + .sort(); + +const ownFixtures = allClaudeFixtures.filter((f) => !NEUTRAL.includes(f)); +const neutralFixtures = allClaudeFixtures.filter((f) => NEUTRAL.includes(f)); + +describeAdapterConformance(claudeAdapter, { + ownFixtures, + foreignFixtures: [], // no second adapter yet — the suite tolerates an empty foreign cohort + neutralFixtures, +}); + +// A focused unit test of the grammar validator itself — the load-bearing helper the suite leans on. +describe("isValidHerdrKey", () => { + it("accepts single literal chars, bare special keys, and modifier chords", () => { + for (const key of [ + "0", + "9", + "n", + "y", + "Enter", + "Escape", + "Tab", + "Backspace", + "Up", + "Down", + "Left", + "Right", + "Space", + "shift+tab", + "ctrl+c", + "ctrl+k", + "ctrl+left", + "F5", + ]) { + expect(isValidHerdrKey(key), key).toBe(true); + } + }); + + it("rejects multi-char digit runs and the unsupported paging/edit keys", () => { + for (const key of ["10", "42", "PageUp", "PageDown", "Home", "End", "Insert", "Delete", "", "C-c"]) { + expect(isValidHerdrKey(key), key).toBe(false); + } + }); +}); diff --git a/web/src/lib/harness/conformance.ts b/web/src/lib/harness/conformance.ts new file mode 100644 index 0000000..00a9bef --- /dev/null +++ b/web/src/lib/harness/conformance.ts @@ -0,0 +1,253 @@ +// The HarnessAdapter CONFORMANCE suite — the CI gate every adapter must clear before its dialog +// buttons are allowed to go hot. It is a single `describe`-registering function, parameterised on +// the adapter under test plus three fixture cohorts, so a future adapter (codex/pi/opencode) gets +// the exact same three invariants for free by calling it from its own `*.test.ts`: +// +// 1. CONSERVATIVE DETECTION (fail-closed) — the adapter must return ONLY raw blocks on every +// FOREIGN adapter's fixtures and on NEUTRAL (plain shell / log) output. A detector that lifts +// an interactive block from a buffer it doesn't own would type keystrokes into a live terminal, +// so "return null on anything unrecognised" is non-negotiable and pinned here. +// 2. TAIL-ANCHORING — every one of the adapter's OWN dialogs lifts ONLY while it sits at the +// buffer tail. Append a couple of lines of ordinary output below it (the menu has scrolled up) +// and detection must fall back to raw-only. This is the false-positive guard every detector +// leans on (see the grammars' "the footer is the last non-blank line" invariant). +// 3. KEY-GRAMMAR VALIDITY — every keystroke any interactive block can emit is a valid Herdr +// `pane.send_keys` key (HERDR_API.md §"send_keys key grammar"): a single literal char, a bare +// special key, or a `+`-joined modifier chord. Multi-char digit runs ("10") and the paging/edit +// keys (PageUp/Home/End/Delete) are rejected — Herdr answers those with `invalid_key`. +// +// Pure + offline: it drives the adapter over the byte-faithful fixture corpus (web/src/fixtures/ +// panes/*.txt) through the same parseAnsi → splitLines pipeline the renderer uses. It never touches +// a pane or the network (guard.ts owns that), so it can gate a read-only Tier-1 lift from fixtures +// alone. + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { parseAnsi } from "../ansi"; +import { splitLines, type Block, type StyledLine } from "../blocks"; +import type { HarnessAdapter } from "./types"; +import { + WIZARD_BACK_KEYS, + WIZARD_CANCEL_KEYS, + WIZARD_NEXT_KEYS, + WIZARD_SUBMIT_KEYS, +} from "./claude/wizard"; + +// Anchored on this file's own directory (NOT `new URL(..., import.meta.url)`, which Vite statically +// rewrites into a root-relative asset path) so fixtures resolve regardless of the run cwd. This file +// sits one level ABOVE the per-detector tests, so it's two ".."s to src, not three. +const PANES_DIR = join(import.meta.dirname, "..", "..", "fixtures", "panes"); + +function loadLines(name: string): StyledLine[] { + return splitLines(parseAnsi(readFileSync(join(PANES_DIR, name), "utf8"))); +} + +// A single unstyled visual line — the minimum an AnsiSegment needs (no colour flags). Used to +// synthesise the trailing output that pushes a dialog off the buffer tail. +function textLine(text: string): StyledLine { + return { segments: [{ text, style: {}, muted: false }] }; +} + +// A couple of lines of ordinary agent output to append below a dialog. They must be NON-blank (a +// trailing blank run is trimmed off the tail by every detector, which would re-expose the footer) +// and must not themselves look like a menu footer or option row — so appending them makes the +// dialog's footer no longer the last non-blank line, and every tail-anchored detector bails. +function trailingOutput(): StyledLine[] { + return [textLine("● Wrote the file"), textLine(" ⎿ done")]; +} + +/** Every non-raw block — i.e. an interactive dialog the adapter lifted out of the raw mirror. + * Kind-agnostic (`kind !== "raw"`) so a newly-added block kind counts as interactive automatically. */ +function interactiveBlocks(blocks: Block[]): Block[] { + return blocks.filter((b) => b.kind !== "raw"); +} + +// Herdr's verified pane.send_keys grammar (HERDR_API.md + project CLAUDE.md). Bare special keys are +// case-insensitive; a lone character is typed literally; modifiers join with `+`. +const SPECIAL_KEYS = new Set([ + "up", + "down", + "left", + "right", + "tab", + "enter", + "escape", + "space", + "backspace", + "bs", +]); +// Explicitly rejected by Herdr (any spelling → invalid_key): no paging or forward-delete via keys. +const UNSUPPORTED_KEYS = new Set(["pageup", "pagedown", "home", "end", "insert", "delete"]); +const MODIFIERS = new Set(["ctrl", "shift", "alt", "cmd", "super"]); + +/** + * Whether `key` is a keystroke Herdr's `pane.send_keys` accepts. A key is valid when it is a single + * literal character (digit/letter/punct), a bare special key (`Enter`, `Up`, `shift`-less `Tab`, …), + * a function key `F1`–`F12`, or a `+`-joined modifier chord (`ctrl+c`, `shift+tab`, `ctrl+left`). + * Rejects multi-char digit runs (`"10"`) and the unsupported paging/edit keys — the two ways a + * detector could emit an unsendable plan. + */ +export function isValidHerdrKey(key: string): boolean { + if (key.length === 0) return false; + const lower = key.toLowerCase(); + if (UNSUPPORTED_KEYS.has(lower)) return false; + if (key.length === 1) return true; // a single literal character (digit, letter, punctuation) + if (SPECIAL_KEYS.has(lower)) return true; // a bare special key (case-insensitive) + if (/^f([1-9]|1[0-2])$/i.test(key)) return true; // F1..F12 + const parts = lower.split("+"); + if (parts.length < 2 || parts.some((p) => p.length === 0)) return false; + const last = parts[parts.length - 1]!; + const mods = parts.slice(0, -1); + if (!mods.every((m) => MODIFIERS.has(m))) return false; + if (last.length === 1) return true; // ctrl+c, shift+a + return SPECIAL_KEYS.has(last) && !UNSUPPORTED_KEYS.has(last); // shift+tab, ctrl+left +} + +// Genuinely-future NON-interactive block kinds (like `raw`) that carry no keystrokes and so need no +// key walk. EMPTY today — every interactive kind that ships is modelled below. A new interactive kind +// that lands without a case here must FAIL the suite (see the default branch), not slip through as a +// silent `null`; only a deliberately keyless kind belongs in this allowlist. +const KEYLESS_FUTURE_KINDS = new Set(); + +/** + * Every keystroke an interactive block can emit, walked off its model + the family's control + * constants. `null` = a keyless kind (`raw`, or a future entry in KEYLESS_FUTURE_KINDS) whose keys + * needn't be validated. An interactive kind with no case here THROWS rather than returning null, so + * the key-grammar invariant can never go silently vacuous when a new dialog kind ships. + */ +function emittableKeys(block: Block): string[] | null { + switch (block.kind) { + case "raw": + return null; + case "prompt-select": + return block.prompt.options.flatMap((o) => o.keys); + case "wizard": { + // Both phases can navigate steps; the review phase's controls ARE submit(1)/cancel(2). + const controls = [ + ...WIZARD_BACK_KEYS, + ...WIZARD_NEXT_KEYS, + ...WIZARD_SUBMIT_KEYS, + ...WIZARD_CANCEL_KEYS, + ]; + return block.wizard.phase === "question" + ? [...block.wizard.options.flatMap((o) => o.keys), ...controls] + : controls; + } + case "preview-select": { + // preview-action.ts's recipe: a digit moves the pointer, Enter selects, `n` opens the note + // input, ctrl+k/Backspace clear it, Escape blurs; a wizard step navigates with Left/Right. + const digits = block.preview.options.map((o) => String(o.n)); + const controls = [ + "Enter", + "n", + "Escape", + "ctrl+k", + "Backspace", + ...WIZARD_BACK_KEYS, + ...WIZARD_NEXT_KEYS, + ]; + return [...digits, ...controls]; + } + case "multi-select": + // checkbox: a digit toggles each option (and the "Chat about this" escape), Up/Down move the + // pointer, Enter activates it. review: the confirm screen's `1. Submit answers / 2. Cancel`. + return block.multi.phase === "checkbox" + ? [ + ...block.multi.options.map((o) => String(o.n)), + ...(block.multi.escape ? [String(block.multi.escape.n)] : []), + "Up", + "Down", + "Enter", + ] + : ["1", "2"]; + default: { + // `block` is `never` here today — every kind is cased above. The cast names the offending kind + // at runtime once a FUTURE Block kind is added to the union without a case here: a keyless one + // is tolerated via the allowlist; any other (an interactive block whose keys aren't being + // validated) fails loudly so the key-grammar invariant can't go vacuous. + const kind = (block as Block).kind; + if (KEYLESS_FUTURE_KINDS.has(kind)) return null; + throw new Error(`conformance: unmodelled interactive block kind "${kind}" — extend emittableKeys`); + } + } +} + +/** + * Register the conformance invariants for `adapter` against its fixture cohorts: + * - `ownFixtures` — this adapter's dialog captures (EACH must lift ≥1 interactive block). + * - `foreignFixtures` — OTHER adapters' dialog captures (must stay raw — cross-adapter fail-closed). + * - `neutralFixtures` — plain shell output / logs with no dialog (must stay raw). + * + * Fail-closed on a misfiled fixture: every own fixture must lift an interactive block (checked + * per-fixture below). A no-dialog capture misfiled into `ownFixtures` would otherwise pass + * tail-anchoring trivially and never exercise the key-grammar leg — so it is a failure, named by + * fixture, not silently tolerated. + */ +export function describeAdapterConformance( + adapter: HarnessAdapter, + opts: { ownFixtures: string[]; foreignFixtures: string[]; neutralFixtures: string[] }, +): void { + const { ownFixtures, foreignFixtures, neutralFixtures } = opts; + + describe(`HarnessAdapter conformance — ${adapter.agent}`, () => { + describe("conservative detection (fail-closed on foreign + neutral buffers)", () => { + const alien: { name: string; cohort: string }[] = [ + ...foreignFixtures.map((name) => ({ name, cohort: "foreign" })), + ...neutralFixtures.map((name) => ({ name, cohort: "neutral" })), + ]; + if (alien.length === 0) it.todo("no foreign or neutral fixtures supplied"); + for (const { name, cohort } of alien) { + it(`${name} (${cohort}) → raw-only, no interactive block`, () => { + const blocks = adapter.buildBlocks(loadLines(name)); + expect(blocks.length).toBeGreaterThan(0); + expect(interactiveBlocks(blocks)).toEqual([]); + }); + } + }); + + describe("own fixtures each lift an interactive block (no misfiled raw capture)", () => { + if (ownFixtures.length === 0) it.todo("no own dialog fixtures supplied"); + for (const name of ownFixtures) { + it(`${name}: lifts ≥1 interactive block`, () => { + const lifted = interactiveBlocks(adapter.buildBlocks(loadLines(name))); + expect( + lifted.length, + `${name} lifted no interactive block — a neutral/raw capture misfiled into ownFixtures?`, + ).toBeGreaterThan(0); + }); + } + }); + + describe("tail-anchoring (a dialog lifts only at the buffer tail)", () => { + if (ownFixtures.length === 0) it.todo("no own dialog fixtures supplied"); + + for (const name of ownFixtures) { + it(`${name}: does NOT lift once ordinary output scrolls below it`, () => { + const scrolled = [...loadLines(name), ...trailingOutput()]; + expect(interactiveBlocks(adapter.buildBlocks(scrolled))).toEqual([]); + }); + } + }); + + describe("key-grammar validity (every emittable key is send_keys-valid)", () => { + if (ownFixtures.length === 0) it.todo("no own dialog fixtures supplied"); + for (const name of ownFixtures) { + it(`${name}: every keystroke its blocks can emit validates`, () => { + for (const block of interactiveBlocks(adapter.buildBlocks(loadLines(name)))) { + const keys = emittableKeys(block); + if (keys === null) continue; // a kind not modelled here yet — tolerated + expect(keys.length, `${name} / ${block.kind} exposes no keys`).toBeGreaterThan(0); + for (const key of keys) { + expect( + isValidHerdrKey(key), + `${name} / ${block.kind} emits invalid key ${JSON.stringify(key)}`, + ).toBe(true); + } + } + }); + } + }); + }); +} diff --git a/web/src/lib/harness/fence.test.ts b/web/src/lib/harness/fence.test.ts new file mode 100644 index 0000000..0bf65e3 --- /dev/null +++ b/web/src/lib/harness/fence.test.ts @@ -0,0 +1,73 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { join, relative } from "node:path"; +import { describe, expect, it } from "vitest"; + +// THE CAPABILITY FENCE, enforced by a test because this repo wires no ESLint runner yet. +// +// web/eslint.config.mjs declares a `no-restricted-imports` rule banning the network API +// (@/lib/api / a relative "…/api" path) under src/lib/harness/**, with ONE exception: guard.ts, the +// race-guard engine that legitimately re-fetches the pane before a guarded keystroke. But nothing +// runs ESLint in CI. So this Vitest test re-implements the fence's INTENT — a socket call types into +// a real terminal, so the harness detection layer must stay pure of I/O — and `bun run test` (which +// the pre-push hook runs) enforces it. If an ESLint runner is added later, the rule and this test +// simply agree. + +const HARNESS_DIR = import.meta.dirname; // src/lib/harness +const GUARD_FILE = "guard.ts"; // the sole allowed importer of the network API + +// The quoted network-API specifier: the "@/lib/api" alias or any relative "…/api" path (./api, +// ../api, ../../api, …), with an optional .js/.ts extension. Matched against the WHOLE file text (not +// line-by-line) so a Prettier-wrapped `import {\n fetchPane,\n} from "../api";` — whose closing +// `} from "../api";` line carries no import keyword — is still caught. A harness file has no +// legitimate reason to even MENTION a quoted api specifier, so no import-keyword co-anchor is needed; +// the quoted-specifier shape (a sibling like "../ansi" ends in "ansi", not "api") is the whole guard. +const NETWORK_API_SPECIFIER = /["'`](?:@\/lib\/api|(?:\.{1,2}\/)+api)(?:\.[jt]s)?["'`]/; + +// The fence guards the PRODUCTION detection layer, so test files are out of scope: a `*.test.ts` may +// legitimately reference the network API (to mock it, or — as this very file does — to assert the +// fence catches an api specifier), and it never runs on the render/keystroke path. Excluding them is +// also what lets this suite's own self-test embed a literal `from "../api"` without self-flagging. +function collectSourceFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) out.push(...collectSourceFiles(full)); + else if (/\.tsx?$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name)) out.push(full); + } + return out; +} + +// Pure content predicate (takes the file TEXT, not a path) so it can be unit-tested directly against +// a multi-line import literal — and so the whole-file match survives a Prettier line wrap. +function importsNetworkApi(source: string): boolean { + return NETWORK_API_SPECIFIER.test(source); +} + +describe("harness network-API fence", () => { + const files = collectSourceFiles(HARNESS_DIR); + + it("finds harness source files to check (guard against a broken glob)", () => { + expect(files.length).toBeGreaterThan(0); + }); + + it("no harness module except guard.ts imports the network API", () => { + const offenders = files + .filter((file) => relative(HARNESS_DIR, file) !== GUARD_FILE) + .filter((file) => importsNetworkApi(readFileSync(file, "utf8"))) + .map((file) => relative(HARNESS_DIR, file)); + expect(offenders).toEqual([]); + }); + + it("guard.ts is the ONE module that legitimately imports it (so the fence is real, not vacuous)", () => { + expect(importsNetworkApi(readFileSync(join(HARNESS_DIR, GUARD_FILE), "utf8"))).toBe(true); + }); + + // The whole-file (not line-by-line) match is the fix: a Prettier-wrapped multi-line import must be + // caught even though its `} from "../api";` line has no import keyword. A sibling "../ansi" must not. + it("importsNetworkApi catches a single-line AND a multi-line api import", () => { + expect(importsNetworkApi(`import { fetchPane } from "../api";`)).toBe(true); + expect(importsNetworkApi(`import {\n fetchPane,\n} from "../api";`)).toBe(true); + expect(importsNetworkApi(`import { fetchPane } from "@/lib/api";`)).toBe(true); + expect(importsNetworkApi(`import { splitLines } from "../ansi";`)).toBe(false); + }); +}); diff --git a/web/src/lib/harness/guard.ts b/web/src/lib/harness/guard.ts new file mode 100644 index 0000000..22d16fc --- /dev/null +++ b/web/src/lib/harness/guard.ts @@ -0,0 +1,147 @@ +// Model-GENERIC race-guard machinery, factored out of the three harness action files +// (prompt-action, wizard-action, preview-action). Every one of them tapped a menu/wizard/dialog +// button that types into a REAL terminal, and the pane may have moved on between render and tap — +// so before sending they all re-fetch the pane, confirm the revision, and re-derive the model to +// compare against what the user tapped. That skeleton (fresh read → parse → detect → unconditional +// revision check → structural-equality check) is identical across all three; only the model type +// M, its detector, and its equality function differ. Those become the generic parameters here. + +import { fetchPane } from "../api"; +import { parseAnsi } from "../ansi"; +import { splitLines, type StyledLine } from "../blocks"; + +/** + * The canonical result of a guarded action. `sent` = the keystrokes went through; `changed` = the + * guard rejected the tap (the pane drifted underfoot) and the caller should refresh; `error` = a + * transport/RPC failure the caller surfaces verbatim. + */ +export type ActionResult = + | { status: "sent" } + | { status: "changed" } + | { status: "error"; error: string }; + +/** Test seam for the verification polls' pacing. */ +export type Sleep = (ms: number) => Promise; +export const defaultSleep: Sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +// Bounded verification polling between choreography steps (the TUI re-renders well under a +// second; ~3s total before we give up and refresh). +export const POLL_ATTEMPTS = 8; +export const POLL_DELAY_MS = 350; + +/** Derive the on-screen dialog model from a fresh pane's styled lines (null = no dialog there). */ +type Detect = (lines: StyledLine[]) => M | null; + +/** One fresh read + re-derivation. Returns the model (null = no dialog on screen). */ +export async function readModel( + paneId: string, + requestedLines: number, + session: string | undefined, + detect: Detect, +): Promise<{ revision: number; model: M | null }> { + const fresh = await fetchPane(paneId, requestedLines, session); + return { revision: fresh.revision, model: detect(splitLines(parseAnsi(fresh.text))) }; +} + +/** + * The shared entry guard: a FRESH pane read, the UNCONDITIONAL revision check, and a full model + * re-derivation compared (via `equals`) against `tapped` — what the user actually tapped. Returns a + * terminal `ActionResult` when the guard fails ("changed") or the read errors, or `null` when the + * guard passes and the caller may proceed to send. + */ +export async function entryGuard( + args: { + paneId: string; + requestedLines: number; + /** The `revision` the rendered dialog was detected against. */ + detectedRevision: number; + /** The session the pane lives in (undefined = primary) — scopes the read. */ + session?: string; + }, + tapped: M, + detect: Detect, + equals: (a: M, b: M) => boolean, +): Promise { + let fresh; + try { + fresh = await readModel(args.paneId, args.requestedLines, args.session, detect); + } catch (e) { + return { status: "error", error: e instanceof Error ? e.message : String(e) }; + } + + // Revision check is UNCONDITIONAL: a 304 only means "unchanged since the last poll", and polls + // keep advancing the ETag cache under a frozen mirror — it does NOT vouch for the snapshot the + // user actually tapped on. The cached 304 body carries its revision, so this covers both paths. + if (fresh.revision !== args.detectedRevision) return { status: "changed" }; + // EMPIRICAL (Herdr 0.7.x, live-verified 2026-07-05): pane.read's `revision` is a stub upstream — + // it is always 0, even for actively-changing panes. The gate above is therefore defense-in-depth + // for future Herdr versions, NOT load-bearing. So the model re-derivation below runs on EVERY + // path, including 304: the fresh (= latest cached) text is exactly what a tap on a possibly + // frozen mirror must be compared against. One parse per tap — taps are rare, correctness isn't. + if (!fresh.model || !equals(fresh.model, tapped)) return { status: "changed" }; + return null; +} + +/** + * Poll (bounded) until `accept` passes on a fresh re-derivation. THREE-VALUED, because the caller + * must distinguish "the awaited state never arrived, but this is still our dialog" from "a different + * dialog is on screen now": + * - `"ok"` — `accept` passed. + * - `"drifted"` — the dialog's IDENTITY changed: a fresh model whose `identity` no longer matches + * `tapped` (a same-shaped successor / another dialog entirely), OR the dialog is + * GONE (every read re-derived to null — e.g. the agent is running again). No + * further key may be sent: a blind keystroke would hit whatever replaced it. + * - `"timeout"` — our dialog stayed on screen (identity intact) but the awaited state never came + * within the bounded window (e.g. a swallowed keystroke). The dialog is still ours, + * so a bounded RETRY of the same key is safe. + * A transient null re-derivation MID-poll keeps polling (the TUI redraw can briefly hide the tail); + * only an all-null poll (the dialog truly vanished) resolves to `"drifted"`. + */ +export async function pollUntil( + args: { + paneId: string; + requestedLines: number; + /** The session the pane lives in (undefined = primary) — scopes every read. */ + session?: string; + /** Test seam for the poll pacing. */ + sleep?: Sleep; + }, + tapped: M, + detect: Detect, + accept: (m: M) => boolean, + identity: (a: M, b: M) => boolean, +): Promise<"ok" | "drifted" | "timeout"> { + const sleep = args.sleep ?? defaultSleep; + let sawDialog = false; + for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt++) { + await sleep(POLL_DELAY_MS); + let fresh; + try { + fresh = await readModel(args.paneId, args.requestedLines, args.session, detect); + } catch { + continue; // transient read failure — the bounded loop is the timeout + } + if (!fresh.model) continue; // transient redraw hid the tail — keep polling + sawDialog = true; + if (accept(fresh.model)) return "ok"; + if (!identity(fresh.model, tapped)) return "drifted"; // a different dialog now + } + // Exhausted. If we never saw the dialog at all it has vanished (a now-running agent) — treat as + // drift, NOT a retryable timeout, so no blind key is sent at whatever replaced it. + return sawDialog ? "timeout" : "drifted"; +} + +/** + * Sanitize free text before it is typed into a focused TUI input via the reply path. Collapse + * whitespace to single spaces FIRST (so \t \n \r become word boundaries, not glue), then strip any + * remaining C0/C1 control chars. Pasted clipboard text can smuggle in ESC (\x1b — blurs/cancels the + * dialog), BEL (\x07 — "edit in nano"), ETX (\x03), etc., which the reply path would deliver + * straight into the focused input BEFORE the readback check — so they must never reach it. + */ +export function sanitizeTypedText(text: string, maxLen: number): string { + return text + .replace(/\s+/g, " ") + .replace(/\p{Cc}/gu, "") + .trim() + .slice(0, maxLen); +} diff --git a/web/src/lib/harness/index.ts b/web/src/lib/harness/index.ts new file mode 100644 index 0000000..207d3e8 --- /dev/null +++ b/web/src/lib/harness/index.ts @@ -0,0 +1,21 @@ +// The block-building DISPATCHER — the public entry the renderer (ansi-output) calls. It lives HERE +// rather than in blocks.ts to keep the dependency edge one-way: harness/ imports blocks.ts (for the +// Block AST + pure helpers), never the reverse, so pulling an adapter's pipeline in can't form an +// import cycle. Routing is trivial: the agent's adapter builds the blocks, or — for any unknown/ +// absent agent — the universal single raw block. This is the seam where the Claude-Code TUI grammars +// (and any future agent's) run; every non-adapter agent keeps the pure raw mirror. + +import type { Block, StyledLine } from "../blocks"; +import { adapterFor, hasBlockGrammar } from "./registry"; + +/** + * Group lines into semantic blocks by routing through the agent's adapter. With no `ctx` (or an agent + * that has no adapter) this is the trivial single-raw-block wrap it always was — conservative gating + * lives entirely in the registry, so a non-adapter pane is never mis-parsed. + */ +export function buildBlocks(lines: StyledLine[], ctx?: { agent?: string }): Block[] { + return adapterFor(ctx?.agent)?.buildBlocks(lines) ?? [{ kind: "raw", lines }]; +} + +export { adapterFor, hasBlockGrammar }; +export type { HarnessAdapter } from "./types"; diff --git a/web/src/lib/harness/registry.test.ts b/web/src/lib/harness/registry.test.ts new file mode 100644 index 0000000..f87b04b --- /dev/null +++ b/web/src/lib/harness/registry.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { adapterFor, hasBlockGrammar } from "./registry"; + +// The single source of truth for "which agents get the block grammars". Both gates (the render +// pipeline's buildBlocks and agent-chat's status strip) route through the registry, so it is worth +// pinning directly — this re-homes the old grammar/agents predicate test onto the registry, which +// now derives the predicate from adapterFor(). +describe("hasBlockGrammar", () => { + it("is true only for Claude Code", () => { + expect(hasBlockGrammar("claude")).toBe(true); + }); + + it("is false for every non-Claude agent (unverified TUI ⇒ raw mirror)", () => { + for (const agent of ["codex", "opencode", "pi", "shell", "unknown"]) { + expect(hasBlockGrammar(agent)).toBe(false); + } + }); + + it("is false for an absent agent", () => { + expect(hasBlockGrammar(undefined)).toBe(false); + }); + + // Inherited Object.prototype keys must not resolve to a truthy non-adapter (which would crash the + // render path calling `.buildBlocks` on `Object.prototype.toString`). `Object.hasOwn` gates the lookup. + it("is false for inherited Object.prototype keys (no prototype-chain lookup)", () => { + for (const key of ["toString", "constructor", "hasOwnProperty", "__proto__"]) { + expect(adapterFor(key)).toBeUndefined(); + expect(hasBlockGrammar(key)).toBe(false); + } + }); +}); diff --git a/web/src/lib/harness/registry.ts b/web/src/lib/harness/registry.ts new file mode 100644 index 0000000..e02963e --- /dev/null +++ b/web/src/lib/harness/registry.ts @@ -0,0 +1,28 @@ +// The adapter registry — the SINGLE decision site for "which agents get the block grammars". Maps a +// Herdr snapshot `agent` string to its HarnessAdapter; anything absent from the map has no adapter, +// so it keeps the universal raw mirror (the T1 fallback). Both gates route through here — the render +// pipeline (harness/index buildBlocks) and agent-chat's status strip — so the policy can't drift, and +// adding a future verified agent is a one-line change to ADAPTERS. `hasBlockGrammar` replaces the old +// grammar/agents predicate: it's now just "is there an adapter for this agent?". + +import type { HarnessAdapter } from "./types"; +import { claudeAdapter } from "./claude"; + +// Built FROM the adapter list (not a hand-written literal) so a key can't silently drift from its +// adapter's own `agent` string — the map key IS `adapter.agent`. +const ADAPTERS: Record = Object.fromEntries( + [claudeAdapter].map((a) => [a.agent, a]), +); + +/** The adapter for `agent`, or undefined when the agent is unknown/absent (→ raw fallback). `Object.hasOwn` + * (not a truthy `ADAPTERS[agent]`) so an inherited Object.prototype key ("toString", "constructor", + * "__proto__", …) can't resolve to a non-adapter and crash the render path. */ +export function adapterFor(agent: string | undefined): HarnessAdapter | undefined { + return agent !== undefined && Object.hasOwn(ADAPTERS, agent) ? ADAPTERS[agent] : undefined; +} + +/** Whether `agent` has block grammars (an adapter). The gate agent-chat's status strip shares with + * the render pipeline, so the two can't diverge. */ +export function hasBlockGrammar(agent: string | undefined): boolean { + return adapterFor(agent) !== undefined; +} diff --git a/web/src/lib/harness/types.ts b/web/src/lib/harness/types.ts new file mode 100644 index 0000000..78f8844 --- /dev/null +++ b/web/src/lib/harness/types.ts @@ -0,0 +1,26 @@ +// The pluggable detection seam. Each supported agent (Claude today; codex/opencode/… tomorrow) +// contributes ONE HarnessAdapter: its own block-building pipeline plus the two chrome re-surfacing +// probes (the statusline the mirror strips, and a stranded input-box draft). The registry +// (registry.ts) maps a Herdr snapshot `agent` string to its adapter; everything not in the registry +// falls back to the universal raw mirror. This is the single decision site the render pipeline and +// agent-chat's status strip both route through, so the "which agents get grammars" policy can't drift. +// +// An adapter is DETECTION only. The Block union + renderers and the keystroke ACTION recipes +// (prompt/wizard/preview-action) stay in core, dispatched from agent-chat — the adapter just tells +// core what's on screen. + +import type { Block, StyledLine } from "../blocks"; + +export interface HarnessAdapter { + /** The exact Herdr snapshot `agent` string this adapter claims (its registry key). */ + agent: string; + /** The adapter's OWN full block pipeline over the pane's styled lines — for Claude that is the + * raw-or-dialog result (dialog lift + chrome strip, else a single raw block). */ + buildBlocks(lines: StyledLine[]): Block[]; + /** Re-surface the statusline this agent's chrome-stripping peeled off the mirror tail (null = no + * box at the tail, so nothing to surface). */ + extractStatusLine(lines: StyledLine[]): string | null; + /** Re-surface a user draft stranded on the input box's prompt line (null = no box / empty / a + * known placeholder). */ + extractInputDraft(lines: StyledLine[]): string | null; +} diff --git a/web/src/lib/multi-select-action.test.ts b/web/src/lib/multi-select-action.test.ts new file mode 100644 index 0000000..ee5a3b0 --- /dev/null +++ b/web/src/lib/multi-select-action.test.ts @@ -0,0 +1,324 @@ +import { describe, expect, it, beforeEach, vi } from "vitest"; + +// The multi-select choreography engine: the entry guard + the Submit macro (walk the pointer DOWN +// onto "Submit", re-reading each step, then Enter) and the one-keystroke intents (toggle / escape / +// confirm / cancel). The api layer is mocked so the mid-flight pane states can be sequenced precisely; +// the detector is the real thing, driven by synthetic plain-text buffers in the verified layout. +vi.mock("./api", () => ({ + fetchPane: vi.fn(), + sendKeys: vi.fn(), +})); + +import { fetchPane, sendKeys } from "./api"; +import { parseAnsi } from "./ansi"; +import { splitLines, type MultiSelectModel } from "./blocks"; +import { detectMultiSelect } from "./harness/claude/multi-select"; +import { + multiSelectEquals, + multiSelectIdentity, + submitMultiSelectIntent, +} from "./multi-select-action"; + +const mockFetchPane = vi.mocked(fetchPane); +const mockSendKeys = vi.mocked(sendKeys); + +type Pointer = "opt1" | "opt2" | "opt3" | "opt4" | "free" | "submit" | "chat" | "none"; + +// A synthetic checkbox screen in the verified layout: stepper, question, four checkbox rows, the +// free-text row, a navigable Submit row, a rule, the "Chat about this" escape, and the select footer. +// `pointer` places the ❯; `checked` marks rows. +function checkboxBuffer( + opts: { pointer?: Pointer; checked?: number[]; question?: string } = {}, +): string { + const pointer = opts.pointer ?? "opt1"; + const checked = new Set(opts.checked ?? []); + const labels = ["Cheese", "Mushrooms", "Olives", "Peppers"]; + const optRows = labels.map((label, i) => { + const n = i + 1; + const box = checked.has(n) ? "[✔]" : "[ ]"; + const ptr = pointer === `opt${n}` ? "❯ " : " "; + return `${ptr}${n}. ${box} ${label}`; + }); + // The ❯ replaces the FIRST leading space (column alignment is preserved), so the pointer normalises + // cleanly out of the signature — the un-pointed row is 5 spaces, the pointed one ❯ + 4. + const submitRow = (pointer === "submit" ? "❯ " : " ") + "Submit"; + const chatRow = (pointer === "chat" ? "❯ " : " ") + "6. Chat about this"; + // The free-text "Type something" row can also carry the pointer (Claude lets the ❯ rest on it); + // pointerAt classifies it as a non-Submit row, so the Submit walk must nudge past it, never Enter. + const freeRow = (pointer === "free" ? "❯ " : " ") + "5. [ ] Type something"; + return [ + "← ☐ Toppings ✔ Submit →", + "", + opts.question ?? "Which pizza toppings do you want?", + "", + ...optRows, + freeRow, + submitRow, + "─".repeat(80), + chatRow, + "", + "Enter to select · ↑/↓ to navigate · Esc to cancel", + ].join("\n"); +} + +function reviewBuffer(opts: { incomplete?: boolean } = {}): string { + return [ + "← ☐ Toppings ✔ Submit →", + "", + "Review your answers", + ...(opts.incomplete ? ["", "⚠ You have not answered all questions"] : []), + "", + "Ready to submit your answers?", + "", + "❯ 1. Submit answers", + " 2. Cancel", + ].join("\n"); +} + +function model(text: string): MultiSelectModel { + const m = detectMultiSelect(splitLines(parseAnsi(text))); + if (!m) throw new Error("synthetic buffer did not detect a multi-select dialog"); + return m; +} + +function paneWith(text: string, revision = 5) { + return { paneId: "w1:p1", text, truncated: false, revision }; +} + +// Serve a SCRIPT of pane states: each fetch consumes one entry; the last repeats forever. +function script(...texts: string[]) { + const queue = [...texts]; + mockFetchPane.mockImplementation(async () => + paneWith(queue.length > 1 ? queue.shift()! : queue[0]!), + ); +} + +const noSleep = async () => {}; +const base = { paneId: "w1:p1", requestedLines: 600, detectedRevision: 5, sleep: noSleep }; +const keysSent = () => mockSendKeys.mock.calls.map((c) => c[1]); + +beforeEach(() => { + mockFetchPane.mockReset(); + mockSendKeys.mockReset(); + mockSendKeys.mockResolvedValue({ ok: true }); +}); + +describe("multiSelectEquals / multiSelectIdentity", () => { + it("equals: full state incl. checked; unequal across question / checked; ignores pointer", () => { + const a = model(checkboxBuffer({ pointer: "opt1", checked: [] })); + // Pointer moved but everything else identical → still the same visible state (pointer is transient). + expect(multiSelectEquals(a, model(checkboxBuffer({ pointer: "chat", checked: [] })))).toBe(true); + // A checkbox flip IS a visible-state change the entry guard must catch. + expect(multiSelectEquals(a, model(checkboxBuffer({ checked: [2] })))).toBe(false); + // A different question is a different dialog. + expect(multiSelectEquals(a, model(checkboxBuffer({ question: "Something else?" })))).toBe(false); + }); + + it("identity: pointer-independent but checked-DEPENDENT (an external mid-walk flip is drift)", () => { + const a = model(checkboxBuffer({ pointer: "opt1", checked: [] })); + // The macro's own pointer move (it only ever sends Down/Up — never a toggle) is NOT drift. + expect(multiSelectIdentity(a, model(checkboxBuffer({ pointer: "submit", checked: [] })))).toBe(true); + // But a box that flipped underfoot (a second device toggled it) IS — we must not walk on and + // ship a set the user never saw. + expect(multiSelectIdentity(a, model(checkboxBuffer({ pointer: "submit", checked: [3] })))).toBe(false); + // A different question / different labels is a different dialog (the identity re-derivation guard). + expect(multiSelectIdentity(a, model(checkboxBuffer({ question: "Another question?" })))).toBe(false); + }); +}); + +describe("toggle / escape — one guarded keystroke", () => { + it("toggle sends the option's digit alone", async () => { + const m = model(checkboxBuffer({ pointer: "opt1" })); + mockFetchPane.mockResolvedValue(paneWith(checkboxBuffer({ pointer: "opt1" }))); + const res = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "toggle", n: 3 } }); + expect(res).toEqual({ status: "sent" }); + expect(mockSendKeys.mock.calls).toEqual([["w1:p1", ["3"], undefined]]); + }); + + it("escape sends the 'Chat about this' digit", async () => { + const m = model(checkboxBuffer({})); + mockFetchPane.mockResolvedValue(paneWith(checkboxBuffer({}))); + const res = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "escape" } }); + expect(res).toEqual({ status: "sent" }); + expect(mockSendKeys.mock.calls).toEqual([["w1:p1", ["6"], undefined]]); + }); + + it("toggle rejects an out-of-range digit against the model, sending nothing (no stray keystroke)", async () => { + const m = model(checkboxBuffer({})); // options 1..4 + mockFetchPane.mockResolvedValue(paneWith(checkboxBuffer({}))); + // n=7 is not a real option row — the renderer must never inject a digit the model doesn't back. + const res = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "toggle", n: 7 } }); + expect(res).toEqual({ status: "changed" }); + expect(mockSendKeys).not.toHaveBeenCalled(); + // The escape digit (6) is NOT a toggle target either — a toggle must match an OPTION, not the escape. + const res2 = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "toggle", n: 6 } }); + expect(res2).toEqual({ status: "changed" }); + expect(mockSendKeys).not.toHaveBeenCalled(); + }); + + it("toggle rejects at the entry guard when the dialog changed underfoot (no keys)", async () => { + const m = model(checkboxBuffer({ checked: [] })); + mockFetchPane.mockResolvedValue(paneWith(checkboxBuffer({ checked: [1] }))); // a box flipped + const res = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "toggle", n: 2 } }); + expect(res).toEqual({ status: "changed" }); + expect(mockSendKeys).not.toHaveBeenCalled(); + }); + + it("toggle rejects when the fresh revision differs (frozen mirror, advanced pane)", async () => { + const m = model(checkboxBuffer({})); + mockFetchPane.mockResolvedValue(paneWith(checkboxBuffer({}), 9)); + const res = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "toggle", n: 1 } }); + expect(res).toEqual({ status: "changed" }); + expect(mockSendKeys).not.toHaveBeenCalled(); + }); +}); + +describe("review — confirm / cancel", () => { + it("confirm sends digit 1; cancel sends digit 2", async () => { + const m = model(reviewBuffer({ incomplete: true })); + mockFetchPane.mockResolvedValue(paneWith(reviewBuffer({ incomplete: true }))); + expect(await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "confirm" } })).toEqual({ + status: "sent", + }); + expect(await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "cancel" } })).toEqual({ + status: "sent", + }); + expect(keysSent()).toEqual([["1"], ["2"]]); + }); +}); + +describe("submit macro — walk the pointer down onto Submit, then Enter", () => { + it("walks Down until a fresh read shows the pointer on Submit, then Enters", async () => { + const m = model(checkboxBuffer({ pointer: "opt1" })); + script( + checkboxBuffer({ pointer: "opt1" }), // entry guard + checkboxBuffer({ pointer: "opt1" }), // read: an option row → Down + checkboxBuffer({ pointer: "opt2" }), // read: still an option row → Down + checkboxBuffer({ pointer: "submit" }), // read: on Submit → Enter (stops here, no overshoot) + ); + const res = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "submit" } }); + expect(res).toEqual({ status: "sent" }); + expect(keysSent()).toEqual([["Down"], ["Down"], ["Enter"]]); + }); + + it("re-sends Down when a key is swallowed (the re-read still shows an option row)", async () => { + const m = model(checkboxBuffer({ pointer: "opt1" })); + script( + checkboxBuffer({ pointer: "opt1" }), // entry guard + checkboxBuffer({ pointer: "opt2" }), // read: option → Down + checkboxBuffer({ pointer: "opt2" }), // read: STILL option (Down swallowed) → Down again + checkboxBuffer({ pointer: "submit" }), // read: Submit → Enter + ); + const res = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "submit" } }); + expect(res).toEqual({ status: "sent" }); + expect(keysSent()).toEqual([["Down"], ["Down"], ["Enter"]]); + }); + + it("Ups onto Submit when the pointer starts on the bottom Chat row (Submit is one row above)", async () => { + const m = model(checkboxBuffer({ pointer: "opt1" })); + script( + checkboxBuffer({ pointer: "opt1" }), // entry guard + checkboxBuffer({ pointer: "chat" }), // read: on the bottom row → Up + checkboxBuffer({ pointer: "submit" }), // read: Submit → Enter + ); + const res = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "submit" } }); + expect(res).toEqual({ status: "sent" }); + expect(keysSent()).toEqual([["Up"], ["Enter"]]); + }); + + it("NEVER Enters when the pointer never reaches Submit within the bounded walk", async () => { + const m = model(checkboxBuffer({ pointer: "opt1" })); + // Every read shows an option row — the pointer never converges on Submit, so the bounded walk + // exhausts and refreshes rather than blind-sending an Enter at an unverified row. + mockFetchPane.mockResolvedValue(paneWith(checkboxBuffer({ pointer: "opt2" }))); + const res = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "submit" } }); + expect(res).toEqual({ status: "changed" }); + expect(keysSent()).not.toContainEqual(["Enter"]); + expect(keysSent().every((k) => k[0] === "Down")).toBe(true); // only ever nudged downward + }); + + it("NEVER Enters when the pointer sits on the free-text row — nudges past it, never activates", async () => { + const m = model(checkboxBuffer({ pointer: "opt1" })); + // The ❯ parked on "5. [ ] Type something" (the composer row) reads as a non-Submit row, so the + // walk only ever nudges Down — it must never mistake it for Submit and blind-Enter. + mockFetchPane.mockResolvedValue(paneWith(checkboxBuffer({ pointer: "free" }))); + const res = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "submit" } }); + expect(res).toEqual({ status: "changed" }); + expect(keysSent()).not.toContainEqual(["Enter"]); + expect(keysSent().every((k) => k[0] === "Down")).toBe(true); + }); + + it("NEVER Enters when NO row carries the pointer (pointer null throughout)", async () => { + const m = model(checkboxBuffer({ pointer: "opt1" })); + // A redraw with the ❯ absent → pointer null → still not Submit, so the walk nudges Down and never + // blind-Enters at an unverified row. + mockFetchPane.mockResolvedValue(paneWith(checkboxBuffer({ pointer: "none" }))); + const res = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "submit" } }); + expect(res).toEqual({ status: "changed" }); + expect(keysSent()).not.toContainEqual(["Enter"]); + expect(keysSent().every((k) => k[0] === "Down")).toBe(true); + }); + + it("aborts (no Enter) when a different dialog drifts in mid-walk", async () => { + const m = model(checkboxBuffer({ pointer: "opt1" })); + script( + checkboxBuffer({ pointer: "opt1" }), // entry guard: same dialog + checkboxBuffer({ pointer: "opt1" }), // read: option → Down + // A DIFFERENT dialog (new question), even with the pointer on Submit — the per-read identity + // check must reject it so NO Enter follows. + checkboxBuffer({ pointer: "submit", question: "A different question?" }), + ); + const res = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "submit" } }); + expect(res).toEqual({ status: "changed" }); + expect(keysSent()).toEqual([["Down"]]); // drift detected on the read, before any further key + expect(keysSent()).not.toContainEqual(["Enter"]); + }); + + it("rejects at the entry guard (no keys at all) when the dialog already changed", async () => { + const m = model(checkboxBuffer({ checked: [] })); + mockFetchPane.mockResolvedValue(paneWith(checkboxBuffer({ question: "Different?" }))); + const res = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "submit" } }); + expect(res).toEqual({ status: "changed" }); + expect(mockSendKeys).not.toHaveBeenCalled(); + }); +}); + +describe("per-pane serialization — overlapping actions can't both fire", () => { + it("a second Submit while one is in-flight on the same pane is rejected, and only ONE Enters", async () => { + const m = model(checkboxBuffer({ pointer: "opt1" })); + // Park the FIRST macro's entry read so it stays in-flight while we fire the second on the same pane. + let releaseFirst!: () => void; + const firstRead = new Promise((resolve) => { + releaseFirst = resolve; + }); + let call = 0; + mockFetchPane.mockImplementation(async () => { + if (call++ === 0) await firstRead; // hold the first entry read open + return paneWith(checkboxBuffer({ pointer: "submit" })); + }); + + const first = submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "submit" } }); + // The second tap lands while the first is parked mid-flight → rejected before any read/send. + const second = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "submit" } }); + expect(second).toEqual({ status: "changed" }); + + releaseFirst(); + expect(await first).toEqual({ status: "sent" }); + // Exactly ONE Enter ever reached the terminal — the second macro never ran (no auto-confirm past + // the review screen). + expect(keysSent().filter((k) => k[0] === "Enter")).toHaveLength(1); + }); + + it("releases the lock after completion — a later action on the same pane proceeds normally", async () => { + const m = model(checkboxBuffer({ pointer: "opt1" })); + mockFetchPane.mockResolvedValue(paneWith(checkboxBuffer({ pointer: "opt1" }))); + expect( + await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "toggle", n: 2 } }), + ).toEqual({ status: "sent" }); + // The lock cleared, so the next action on the same pane is NOT blocked by a stale entry. + expect( + await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "toggle", n: 3 } }), + ).toEqual({ status: "sent" }); + expect(keysSent()).toEqual([["2"], ["3"]]); + }); +}); diff --git a/web/src/lib/multi-select-action.ts b/web/src/lib/multi-select-action.ts new file mode 100644 index 0000000000000000000000000000000000000000..16621dbb24fc87e6df1ccbdb62efe472a1bafce1 GIT binary patch literal 12404 zcmeHN?QR=KmfhcaiZU^9$XFy}53;k6<75?!PK1#qk4Pm5f*>rii)4?QT}*dVwAN9u zzvcl3`*)sU-YHMA=iFP>Y*KO(4EF2C8kmeoc30iHb?>?7o~nM|j26x;*IAWzOP9H6 zWePiWX1=yXQktLt{XfjA@XJ-zwbLr~`3I$0Ez-=FezmwYcD2eRPit55rGtLoB&p5({LpL`zI0}|o-EVKq@}Uu<=Mq>aMC@yxOjsReBu1O zupGUeyBk;FiAk2`i5cXTD>_FQjN=*8HOClrj$uX@Uwm;k7@D*yjbCv_qyI%PGI;|fibzZ%KNM*&MMqZ z>NcjF7S7QhPy5eIDeLJ^Z9a8)Y+$Dg<7a#ki^VE^9$X4%UAx<|D*UzU#dQR4v}^zU z{FlEPouuy77P@m1xQrhU&99kWTRg=wjcE#ux|;_Oydgvscl8y6=@tDYHZ zEbRE$EbX-LSjv%^W_Gz!3qsU{sk!E>6~;yM@$at&m#4435#z!pni3lvr@|iBHt}(v z!zipISNG^v#TVhj!gUi@q&L`r83G#qnH8>F;Cq|U2@JJcQ&w(uXi9HvoTuKCS(dI$ zjAI0<&F4s*H0kQvLa2N`cy)0(fR}HB9TH5+xok3h;ddWIt zbxX^XaX*)h_BmW<&8y47@Jq9@h$h;7$OUusbw2fZk}~pbCXrJz3B-2Yax8b9WoGFU z#|P9GR#Ih(^tJ{3V&^u^%L*wPf?|yvHWiZGC7h-Gr7M!ceAN3x?=9k<0V>l-7MIm} zB5=Va?M@IPIP*5?PMj^cC@~GGH^S3&BD_0aRGbBlN@wX*ZCm*)YwVqF-FD*uAGG<9 zIfVsa$<~BD@5>A>X=c7jng3~Z%XzTLG*xyFt6@qoFZzhVB|^G13D6E`DDi<$u&U}E z9Hh$?Fu}YtCD7!LNM&=UR<(M4yGmQnw@*~JD`#F4T8DzHuf_S>$0wPeUYE@`m^9O> z@k(QG06^}_vX2B>C$8Uw?A;+|7E=y0$4Hj2`hr+~C@T>Y5H_^zSOERfm3TaSC2u`2 zr^F>@Ip}}*p}9cxAt>N8LM!nUiR3e87Bad{4-n;`^U|D!E zCc}aG&@^54IbDSD0o(;*u}y>@`&>;Q8;2rN){unlEFEI%!~TJLE4#G613ImFabV1k z*ura6g(K6d{Cu9d)^n2|nS8yRV4^#N2#KkGgmv+WSX*Ls*&W4|Q@e64eHG;?{@adn z4_hT)_G--Tpo-e>A5@T1D+R1XI8RZU5%ih&3bKI4s}=quS#^s)1}ujf^}AC6L1t;O zyf;@IT}_cmY+i0$QJS`v->03uk)#S@py%}B_Fo)nVzH-tlmYn`MtjzgFk>Wqy>M6) zAZX*IW=|`GiV_KAet|H=iUmG>-qm_cbgfW8z+3pE#D7;|b?kmv+pHW5=y3QQqyTN% zfr=@J2nxW5_)3%7nZV1X~kd%hm3}KpAjQ2kykvWML3gk-Fd2W6P zgvdMzv4mA;cH)3>$LA+z958F#;RML8!Y6B#L)qBKt#pC-ilc80%zd%6Sz5x8pfOUz z_}Z(hvok}So*;hpRe(KcPr4#^xK}1o9#SL#enUA0kB}wA0zt1TYpV*xK+2`IIg65m zP~{apv0mXC{CR7o&4{P0vqie1Q+k-7PS+Xgn3N;|HwDdsY+BnMHDMvZ*yR3~og+{g zpE;YGcZkXq6wvlo3-BiM=*0^&(ZA_mY-?_22nhGQesF83K3so(-wQhO3yj=~dEPRQ zAIEvj?k^p&$;8Rqn3w--uZ{~oKiPhK{J4HZd2QLtTs{XM(+BsyeBg1RHE|+i+Wyd_ z9rNN-<9iZ#y90fje)lMRk45-ih{p#W3cnl%`-lL>bm+-D{IBVDae=1(ebq3XUF$&d07eGIP|9n{?_rvaV<# zgOUpXf5N~lbLc6=RhXIcaB?59m3DmB0(C<)c+-;SUDEnhP(BR__W!WFhrs{0VL5H5bm0y6NkMaB5F}|@xkPECGf+CVGNw(i znZe-1ynyJHE^TqEwV<5(6`oOvKpMg)+djvDzIkLGXpBKx%IKdpogqtx5QZP^vMx#Y z+uRRe&lC=fL$QOyK+=;vr~iQ@7{wLE>v9eg+mM3R`E`z_RVsQLyCqOGb->_3Q6TR> zGp@IUO%_@OhLR0xyABF9Ti`bk#>5Bc^C8cr|E5hh-fni6QGhUCpN_sfJ;#253{Rl_ zP#u6x6;Mwr+zt(-D!@s?P$I1S^tAh|L~(42e1hS!8!FYYodmhI0?m#>8buk9XFgNH zKbh;+O(rK`SmwZNHUvMcQ}h-rCXn`^caW(=-+3a4nI}E-HN-4dDGR$`IQm}n7HId2 z3=ogOA+eDk1l%7UzaDT3aT3HmnX3T1L>G`y04n^t1mMPY!%i`1Q0X{u(;@H~iVE$S zN@;1u0;>9`KH3t_EDus&pg~fwsGFj-5Fflcy&R4}19IrI!A?!&AW3SM97m6gTW4uK zMa!+)YiQzHTbe!`yu3gcA`>>`#gyHIyi?I0M#06^NKBFHQ#!}*)dtZtM_BrX#Xdzx zt<4+DpO{M}6ia~~f$-!9EsbG)*cMbpC<2AuAjVQRgLl{Y(yy_@PYS1ha7!2sBA|%J zrq~lSq*Mc-7@z=;gZsw(j1rsXH;~6*ru1%*FM3dqp@yvyO_JUT9tkQGiz}y)UOH-V z5_iE?5&?3Zf|`}gUoHTB%Be!YHy;hXZdzbr>nU3?4$sI$Lrz1Xf8+_I!8S#@TbY=) zg28P3I!jFMNa|dHIPI2zWTc%h5FMJYjK09UWGRq9j-gR#P;(}vV&T}35==|BDvaHv z6*Vx%JaV~&wuRZTk-&mPq-A8c%4Dj!NdwsnK$PaMM6?4G<l#EYUK2=kzlZUWVn6m4WL)(m*Q^ahzaQ?-42} zBRSAa4n$0V3W^n(nET8rFj7jw?KI~+4`H|*H9Q>m0v4((L_B%vDpq=wB>C+ zh1MJzdBu&_+!<}>PX(;mkXq1(qXv_B3_(tJZKr_i3+C?d0ImHSDEE*!K50sqp9%-j za6?^S?be`cbB3OV3`B5#2mvL1Dtpuz!aWECZM@v68&T-H;&sS_b+)LUh5gtO<_&lf zkXSs*$*{C~Seqm=EC~qp{df5Px`(wJiv&+X00+m=Ol_kLs0g+*zEqD1g$B)WJ?tHs z0VxKAT#B%Qsp>~Y4-rx1FGPA>HQOa1;&Pog4k3^QN(Dr4UAPRj5{zbVJNJzJZ`#Ee z2ar-<$iC5ix?QaFJ<7Hs4zx79P{IPUE!MeKx7X}qB>)x^~+KX)2fP)hO=e$VyF|I&~h-PVq72r~0`N`KUk=3y0?0K!NL_ zztd!UUw{QsKN|24EKo9~+n;Tx!LE)KSF+c1$5=0&Ne*2ZZZ8PZ~v9tBG zwY3K^qE$1vwqgBNj&1$sY2WLd)-#zjfFQVQjCna6MNpGo#Y5{cA#e;u1jVsN!=2<- zkwg!X_P}p%V;Z$f&L5HUN`Jon?l)AbFc<+&%77&67d)s7k-GJl-dET;7Ccn4(^SZ7 zoC_n@5f?raL$@VJ%O!HSHh_LYo|H0uT{Y>X2WqFn@guz7^UzDf<}_jkO{yZ=S3 zo>@Svo$(aRn%v$n5QK@KR zKYiS#`B4oPTYq}`F;1Z_w=F;I1zE*WT~yZ@KBP8KU|_16hGFRq-Bgqx4e0aZuf87+ zMx(RA_pgTt?Pni81?>L9bf>15aAUK*UA(n71>9g zMbwf@4%JIiAxJZvhYJ=-q-I*}4FnFa7qZrs#}zxQ8+EdyP19b5d4Rq)s?0A|mG@V6 zaVic(enV~7B>G<{O#pqJyQJtDSbtAHp!tU^TiTEU}n>d5(yQp0G+Z2KzrsE zE@&bL6~V&z2phtxs$fqeLF%nq&G#B#Ia}XZfR_1k7R`rm#dTleu-V z4eceK+AYIwN(|LV@VBO&kE6Wf| zEf>5CymGx{7gO<2foopDy@7kSL!e;;VyoAZnuYG5V}KzI?%1H3dR;+s{hZJN zCEoYZ+>FBI&J9TVKEh91dll{^L>e?W*#4Z?XYow9TJexNRy(;-w0~o32=$v2sFu0K ZYu!me_RdEB@qfBaqiNjq|C+|^KLPgJSxEo@ literal 0 HcmV?d00001 diff --git a/web/src/lib/preview-action.test.ts b/web/src/lib/preview-action.test.ts index d154781..a1ff1de 100644 --- a/web/src/lib/preview-action.test.ts +++ b/web/src/lib/preview-action.test.ts @@ -14,7 +14,7 @@ vi.mock("./api", () => ({ import { fetchPane, sendKeys, sendReply } from "./api"; import { parseAnsi } from "./ansi"; import { splitLines } from "./blocks"; -import { detectPreviewSelect } from "./grammar/preview-select"; +import { detectPreviewSelect } from "./harness/claude/preview-select"; import { NOTE_MAX_LENGTH, previewsEqual, diff --git a/web/src/lib/preview-action.ts b/web/src/lib/preview-action.ts index 502bd94..c159d26 100644 --- a/web/src/lib/preview-action.ts +++ b/web/src/lib/preview-action.ts @@ -16,11 +16,16 @@ // mid-flight verification polls then re-derive from fresh reads again; a dialog that drifts // structurally at any point aborts with "changed" BEFORE anything irreversible is sent. -import { fetchPane, sendKeys, sendReply } from "./api"; -import { parseAnsi } from "./ansi"; -import { splitLines, type PreviewOption, type PreviewSelectModel } from "./blocks"; -import { detectPreviewSelect } from "./grammar/preview-select"; -import type { PromptActionResult } from "./prompt-action"; +import { sendKeys, sendReply } from "./api"; +import { type PreviewOption, type PreviewSelectModel } from "./blocks"; +import { detectPreviewSelect } from "./harness/claude/preview-select"; +import { + entryGuard, + pollUntil, + sanitizeTypedText, + type ActionResult, + type Sleep, +} from "./harness/guard"; /** Longest note Collie will type (the editor enforces it). The TUI itself windows the display at * ~60 columns, so long notes can't be read back faithfully anyway — keep them phone-sized. */ @@ -30,14 +35,6 @@ export const NOTE_MAX_LENGTH = 300; // Collie itself attached is always fully cleared; ctrl+u/ctrl+a are NOT supported by the input. const CLEAR_SWEEP = NOTE_MAX_LENGTH + 20; -// Bounded verification polling between choreography steps (the TUI re-renders well under a -// second; ~3s total before we give up and refresh). -const POLL_ATTEMPTS = 8; -const POLL_DELAY_MS = 350; - -type Sleep = (ms: number) => Promise; -const defaultSleep: Sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - /** * Whether two detected preview dialogs are the same dialog in the same visible state: identity (the * pointer/note-independent core signature — see below), question, stepper chips, options (labels, @@ -101,68 +98,6 @@ interface GuardArgs { sleep?: Sleep; } -/** One fresh read + re-derivation. Returns the model (null = no preview dialog on screen). */ -async function readModel( - paneId: string, - requestedLines: number, - session?: string, -): Promise<{ revision: number; model: PreviewSelectModel | null }> { - const fresh = await fetchPane(paneId, requestedLines, session); - return { revision: fresh.revision, model: detectPreviewSelect(splitLines(parseAnsi(fresh.text))) }; -} - -/** The shared entry guard: fresh read, unconditional revision equality, full model equality. */ -async function entryGuard(args: GuardArgs): Promise { - let fresh; - try { - fresh = await readModel(args.paneId, args.requestedLines, args.session); - } catch (e) { - return { status: "error", error: e instanceof Error ? e.message : String(e) }; - } - if (fresh.revision !== args.detectedRevision) return { status: "changed" }; - if (!fresh.model || !previewsEqual(fresh.model, args.preview)) return { status: "changed" }; - return null; -} - -/** - * Poll (bounded) until `accept` passes on a fresh re-derivation. THREE-VALUED, because the caller - * must distinguish "the awaited state never arrived, but this is still our dialog" from "a different - * dialog is on screen now": - * - `"ok"` — `accept` passed. - * - `"drifted"` — the dialog's IDENTITY changed: a fresh model whose core signature no longer - * matches (a same-shaped successor / another dialog entirely), OR the dialog is - * GONE (every read re-derived to null — e.g. the agent is running again). No - * further key may be sent: a blind keystroke would hit whatever replaced it. - * - `"timeout"` — our dialog stayed on screen (signature intact) but the awaited state never came - * within the bounded window (e.g. a swallowed keystroke). The dialog is still ours, - * so a bounded RETRY of the same key is safe. - * A transient null re-derivation MID-poll keeps polling (the TUI redraw can briefly hide the tail); - * only an all-null poll (the dialog truly vanished) resolves to `"drifted"`. - */ -async function pollUntil( - args: GuardArgs, - accept: (m: PreviewSelectModel) => boolean, -): Promise<"ok" | "drifted" | "timeout"> { - const sleep = args.sleep ?? defaultSleep; - let sawDialog = false; - for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt++) { - await sleep(POLL_DELAY_MS); - let fresh; - try { - fresh = await readModel(args.paneId, args.requestedLines, args.session); - } catch { - continue; // transient read failure — the bounded loop is the timeout - } - if (!fresh.model) continue; // transient redraw hid the tail — keep polling - sawDialog = true; - if (accept(fresh.model)) return "ok"; - if (!coreEqual(fresh.model, args.preview)) return "drifted"; // a different dialog now - } - // Exhausted. If we never saw the dialog at all it has vanished (a now-running agent) — treat as - // drift, NOT a retryable timeout, so no blind key is sent at whatever replaced it. - return sawDialog ? "timeout" : "drifted"; -} - /** * Select an option: entry guard → digit (pointer move) → poll until the pointer verifiably sits on * the tapped row → Enter. If the pointer never converges nothing has been submitted — the digit's @@ -170,8 +105,8 @@ async function pollUntil( */ export async function submitPreviewOption( args: GuardArgs & { option: PreviewOption }, -): Promise { - const guarded = await entryGuard(args); +): Promise { + const guarded = await entryGuard(args, args.preview, detectPreviewSelect, previewsEqual); if (guarded) return guarded; try { @@ -183,9 +118,12 @@ export async function submitPreviewOption( const pointed = await pollUntil( args, + args.preview, + detectPreviewSelect, (m) => structureEqual(m, args.preview) && // same dialog, note untouched (an opened input eats keys) (m.options.find((o) => o.n === args.option.n)?.pointed ?? false), + coreEqual, ); if (pointed !== "ok") return { status: "changed" }; @@ -212,20 +150,12 @@ export async function submitPreviewOption( */ export async function submitPreviewNote( args: GuardArgs & { text: string }, -): Promise { +): Promise { if (args.preview.note.state === "editing") return { status: "changed" }; - const guarded = await entryGuard(args); + const guarded = await entryGuard(args, args.preview, detectPreviewSelect, previewsEqual); if (guarded) return guarded; - // Collapse whitespace to single spaces FIRST (so \t \n \r become word boundaries, not glue), then - // strip any remaining C0/C1 control chars. Pasted clipboard text can smuggle in ESC (\x1b — blurs/ - // cancels the dialog), BEL (\x07 — "edit in nano"), ETX (\x03), etc., which the reply path would - // deliver straight into the focused input BEFORE the readback check — so they must never reach it. - const text = args.text - .replace(/\s+/g, " ") - .replace(/\p{Cc}/gu, "") - .trim() - .slice(0, NOTE_MAX_LENGTH); + const text = sanitizeTypedText(args.text, NOTE_MAX_LENGTH); const editing = (m: PreviewSelectModel) => coreEqual(m, args.preview) && m.note.state === "editing"; try { @@ -237,7 +167,7 @@ export async function submitPreviewNote( // The input must be FOCUSED before anything else is sent — early keys are misrouted (verified). // On timeout we stop dead: a blind Escape could cancel the whole dialog if `n` never landed. - if ((await pollUntil(args, editing)) !== "ok") { + if ((await pollUntil(args, args.preview, detectPreviewSelect, editing, coreEqual)) !== "ok") { return { status: "error", error: "Note input didn't open — check the pane" }; } @@ -252,7 +182,15 @@ export async function submitPreviewNote( args.session, ); if (!clear.ok) return { status: "error", error: clear.error }; - if ((await pollUntil(args, (m) => editing(m) && m.note.text === "")) !== "ok") { + if ( + (await pollUntil( + args, + args.preview, + detectPreviewSelect, + (m) => editing(m) && m.note.text === "", + coreEqual, + )) !== "ok" + ) { return { status: "error", error: "Couldn't clear the existing note — check the pane" }; } } @@ -263,7 +201,10 @@ export async function submitPreviewNote( // the visible value is the TAIL of what we typed (the whole of it when it fits). const landed = await pollUntil( args, + args.preview, + detectPreviewSelect, (m) => editing(m) && m.note.text.length > 0 && text.endsWith(m.note.text), + coreEqual, ); if (landed !== "ok") { return { status: "error", error: "Note text didn't arrive — check the pane" }; @@ -279,7 +220,10 @@ export async function submitPreviewNote( if (!blur.ok) return { status: "error", error: blur.error }; const blurred = await pollUntil( args, + args.preview, + detectPreviewSelect, (m) => coreEqual(m, args.preview) && m.note.state !== "editing", + coreEqual, ); if (blurred === "ok") return { status: "sent" }; if (blurred === "drifted") return { status: "changed" }; // no second Escape at a successor @@ -297,8 +241,8 @@ export async function submitPreviewNote( */ export async function submitPreviewKeys( args: GuardArgs & { keys: string[] }, -): Promise { - const guarded = await entryGuard(args); +): Promise { + const guarded = await entryGuard(args, args.preview, detectPreviewSelect, previewsEqual); if (guarded) return guarded; try { const res = await sendKeys(args.paneId, args.keys, args.session); diff --git a/web/src/lib/prompt-action.ts b/web/src/lib/prompt-action.ts index bab5a9f..b332bc3 100644 --- a/web/src/lib/prompt-action.ts +++ b/web/src/lib/prompt-action.ts @@ -16,10 +16,10 @@ // Only then do we send the option's keys through the existing sendKeys write path. A failed guard // discards the tap and reports "changed" so the caller can surface a "menu changed" notice. -import { fetchPane, sendKeys } from "./api"; -import { parseAnsi } from "./ansi"; -import { splitLines, type PromptModel, type PromptOption } from "./blocks"; -import { detectPromptSelect } from "./grammar/prompt-select"; +import { sendKeys } from "./api"; +import { type PromptModel, type PromptOption } from "./blocks"; +import { detectPromptSelect } from "./harness/claude/prompt-select"; +import { entryGuard, type ActionResult } from "./harness/guard"; /** * Whether two detected dialogs are the SAME on-screen prompt — not merely the same shape. `signature` @@ -43,10 +43,9 @@ export function sameKeys(a: string[], b: string[]): boolean { return a.length === b.length && a.every((k, i) => k === b[i]); } -export type PromptActionResult = - | { status: "sent" } - | { status: "changed" } - | { status: "error"; error: string }; +/** The guarded-action result union, canonical in `harness/guard.ts`; re-exported under the original + * name so existing imports (wizard-action, AgentChat, tests) keep working. */ +export type PromptActionResult = ActionResult; /** * Run the race guard and, if it passes, send `option.keys`. Pure of any UI — the caller maps the @@ -62,26 +61,10 @@ export async function submitPromptOption(args: { /** The session the pane lives in (undefined = primary) — scopes the read + keystroke. */ session?: string; }): Promise { - const { paneId, requestedLines, detectedRevision, prompt, option, session } = args; + const { paneId, prompt, option, session } = args; - let fresh; - try { - fresh = await fetchPane(paneId, requestedLines, session); - } catch (e) { - return { status: "error", error: e instanceof Error ? e.message : String(e) }; - } - - // Revision check is UNCONDITIONAL: a 304 only means "unchanged since the last poll", and polls - // keep advancing the ETag cache under a frozen mirror — it does NOT vouch for the snapshot the - // user actually tapped on. The cached 304 body carries its revision, so this covers both paths. - if (fresh.revision !== detectedRevision) return { status: "changed" }; - // EMPIRICAL (Herdr 0.7.x, live-verified 2026-07-05): pane.read's `revision` is a stub upstream — - // it is always 0, even for actively-changing panes. The gate above is therefore defense-in-depth - // for future Herdr versions, NOT load-bearing. So the menu re-derivation below runs on EVERY - // path, including 304: the fresh (= latest cached) text is exactly what a tap on a possibly - // frozen mirror must be compared against. One parse per tap — taps are rare, correctness isn't. - const freshModel = detectPromptSelect(splitLines(parseAnsi(fresh.text))); - if (!freshModel || !promptsEqual(freshModel, prompt)) return { status: "changed" }; + const guarded = await entryGuard(args, prompt, detectPromptSelect, promptsEqual); + if (guarded) return guarded; try { const res = await sendKeys(paneId, option.keys, session); diff --git a/web/src/lib/wizard-action.ts b/web/src/lib/wizard-action.ts index 54894c0..be3c527 100644 --- a/web/src/lib/wizard-action.ts +++ b/web/src/lib/wizard-action.ts @@ -15,11 +15,11 @@ // // A failed guard discards the tap and reports "changed" so the caller can refresh the mirror. -import { fetchPane, sendKeys } from "./api"; -import { parseAnsi } from "./ansi"; -import { splitLines, type WizardModel } from "./blocks"; -import { detectWizard } from "./grammar/wizard"; +import { sendKeys } from "./api"; +import { type WizardModel } from "./blocks"; +import { detectWizard } from "./harness/claude/wizard"; import type { PromptActionResult } from "./prompt-action"; +import { entryGuard } from "./harness/guard"; /** * Whether two detected wizards are the same step of the same dialog. Field-by-field over the @@ -80,18 +80,10 @@ export async function submitWizardKeys(args: { /** The session the pane lives in (undefined = primary) — scopes the read + keystroke. */ session?: string; }): Promise { - const { paneId, requestedLines, detectedRevision, wizard, keys, session } = args; + const { paneId, wizard, keys, session } = args; - let fresh; - try { - fresh = await fetchPane(paneId, requestedLines, session); - } catch (e) { - return { status: "error", error: e instanceof Error ? e.message : String(e) }; - } - - if (fresh.revision !== detectedRevision) return { status: "changed" }; - const freshModel = detectWizard(splitLines(parseAnsi(fresh.text))); - if (!freshModel || !wizardsEqual(freshModel, wizard)) return { status: "changed" }; + const guarded = await entryGuard(args, wizard, detectWizard, wizardsEqual); + if (guarded) return guarded; try { const res = await sendKeys(paneId, keys, session); diff --git a/web/vite.config.ts b/web/vite.config.ts index 144bd61..cac0cfd 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -39,18 +39,29 @@ const allowedHosts = wildcardDevHost ? true : devHosts.length > 0 ? devHosts : u // service-worker-cached bundle (caches are per-origin) — see README → Troubleshooting. The id mixes // version + git sha + build time so it changes on every rebuild, even between commits. function gitSha(): string { + const git = (cmd: string) => + execSync(cmd, { cwd: import.meta.dirname, stdio: ["ignore", "pipe", "ignore"] }) + .toString() + .trim(); + let sha: string; try { - return ( - execSync("git rev-parse --short HEAD", { - cwd: import.meta.dirname, - stdio: ["ignore", "pipe", "ignore"], - }) - .toString() - .trim() || "nogit" - ); + sha = git("git rev-parse --short HEAD") || "nogit"; } catch { return "nogit"; } + // Mark a dirty working tree so the footer stamp doesn't silently claim HEAD when the build + // actually contains uncommitted work — the common case while developing (the bridge serves the + // rebuilt `dist` straight off disk, so most builds here are pre-commit). Mirrors `git describe + // --dirty`. The `-dirty` also flows into the build `id`, so a dirty rebuild always reads as a + // fresh, distinct build to the stale-cache check. Its OWN try/catch: a `git status` failure must + // keep the good sha (just drop the dirty marker), not discard it back to "nogit". + let dirty = false; + try { + dirty = git("git status --porcelain").length > 0; + } catch { + /* keep the sha, just no dirty marker */ + } + return dirty ? `${sha}-dirty` : sha; } const pkgVersion = ( JSON.parse(readFileSync(resolve(import.meta.dirname, "package.json"), "utf8")) as { From c12b4be946dd7c8bf845399f1d9d6de2e4d0a3e4 Mon Sep 17 00:00:00 2001 From: Altan Sarisin Date: Wed, 15 Jul 2026 23:29:00 +0200 Subject: [PATCH 2/3] ci: gate PRs on version check, typecheck, and tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a GitHub Actions workflow running the same invariants the local git hooks enforce — scripts/check-version.sh, root typecheck + bridge tests, and web typecheck + Vitest — on pull_request and pushes to main. Public-repo Actions minutes are free, and the local hooks aren't installed in every checkout, so this is the only server-side gate. Not a bump-triggering path (no version bump). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..90062b7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +# A new push to a PR supersedes the old run — don't burn a runner on the stale one. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + name: typecheck + tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + + # The MANDATORY invariant: the three version files + the newest CHANGELOG heading must agree. + - name: Version consistency + run: bash scripts/check-version.sh + + - name: Install (root) + run: bun install --frozen-lockfile + - name: Typecheck (root) + run: bun run typecheck + - name: Test (bridge) + run: bun run test + + - name: Install (web) + run: cd web && bun install --frozen-lockfile + - name: Typecheck (web) + run: cd web && bun run typecheck + - name: Test (web) + run: cd web && bun run test From 454f5e8b9b1e5aa768b5aa5502d70c02ceeb2d98 Mon Sep 17 00:00:00 2001 From: Altan Sarisin Date: Thu, 16 Jul 2026 00:55:23 +0200 Subject: [PATCH 3/3] feat: update notifications, on-demand check, and auto-release (0.11.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface update-availability in the app: the bridge checks the repo's GitHub tags over anonymous HTTPS for a newer RELEASE and stamps its own sources to detect a rebuilt-but-not-restarted PROCESS, exposing both on the access-gated /api/snapshot as `update`. A footer banner links to the GitHub release (its notes carry the update commands) or shows a Herdr-plugin-action restart command; a Settings "check for updates" button forces an immediate check (POST /api/update/check); an opt-out `updates` notify pref gates a de-duped web-push (its own collapse topic, tap opens Settings). Every pushed v* tag auto-creates the GitHub release (CHANGELOG section + update commands) via .github/workflows/release.yml, so the page the banner links to always tells you how to update. User-facing update/restart are Herdr plugin actions (location-independent) — codified in CLAUDE.md. Harden "tap to update": a wedged service worker (HTTPS/PWA) serving a stale precache now triggers an unregister-then-reload bypass, but only on the online-proven path — network-failure paths keep the precache so an offline PWA still works. Folds in a Fable review (ship-with-fixes): the update push rides its own collapse topic so it can't overwrite a queued herd alert (with a test pinning the seam); the check button no longer reads a silently-failed check as "up to date"; UpdateStateStore writes atomically. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 54 ++++ CHANGELOG.md | 1 + CLAUDE.md | 7 + bridge/index.ts | 61 +++- bridge/notify-prefs.test.ts | 26 +- bridge/notify-prefs.ts | 8 +- bridge/push.test.ts | 48 ++++ bridge/push.ts | 44 ++- bridge/server.ts | 16 +- bridge/types.ts | 23 ++ bridge/update.test.ts | 205 +++++++++++++ bridge/update.ts | 272 ++++++++++++++++++ .../components/notify-prefs-control.test.tsx | 17 +- web/src/components/notify-prefs-control.tsx | 8 +- web/src/components/update-banner.test.tsx | 112 ++++++++ web/src/components/update-banner.tsx | 108 +++++++ .../components/update-check-control.test.tsx | 134 +++++++++ web/src/components/update-check-control.tsx | 85 ++++++ web/src/hooks/use-polling.test.ts | 1 + web/src/lib/api.test.ts | 37 ++- web/src/lib/api.ts | 12 +- web/src/lib/format.test.ts | 23 +- web/src/lib/format.ts | 14 + web/src/lib/loaders.test.ts | 22 ++ web/src/lib/loaders.ts | 5 + web/src/lib/push-decision.test.ts | 24 ++ web/src/lib/push-decision.ts | 12 +- web/src/lib/pwa.ts | 39 ++- web/src/lib/types.ts | 24 ++ web/src/routes/detail.test.tsx | 1 + web/src/routes/home.tsx | 5 +- web/src/routes/settings.tsx | 11 +- web/src/routes/space.tsx | 5 +- web/src/sw.ts | 22 +- web/src/test/handlers.ts | 16 +- 35 files changed, 1449 insertions(+), 53 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 bridge/update.test.ts create mode 100644 bridge/update.ts create mode 100644 web/src/components/update-banner.test.tsx create mode 100644 web/src/components/update-banner.tsx create mode 100644 web/src/components/update-check-control.test.tsx create mode 100644 web/src/components/update-check-control.tsx diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7c20641 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,54 @@ +name: Release + +# Every pushed vX.Y.Z tag becomes a GitHub Release whose notes carry the update commands — the +# in-app update banner links here, so the page a user lands on tells them exactly how to update. +on: + push: + tags: ["v*.*.*"] + +permissions: + contents: write # create the release + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Assemble release notes (CHANGELOG section + update commands) + id: notes + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#v}" + notes="$RUNNER_TEMP/notes.md" + # This version's CHANGELOG block: everything between its `## [x.y.z]` heading and the next. + awk -v ver="$version" ' + $0 ~ ("^## \\[" ver "\\]") { grab = 1; next } + grab && /^## \[/ { exit } + grab { print } + ' CHANGELOG.md > "$RUNNER_TEMP/changelog.md" || true + { + if [ -s "$RUNNER_TEMP/changelog.md" ]; then + cat "$RUNNER_TEMP/changelog.md" + echo + fi + echo "## Update" + echo + echo "Update the plugin — pulls, rebuilds, restarts, and re-links (runs from any directory):" + echo + echo '```' + echo "herdr plugin action invoke update --plugin herdr.collie" + echo '```' + echo + echo "Or just restart the bridge to pick up an already-built change:" + echo + echo '```' + echo "herdr plugin action invoke restart --plugin herdr.collie" + echo '```' + } > "$notes" + echo "file=$notes" >> "$GITHUB_OUTPUT" + + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: gh release create "$GITHUB_REF_NAME" --title "Collie $GITHUB_REF_NAME" --notes-file "${{ steps.notes.outputs.file }}" diff --git a/CHANGELOG.md b/CHANGELOG.md index b870ba9..1fa6532 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to Collie are recorded here. The format follows - Pluggable harness-adapter architecture: a `HarnessAdapter` registry replaces the single Claude-only gate, Claude's detectors move to `lib/harness/claude/`, and a core race-guard engine (`lib/harness/guard.ts`) is the only module that may touch the network — an import fence (enforced by `fence.test.ts` under `bun run test`) + a conformance suite let contributors add codex/pi/opencode (see `HARNESS_CONTRIBUTING.md`) - multiSelect AskUserQuestion support: checkbox options up-level to tappable checkbox rows (terminal is source of truth), with a closed-loop Submit that navigates the pointer to Submit and verifies before Enter (never blind-sends), plus the review/confirm screen - Prompt overlay: interactive prompts render in a bordered `bg-card` panel that lifts the whole dialog off the terminal mirror, with elevated option rows, leading key-digit badges, and a family-aware caption +- Update notifications: a footer banner (linking to the GitHub release) and an opt-out web-push when a newer release is published upstream or the running bridge is behind the on-disk code — checks the repo's tags over anonymous HTTPS, stamps its own sources for the restart signal, a Settings "check for updates" button forces an immediate check, an `updates` notify pref is the off-switch, and update/restart are surfaced as location-independent Herdr plugin actions ### Changed - Keys and Quick menus dock in-flow above the controls row instead of a fixed overlay, so the terminal mirror shrinks and re-pins to the bottom (ResizeObserver) — the prompt/cursor stays visible; both buttons are toggles diff --git a/CLAUDE.md b/CLAUDE.md index 774aafb..3b899ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,6 +44,13 @@ push origin vX.Y.Z` (or `git push --follow-tags` so the tag ships *with* the rel tag per shipped version on the remote. Not hook-enforced — it's on you. (Adding/adjusting this note is a doc-only change and needs no version bump.) +**Update notice (user-facing).** The app's in-app update banner links to the newest release's GitHub +page and shows the command to run. Pushing a `v*` tag auto-creates that GitHub Release (with the +commands) via `.github/workflows/release.yml`. **Always express user-facing update/restart +instructions as Herdr plugin actions** — `herdr plugin action invoke update --plugin herdr.collie` +(or `restart`) — never `collie-ctl.sh …` / `systemctl … collie`, which depend on the caller's cwd and +the unit name; the Herdr action runs from anywhere. + ## Build / run (operational facts that are easy to forget) - **Frontend changes** (`web/`): rebuild with `bun run build` (root) or `cd web && bun run build`. diff --git a/bridge/index.ts b/bridge/index.ts index 1f3928e..25ae2b8 100644 --- a/bridge/index.ts +++ b/bridge/index.ts @@ -1,4 +1,4 @@ -import { existsSync, readdirSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; import { mkdir } from "node:fs/promises"; import { join } from "node:path"; @@ -18,10 +18,20 @@ import { } from "./sessions.ts"; import { Snooze } from "./snooze.ts"; import { StateEngine } from "./state-engine.ts"; +import { + bridgeStampSync, + githubTagsFetcher, + UpdateMonitor, + UpdateStateStore, +} from "./update.ts"; import { SWEEP_INTERVAL_MS, sweepUploads } from "./uploads.ts"; // How often the registry rescans the filesystem for sessions that appeared/disappeared after boot. const SESSION_REFRESH_MS = 15_000; +// Upstream release check cadence. Releases are rare, so poll every few hours; the first check is +// delayed so we never probe the network mid-boot. +const UPDATE_FIRST_DELAY_MS = 90_000; +const UPDATE_INTERVAL_MS = 6 * 60 * 60 * 1000; // Entry point: resolve config, wire the pieces, start polling and serving. const cfg = loadConfig(); @@ -44,6 +54,51 @@ await notifyPrefs.load(); // inside record() so it can never break the user action it's auditing. const audit = new AuditLog(fileAuditAppender(join(cfg.stateDir, "audit.log"))); +// ── Update-availability monitor ─────────────────────────────────────────────── +// The running plugin version, captured NOW at module load — never re-read from disk later, or a +// post-pull package.json would mask the very update we detect (same class of bug as the buildId gap). +// The bridge-source stamp is snapshotted here too, so a rebuilt-but-not-restarted process reads stale. +const bridgeDir = import.meta.dir; +const rootDir = join(bridgeDir, ".."); +const currentVersion = ( + JSON.parse(readFileSync(join(rootDir, "package.json"), "utf8")) as { version: string } +).version; + +const updateStore = new UpdateStateStore(cfg); +await updateStore.load(); + +// The repo the release check + release links point at. Defaults to Collie's own; overridable for a +// fork (or a synthetic test target) via COLLIE_UPDATE_REPO. +const updateRepo = process.env.COLLIE_UPDATE_REPO?.trim() || "AltanS/collie"; +const updateMonitor = new UpdateMonitor({ + repo: updateRepo, + current: currentVersion, + startupStamp: bridgeStampSync(bridgeDir, rootDir), + fetchTags: githubTagsFetcher(updateRepo), + bridgeStamp: () => bridgeStampSync(bridgeDir, rootDir), + store: updateStore, + now: Date.now, + // The `updates` notify pref is the off-switch — update pushes bypass snooze, so this is their gate. + updatesEnabled: () => notifyPrefs.current().updates, + notify: (latest) => + void push.send({ + type: "update", + tag: "collie:update", + // No command in the body — the tap opens Settings (target below), and the update banner / linked + // release page carry the location-independent Herdr actions. Keeps this off the cwd-dependent path. + title: "Collie update available", + body: `Version ${latest} is available`, + target: "settings", + }), +}); + +// First check delayed (don't probe mid-boot); then every few hours. unref() so neither timer holds +// the process open; both cleared on shutdown. +const updateFirstCheck = setTimeout(() => void updateMonitor.checkRelease(), UPDATE_FIRST_DELAY_MS); +updateFirstCheck.unref(); +const updateTimer = setInterval(() => void updateMonitor.checkRelease(), UPDATE_INTERVAL_MS); +updateTimer.unref(); + // ── Per-session runtime factory ────────────────────────────────────────────── // One HerdrClient + StateEngine + EventPoker + NotificationCoordinator per herdr session. The // registry calls this for the primary at construction and for each session discovered later. Push, @@ -132,7 +187,7 @@ const sweepTimer = setInterval(() => { }, SWEEP_INTERVAL_MS); sweepTimer.unref(); -const server = startServer({ cfg, registry, push, snooze, notifyPrefs, audit }); +const server = startServer({ cfg, registry, push, snooze, notifyPrefs, updateMonitor, audit }); const shutdown = async () => { console.log("\n[bridge] shutting down"); @@ -142,6 +197,8 @@ const shutdown = async () => { clearInterval(refreshTimer); registry.disposeAll(); clearInterval(sweepTimer); + clearTimeout(updateFirstCheck); + clearInterval(updateTimer); process.exit(0); }; process.on("SIGINT", shutdown); diff --git a/bridge/notify-prefs.test.ts b/bridge/notify-prefs.test.ts index 5e575ab..294bacd 100644 --- a/bridge/notify-prefs.test.ts +++ b/bridge/notify-prefs.test.ts @@ -22,12 +22,18 @@ afterAll(async () => { describe("coerceNotifyPrefs", () => { test("fills missing / non-boolean keys from defaults", () => { - expect(coerceNotifyPrefs(undefined)).toEqual({ blocked: true, done: false }); - expect(coerceNotifyPrefs(null)).toEqual({ blocked: true, done: false }); - expect(coerceNotifyPrefs({})).toEqual({ blocked: true, done: false }); - expect(coerceNotifyPrefs({ blocked: false })).toEqual({ blocked: false, done: false }); - expect(coerceNotifyPrefs({ done: true })).toEqual({ blocked: true, done: true }); - expect(coerceNotifyPrefs({ blocked: "yes", done: 1 })).toEqual({ blocked: true, done: false }); + expect(coerceNotifyPrefs(undefined)).toEqual({ blocked: true, done: false, updates: true }); + expect(coerceNotifyPrefs(null)).toEqual({ blocked: true, done: false, updates: true }); + expect(coerceNotifyPrefs({})).toEqual({ blocked: true, done: false, updates: true }); + expect(coerceNotifyPrefs({ blocked: false })).toEqual({ blocked: false, done: false, updates: true }); + expect(coerceNotifyPrefs({ done: true })).toEqual({ blocked: true, done: true, updates: true }); + // `updates` is a first-class key: an explicit false sticks, non-booleans fall back to the default. + expect(coerceNotifyPrefs({ updates: false })).toEqual({ blocked: true, done: false, updates: false }); + expect(coerceNotifyPrefs({ blocked: "yes", done: 1, updates: 0 })).toEqual({ + blocked: true, + done: false, + updates: true, + }); }); }); @@ -52,13 +58,13 @@ describe("NotifyPrefsStore", () => { test("set merges a partial patch, persists, and returns the updated prefs", async () => { const cfg = await tempCfg(); const store = new NotifyPrefsStore(cfg); - const updated = await store.set({ done: true }); - expect(updated).toEqual({ blocked: true, done: true }); + const updated = await store.set({ done: true, updates: false }); + expect(updated).toEqual({ blocked: true, done: true, updates: false }); // Round-trips through disk: a fresh store reloads the same values (survives a restart). const reloaded = new NotifyPrefsStore(cfg); await reloaded.load(); - expect(reloaded.current()).toEqual({ blocked: true, done: true }); + expect(reloaded.current()).toEqual({ blocked: true, done: true, updates: false }); }); test("current() returns a copy — callers can't mutate the store's state", async () => { @@ -82,7 +88,7 @@ describe("NotifyPrefsStore", () => { await writeFile(join(cfg.stateDir, "notify-prefs.json"), JSON.stringify({ blocked: false })); const store = new NotifyPrefsStore(cfg); await store.load(); - expect(store.current()).toEqual({ blocked: false, done: false }); + expect(store.current()).toEqual({ blocked: false, done: false, updates: true }); }); test("load tolerates a missing file (keeps defaults)", async () => { diff --git a/bridge/notify-prefs.ts b/bridge/notify-prefs.ts index 2170877..f59041f 100644 --- a/bridge/notify-prefs.ts +++ b/bridge/notify-prefs.ts @@ -16,9 +16,13 @@ export interface NotifyPrefs { blocked: boolean; /** Push when an agent finishes its task. Default off. */ done: boolean; + /** Push when a newer Collie release is available. Default on — the off-switch for update alerts, + * which otherwise bypass snooze (an update isn't quiet-hours material). Not an agent status, so it + * never flows through {@link isNotifiable}; the update monitor reads it directly. */ + updates: boolean; } -export const DEFAULT_NOTIFY_PREFS: NotifyPrefs = { blocked: true, done: false }; +export const DEFAULT_NOTIFY_PREFS: NotifyPrefs = { blocked: true, done: false, updates: true }; /** * Coerce an untrusted parsed value into a {@link NotifyPrefs}, filling any missing or non-boolean key @@ -29,6 +33,7 @@ export function coerceNotifyPrefs(raw: unknown): NotifyPrefs { return { blocked: typeof o.blocked === "boolean" ? o.blocked : DEFAULT_NOTIFY_PREFS.blocked, done: typeof o.done === "boolean" ? o.done : DEFAULT_NOTIFY_PREFS.done, + updates: typeof o.updates === "boolean" ? o.updates : DEFAULT_NOTIFY_PREFS.updates, }; } @@ -67,6 +72,7 @@ export class NotifyPrefsStore { async set(patch: Partial): Promise { if (typeof patch.blocked === "boolean") this.prefs.blocked = patch.blocked; if (typeof patch.done === "boolean") this.prefs.done = patch.done; + if (typeof patch.updates === "boolean") this.prefs.updates = patch.updates; await this.save(); return this.current(); } diff --git a/bridge/push.test.ts b/bridge/push.test.ts index b51d94c..3083f4a 100644 --- a/bridge/push.test.ts +++ b/bridge/push.test.ts @@ -124,3 +124,51 @@ describe("Push — persistence", () => { expect((await fileEndpoints(cfg.stateDir)).sort()).toEqual(["a", "b", "c"]); }); }); + +describe("Push — per-message collapse topic (update must not share the herd slot)", () => { + // The `topic` is the push service's collapse key: sharing it would let an offline device's queued + // herd summary and an update push silently overwrite each other. Capture the options + payload the + // sender receives to pin the seam the update feature must never regress. + function capturing() { + const sends: { payload: string; options: { topic: string; TTL: number } }[] = []; + const sender: PushSender = (_s, payload, options) => { + sends.push({ payload, options }); + return Promise.resolve(); + }; + return { sender, sends }; + } + + test("an update push rides its OWN topic + longer TTL, and carries the settings target", async () => { + const cfg = await tempCfg(); + const { sender, sends } = capturing(); + const push = new Push(cfg, sender); + enable(push, [sub("a")]); + + await push.send({ type: "update", tag: "collie:update", title: "t", body: "b", target: "settings" }); + + expect(sends[0]!.options).toEqual({ topic: "collie-update", TTL: 259_200 }); + expect(JSON.parse(sends[0]!.payload).data.target).toBe("settings"); + }); + + test("an agent send keeps the herd topic/TTL and carries NO target (byte-identical path)", async () => { + const cfg = await tempCfg(); + const { sender, sends } = capturing(); + const push = new Push(cfg, sender); + enable(push, [sub("a")]); + + await push.send({ title: "claude needs you", body: "…", tag: "collie:herd", paneId: "w1:p1" }); + + expect(sends[0]!.options).toEqual({ topic: "collie-herd", TTL: 21_600 }); + expect("target" in JSON.parse(sends[0]!.payload).data).toBe(false); + }); + + test("a clear stays on the herd topic (it closes the herd slot)", async () => { + const cfg = await tempCfg(); + const { sender, sends } = capturing(); + const push = new Push(cfg, sender); + enable(push, [sub("a")]); + + await push.send({ type: "clear", tag: "collie:herd" }); + expect(sends[0]!.options.topic).toBe("collie-herd"); + }); +}); diff --git a/bridge/push.ts b/bridge/push.ts index 9a49a01..b28b62d 100644 --- a/bridge/push.ts +++ b/bridge/push.ts @@ -17,17 +17,31 @@ export type PushSubscription = { endpoint: string; keys: { p256dh: string; auth: // • `TTL` (seconds) bounds how long the service holds an undelivered message: 6h is long enough to // reach a briefly-offline phone but short enough that a day-old "needs you" doesn't resurface. const SEND_OPTIONS = { TTL: 21_600, topic: "collie-herd" } as const; - -/** Delivers one payload to one subscription. Injectable so the prune/log logic is testable. */ -export type PushSender = (sub: PushSubscription, payload: string) => Promise; +// Update-available pushes ride their OWN collapse topic (and a longer TTL). The `topic` — NOT the +// client-side `tag` — is the push service's collapse key: sharing "collie-herd" would make an offline +// device's queued herd summary and an update push silently overwrite each other. 3-day TTL, since an +// update stays relevant far longer than a transient "needs you". +const UPDATE_SEND_OPTIONS = { TTL: 259_200, topic: "collie-update" } as const; + +/** web-push delivery options (collapse topic + TTL), derived per message from its `type`. */ +export type SendOptions = { TTL: number; topic: string }; + +/** Delivers one payload to one subscription with the given options. Injectable so the prune/log + * logic is testable. */ +export type PushSender = ( + sub: PushSubscription, + payload: string, + options: SendOptions, +) => Promise; /** * A notification instruction for the service worker (see web/src/sw.ts). `type:"clear"` closes the - * notification on `tag` instead of showing one; otherwise the SW renders `{ title, body }` into the - * `tag` slot, deep-links to `paneId` on tap, and re-alerts when `renotify` is set. + * notification on `tag` instead of showing one; `type:"update"` is an update-available alert (its own + * collapse topic; taps open Settings); otherwise the SW renders `{ title, body }` into the `tag` slot, + * deep-links to `paneId` on tap, and re-alerts when `renotify` is set. */ export interface PushMessage { - type?: "clear"; + type?: "clear" | "update"; title?: string; body?: string; /** Notification slot. Same tag replaces (rather than stacks) the previous notification. */ @@ -39,6 +53,9 @@ export interface PushMessage { * then stays byte-identical to the single-session case (an older cached SW keeps working). */ session?: string; + /** Where a tap should land instead of the default pane deep-link. `"settings"` for update alerts; + * absent = today's pane deep-link (so the agent-alert payload is unchanged). */ + target?: "settings"; renotify?: boolean; } @@ -57,7 +74,7 @@ export class Push { sender?: PushSender, ) { this.file = join(cfg.stateDir, "push-subscriptions.json"); - this.sender = sender ?? ((sub, payload) => this.lib!.sendNotification(sub, payload, SEND_OPTIONS)); + this.sender = sender ?? ((sub, payload, options) => this.lib!.sendNotification(sub, payload, options)); } /** Whether push is live (VAPID keys configured and `web-push` installed). Set once in init(). */ @@ -92,13 +109,16 @@ export class Push { await this.save(); } - /** Send a notification instruction (render or clear) to every subscribed device. */ + /** Send a notification instruction (render, clear, or update) to every subscribed device. */ async send(msg: PushMessage): Promise { // The SW reads deep-link fields from `data`. `session` is omitted for the primary (absent on the // message), keeping that payload identical to the pre-multi-session shape. - const data: { paneId?: string; session?: string } = { paneId: msg.paneId }; + const data: { paneId?: string; session?: string; target?: "settings" } = { paneId: msg.paneId }; if (msg.session !== undefined) data.session = msg.session; - await this.broadcast(JSON.stringify({ ...msg, data })); + if (msg.target !== undefined) data.target = msg.target; + // Per-message collapse topic — update alerts must not share the herd slot (see UPDATE_SEND_OPTIONS). + const options = msg.type === "update" ? UPDATE_SEND_OPTIONS : SEND_OPTIONS; + await this.broadcast(JSON.stringify({ ...msg, data }), options); } /** Convenience for a one-off render (used by the manual push-test script). */ @@ -106,13 +126,13 @@ export class Push { await this.send({ title, body, paneId: data.paneId }); } - private async broadcast(payload: string): Promise { + private async broadcast(payload: string, options: SendOptions): Promise { if (!this.enabled) return; const dead: string[] = []; await Promise.all( [...this.subs.values()].map(async (sub) => { try { - await this.sender(sub, payload); + await this.sender(sub, payload, options); } catch (err) { // 404/410 mean the subscription is gone — prune it. Anything else (network, 5xx) is a // real failure worth a log line rather than vanishing silently. diff --git a/bridge/server.ts b/bridge/server.ts index d5eaf56..70c07e1 100644 --- a/bridge/server.ts +++ b/bridge/server.ts @@ -9,6 +9,7 @@ import type { NotifyPrefs, NotifyPrefsStore } from "./notify-prefs.ts"; import type { Push, PushSubscription } from "./push.ts"; import { herdTagFor, type SessionRegistry } from "./sessions.ts"; import type { Snooze } from "./snooze.ts"; +import type { UpdateMonitor } from "./update.ts"; import type { StateEngine } from "./state-engine.ts"; import type { ActionResponse, @@ -83,9 +84,10 @@ export function startServer(opts: { push: Push; snooze: Snooze; notifyPrefs: NotifyPrefsStore; + updateMonitor: UpdateMonitor; audit: AuditLog; }) { - const { cfg, registry, push, snooze, notifyPrefs, audit } = opts; + const { cfg, registry, push, snooze, notifyPrefs, updateMonitor, audit } = opts; // Per-session background notifications live in each session's runtime (built by the factory in // index.ts, wired to its StateEngine transitions). The routes here only fan preference changes and // snooze-clears across every live session's coordinator. @@ -126,6 +128,7 @@ export function startServer(opts: { tabs, sessions: registry.list(), notifications: { snoozedUntil: snooze.until() }, + update: updateMonitor.status(), ts: Date.now(), } satisfies SnapshotResponse, req.headers.get("accept-encoding")); } @@ -241,6 +244,15 @@ export function startServer(opts: { } return text("method not allowed", 405); } + if (pathname === "/api/update/check" && req.method === "POST") { + // Force an immediate upstream check (the "check for updates" button), instead of waiting for + // the periodic timer. Read-level — checking a version isn't terminal-driving — and idempotent + // (the monitor de-dupes concurrent checks). Returns the fresh status the client revalidates on. + const denied = guard(req, cfg, "read"); + if (denied) return denied; + await updateMonitor.checkRelease(); + return json(updateMonitor.status(), req.headers.get("accept-encoding")); + } // ── Static PWA (with SPA fallback) ─────────────────────────────────── return serveStatic(pathname); @@ -759,7 +771,7 @@ export function parseNotifyPrefsPatch(v: unknown): Partial | null { if (typeof v !== "object" || v === null) return null; const o = v as Record; const patch: Partial = {}; - for (const key of ["blocked", "done"] as const) { + for (const key of ["blocked", "done", "updates"] as const) { if (!(key in o)) continue; if (typeof o[key] !== "boolean") return null; patch[key] = o[key] as boolean; diff --git a/bridge/types.ts b/bridge/types.ts index fecba45..570d18f 100644 --- a/bridge/types.ts +++ b/bridge/types.ts @@ -99,9 +99,32 @@ export interface SnapshotResponse { sessions: SessionSummary[]; /** Notification quiet-hours: the active snooze deadline (epoch ms) or null. */ notifications?: { snoozedUntil: number | null }; + /** Update-availability signal. Optional — a stale bridge that predates the field simply omits it, + * which the client reads as "no info" (see bridge/update.ts). */ + update?: UpdateStatus; ts: number; } +/** + * GET /api/snapshot `update` — whether the running plugin is behind (see bridge/update.ts). Both a + * newer upstream RELEASE (`releaseAvailable` + `latest`) and a rebuilt-but-not-restarted bridge + * PROCESS (`bridgeStale`) surface here; the client shows one banner, `bridgeStale` taking precedence. + */ +export interface UpdateStatus { + /** The running bridge/plugin version, captured at process start. */ + current: string; + /** Newest upstream release (dotted `X.Y.Z`, no leading `v`), or null if unknown/none yet. */ + latest: string | null; + /** GitHub release page for `latest` (the banner links to it), or null when `latest` is unknown. */ + latestUrl: string | null; + /** `latest` is strictly newer than `current`. */ + releaseAvailable: boolean; + /** The running process is behind the on-disk bridge source — needs `systemctl --user restart collie`. */ + bridgeStale: boolean; + /** When the upstream check last completed (epoch ms), or null if it hasn't run yet. */ + checkedAt: number | null; +} + /** GET /api/pane/:id — recent terminal output for one agent (ANSI/SGR, rendered colored). */ export interface PaneReadResponse { paneId: string; diff --git a/bridge/update.test.ts b/bridge/update.test.ts new file mode 100644 index 0000000..a39f766 --- /dev/null +++ b/bridge/update.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from "bun:test"; + +import { + compareSemver, + githubReleaseUrl, + latestReleaseTag, + parseSemverTag, + shouldNotify, + stampOf, + UpdateMonitor, + type UpdateMonitorDeps, + type UpdateStore, +} from "./update.ts"; + +describe("compareSemver", () => { + it("orders by major, then minor, then patch", () => { + expect(compareSemver("0.11.0", "0.12.0")).toBe(-1); + expect(compareSemver("0.12.0", "0.11.0")).toBe(1); + expect(compareSemver("1.0.0", "0.99.99")).toBe(1); + expect(compareSemver("0.11.0", "0.11.0")).toBe(0); + expect(compareSemver("0.11.2", "0.11.10")).toBe(-1); // numeric, not lexical + }); +}); + +describe("parseSemverTag / latestReleaseTag", () => { + it("accepts strict vX.Y.Z, rejects prereleases and junk", () => { + expect(parseSemverTag("v0.11.0")).toEqual([0, 11, 0]); + expect(parseSemverTag(" v1.2.3 ")).toEqual([1, 2, 3]); + expect(parseSemverTag("v1.0.0-rc.1")).toBeNull(); + expect(parseSemverTag("0.11.0")).toBeNull(); // no leading v + expect(parseSemverTag("latest")).toBeNull(); + }); + + it("picks the max release and strips the leading v", () => { + expect(latestReleaseTag(["v0.10.3", "v0.11.0", "v0.9.0"])).toBe("0.11.0"); + // Non-release refs and prereleases are ignored, not chosen. + expect(latestReleaseTag(["v0.11.0", "v0.12.0-beta.1", "nightly"])).toBe("0.11.0"); + expect(latestReleaseTag([])).toBeNull(); + expect(latestReleaseTag(["main", "v1.0.0-rc"])).toBeNull(); + }); +}); + +describe("shouldNotify", () => { + const current = "0.11.0"; + it("fires only for a strictly-newer, not-yet-notified release", () => { + expect(shouldNotify({ current, latest: "0.12.0", lastNotified: null })).toBe(true); + // Already notified for this exact version → no re-nag. + expect(shouldNotify({ current, latest: "0.12.0", lastNotified: "0.12.0" })).toBe(false); + // A newer one than we last notified → fire again. + expect(shouldNotify({ current, latest: "0.13.0", lastNotified: "0.12.0" })).toBe(true); + // Not newer than what we're running → never. + expect(shouldNotify({ current, latest: "0.11.0", lastNotified: null })).toBe(false); + expect(shouldNotify({ current, latest: "0.10.0", lastNotified: null })).toBe(false); + expect(shouldNotify({ current, latest: null, lastNotified: null })).toBe(false); + }); +}); + +describe("stampOf", () => { + it("is order-independent and changes on any mtime/size change", () => { + const a = [ + { path: "b.ts", mtimeMs: 2, size: 20 }, + { path: "a.ts", mtimeMs: 1, size: 10 }, + ]; + const b = [ + { path: "a.ts", mtimeMs: 1, size: 10 }, + { path: "b.ts", mtimeMs: 2, size: 20 }, + ]; + expect(stampOf(a)).toBe(stampOf(b)); // same set, different order → same stamp + expect(stampOf(a)).not.toBe(stampOf([{ path: "a.ts", mtimeMs: 9, size: 10 }, { path: "b.ts", mtimeMs: 2, size: 20 }])); + expect(stampOf(a)).not.toBe(stampOf([{ path: "a.ts", mtimeMs: 1, size: 99 }, { path: "b.ts", mtimeMs: 2, size: 20 }])); + }); +}); + +// A fake store + a scripted clock for the monitor. +function fakeStore(initial: string | null = null): UpdateStore & { saved: string[] } { + let last = initial; + const saved: string[] = []; + return { + saved, + lastNotified: () => last, + setLastNotified: async (v) => { + last = v; + saved.push(v); + }, + }; +} + +function makeMonitor(over: Partial = {}) { + const notified: string[] = []; + const store = fakeStore(); + let clock = 1_000_000; + const monitor = new UpdateMonitor({ + repo: "AltanS/collie", + current: "0.11.0", + startupStamp: "STAMP@boot", + fetchTags: async () => ["v0.12.0"], + bridgeStamp: () => "STAMP@boot", + store, + now: () => clock, + updatesEnabled: () => true, + notify: (v) => notified.push(v), + ...over, + }); + return { monitor, notified, store, tick: (ms: number) => (clock += ms) }; +} + +describe("UpdateMonitor", () => { + it("surfaces releaseAvailable + latest + latestUrl after a successful check", async () => { + // Use a REAL Collie release (v0.10.3) with `current` below it, so the asserted release URL exists. + const { monitor } = makeMonitor({ + current: "0.9.0", + fetchTags: async () => ["v0.2.0", "v0.10.0", "v0.10.3"], + }); + expect(monitor.status()).toMatchObject({ current: "0.9.0", latest: null, latestUrl: null, releaseAvailable: false, checkedAt: null }); + await monitor.checkRelease(); + expect(monitor.status()).toMatchObject({ + latest: "0.10.3", + latestUrl: "https://github.com/AltanS/collie/releases/tag/v0.10.3", + releaseAvailable: true, + }); + expect(monitor.status().checkedAt).not.toBeNull(); + }); + + it("githubReleaseUrl reconstructs the vX.Y.Z tag page", () => { + expect(githubReleaseUrl("AltanS/collie", "0.10.3")).toBe( + "https://github.com/AltanS/collie/releases/tag/v0.10.3", + ); + }); + + it("fires the push exactly once per new version, persisting BEFORE notifying", async () => { + const order: string[] = []; + const store = fakeStore(); + const wrapped: UpdateStore = { + lastNotified: store.lastNotified, + setLastNotified: async (v) => { + order.push(`persist:${v}`); + await store.setLastNotified(v); + }, + }; + const { monitor, notified } = makeMonitor({ store: wrapped, notify: (v) => order.push(`notify:${v}`) }); + await monitor.checkRelease(); + await monitor.checkRelease(); // same latest → no re-nag + expect(order).toEqual(["persist:0.12.0", "notify:0.12.0"]); // persisted first, fired once + expect(notified).toEqual([]); // notify routed into `order` above + }); + + it("does not push when the updates pref is off, but still surfaces releaseAvailable", async () => { + const { monitor, notified } = makeMonitor({ updatesEnabled: () => false }); + await monitor.checkRelease(); + expect(notified).toEqual([]); + expect(monitor.status().releaseAvailable).toBe(true); // the banner still shows; only the push is gated + }); + + it("is fail-soft: a fetch error keeps prior state and sends nothing", async () => { + const { monitor, notified } = makeMonitor({ + fetchTags: async () => { + throw new Error("network down"); + }, + }); + await monitor.checkRelease(); + expect(monitor.status()).toMatchObject({ latest: null, releaseAvailable: false, checkedAt: null }); + expect(notified).toEqual([]); + }); + + it("does not notify when latest is not newer than current", async () => { + const { monitor, notified } = makeMonitor({ fetchTags: async () => ["v0.11.0", "v0.10.0"] }); + await monitor.checkRelease(); + expect(monitor.status().releaseAvailable).toBe(false); + expect(notified).toEqual([]); + }); + + it("de-dupes concurrent checks — one fetch backs both callers, then the guard clears", async () => { + let calls = 0; + let release!: (tags: string[]) => void; + const gate = new Promise((r) => { + release = r; + }); + const { monitor } = makeMonitor({ + fetchTags: () => { + calls++; + return gate; + }, + }); + const a = monitor.checkRelease(); + const b = monitor.checkRelease(); // lands while the first is still in flight → same promise + release(["v0.12.0"]); + await Promise.all([a, b]); + expect(calls).toBe(1); // NOT two hits on the API + expect(monitor.status().latest).toBe("0.12.0"); + + await monitor.checkRelease(); // guard cleared → a later check fetches afresh + expect(calls).toBe(2); + }); + + it("reports bridgeStale when the on-disk stamp diverges from the boot stamp (throttled)", async () => { + let disk = "STAMP@boot"; + const { monitor, tick } = makeMonitor({ bridgeStamp: () => disk }); + expect(monitor.status().bridgeStale).toBe(false); + disk = "STAMP@rebuilt"; + // Within the throttle window the cached value stands... + expect(monitor.status().bridgeStale).toBe(false); + tick(6_000); // ...past it, the recompute sees the divergence. + expect(monitor.status().bridgeStale).toBe(true); + }); +}); diff --git a/bridge/update.ts b/bridge/update.ts new file mode 100644 index 0000000..80f5b45 --- /dev/null +++ b/bridge/update.ts @@ -0,0 +1,272 @@ +import { mkdir, rename, writeFile } from "node:fs/promises"; +import { readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import type { Config } from "./config.ts"; +import type { UpdateStatus } from "./types.ts"; + +// Update-availability signal, surfaced on the (access-gated) /api/snapshot as `update`. Two +// independent questions the running plugin can answer about itself: +// +// • releaseAvailable — is a newer Collie RELEASE published upstream? We read the repo's git tags +// over anonymous HTTPS (the repo is public) and compare the newest `vX.Y.Z` to the running +// version. No `git` subprocess (the SSH origin has no agent under systemd --user, and a +// non-git install has no origin at all), no auth (the 60/hr anonymous limit is irrelevant at a +// few-hours cadence), and the fetch is trivially injectable for `bun test`. +// • bridgeStale — is the running bridge PROCESS behind the on-disk bridge source? The frontend +// build id can't answer this (it's read fresh from disk, so a stale bridge reports the NEW +// bundle). We stamp the bridge sources at process start and compare; a rebuilt-but-not-restarted +// bridge (the "#1 my change didn't take" trap) then reads as stale. +// +// The pure pieces (semver compare, tag selection, notify gating, the source stamp) are exported and +// unit-tested; the network + filesystem live behind injected seams on {@link UpdateMonitor}, matching +// the NotificationCoordinator/Snooze injection style. + +const SEMVER_TAG = /^v(\d+)\.(\d+)\.(\d+)$/; +// The upstream tag check is bounded — a hung request must never wedge the monitor's timer. +const TAGS_TIMEOUT_MS = 10_000; +// bridgeStale is read on every snapshot poll; recompute the on-disk stamp at most this often so a +// busy poll loop doesn't stat the source tree dozens of times a second (the value barely changes). +const STALE_TTL_MS = 5_000; + +// ── Pure helpers (unit-tested) ──────────────────────────────────────────────── + +/** Parse a strict `vX.Y.Z` tag into its numeric parts, or null (prereleases like `v1.0.0-rc` and any + * non-release ref are rejected by the anchor). Remote ref names are untrusted input. */ +export function parseSemverTag(tag: string): [number, number, number] | null { + const m = SEMVER_TAG.exec(tag.trim()); + return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null; +} + +/** Compare two dotted `X.Y.Z` versions (no leading `v`). Returns -1 / 0 / 1. */ +export function compareSemver(a: string, b: string): number { + const pa = a.split(".").map(Number); + const pb = b.split(".").map(Number); + for (let i = 0; i < 3; i++) { + const d = (pa[i] ?? 0) - (pb[i] ?? 0); + if (d !== 0) return d < 0 ? -1 : 1; + } + return 0; +} + +/** The newest release among `tags`, as a dotted `X.Y.Z` (leading `v` stripped to match + * package.json's `version`), or null if none parse as a strict release tag. */ +export function latestReleaseTag(tags: string[]): string | null { + let best: string | null = null; + for (const tag of tags) { + const parts = parseSemverTag(tag); + if (!parts) continue; + const v = parts.join("."); + if (best === null || compareSemver(v, best) > 0) best = v; + } + return best; +} + +/** Whether a NEW-version push should fire: a strictly-newer release we haven't already notified for. + * Comparing against `current` (not the raw `latest`) means a restart after updating self-heals — the + * new `current` catches up and the condition falls false with no state reset. */ +export function shouldNotify(a: { + current: string; + latest: string | null; + lastNotified: string | null; +}): boolean { + if (!a.latest) return false; + if (compareSemver(a.latest, a.current) <= 0) return false; + return a.latest !== a.lastNotified; +} + +/** A stable, comparable stamp of source files by (path, mtime, size). Order-independent. Equality is + * all we need — any content edit changes size or mtime, and a pull/rebuild touches the changed files. */ +export function stampOf(entries: { path: string; mtimeMs: number; size: number }[]): string { + return [...entries] + .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) + .map((e) => `${e.path}:${e.mtimeMs}:${e.size}`) + .join("\n"); +} + +// ── Impure seams (injected into the monitor; not unit-tested) ───────────────── + +/** Stamp the running bridge's source: every `bridge/*.ts` (EXCLUDING `*.test.ts` — a test-only edit + * needs no restart), plus the root `package.json` + `bun.lock` (a dep bump needs a restart and is + * otherwise invisible from `bridge/`). Re-`readdir`s each call so an added/deleted source counts. */ +export function bridgeStampSync(bridgeDir: string, rootDir: string): string { + const entries: { path: string; mtimeMs: number; size: number }[] = []; + const add = (path: string) => { + try { + const s = statSync(path); + entries.push({ path, mtimeMs: s.mtimeMs, size: s.size }); + } catch { + /* a missing file is itself a change vs the startup stamp — just omit it */ + } + }; + let names: string[] = []; + try { + names = readdirSync(bridgeDir).filter((n) => n.endsWith(".ts") && !n.endsWith(".test.ts")); + } catch { + /* unreadable bridge dir → an empty stamp; startup captured the same, so not "stale" */ + } + for (const n of names) add(join(bridgeDir, n)); + add(join(rootDir, "package.json")); + add(join(rootDir, "bun.lock")); + return stampOf(entries); +} + +/** The GitHub release page for a version, e.g. `…/releases/tag/v0.12.0`. Collie tags are `vX.Y.Z` + * (the versioning convention), so the `v` prefix is reconstructed from the bare version. GitHub + * serves the tag page even when there's no formal release attached, so this is always a live link. */ +export function githubReleaseUrl(repo: string, version: string): string { + return `https://github.com/${repo}/releases/tag/v${version}`; +} + +/** Anonymous HTTPS fetch of a GitHub repo's tags → their names (`["v0.11.0", …]`). Throws on a + * non-OK response or timeout so the caller keeps its previous result and retries next tick. */ +export function githubTagsFetcher(repo: string): () => Promise { + const url = `https://api.github.com/repos/${repo}/tags?per_page=100`; + return async () => { + const res = await fetch(url, { + headers: { accept: "application/vnd.github+json", "user-agent": "collie-update-check" }, + signal: AbortSignal.timeout(TAGS_TIMEOUT_MS), + }); + if (!res.ok) throw new Error(`github tags: HTTP ${res.status}`); + const data = (await res.json()) as unknown; + if (!Array.isArray(data)) return []; + return data + .map((t) => (typeof (t as { name?: unknown }).name === "string" ? (t as { name: string }).name : "")) + .filter(Boolean); + }; +} + +// ── Persistence (edge-trigger de-dupe across restarts) ──────────────────────── + +/** Records the last release we pushed a notification for, so the periodic re-check doesn't re-nag the + * same version. Its own tiny store (NOT piggybacked on push-subscriptions.json), owner-only. */ +export class UpdateStateStore { + private lastVersion: string | null = null; + private readonly file: string; + + constructor(private readonly cfg: Config) { + this.file = join(cfg.stateDir, "update-state.json"); + } + + async load(): Promise { + try { + const raw = (await Bun.file(this.file).json()) as { lastNotified?: unknown }; + this.lastVersion = typeof raw.lastNotified === "string" ? raw.lastNotified : null; + } catch { + /* none saved yet */ + } + } + + lastNotified(): string | null { + return this.lastVersion; + } + + async setLastNotified(version: string): Promise { + this.lastVersion = version; + await mkdir(this.cfg.stateDir, { recursive: true, mode: 0o700 }); + // Atomic write (tmp + rename), matching Push/NotifyPrefs/Snooze — a crash mid-write can't leave a + // corrupt file that would re-nag (or worse) on the next load. + const tmp = `${this.file}.tmp`; + await writeFile(tmp, JSON.stringify({ lastNotified: version }, null, 2), { mode: 0o600 }); + await rename(tmp, this.file); + } +} + +// ── The monitor ─────────────────────────────────────────────────────────────── + +/** Persistence seam — just what the monitor needs from {@link UpdateStateStore}. */ +export interface UpdateStore { + lastNotified(): string | null; + setLastNotified(version: string): Promise; +} + +export interface UpdateMonitorDeps { + /** The `owner/name` repo the release check + release links point at (default `AltanS/collie`). */ + repo: string; + /** The running plugin version (captured at process start — never re-read from disk, or a post-pull + * package.json would mask the very update we're detecting). */ + current: string; + /** The bridge source stamp captured at process start (see {@link bridgeStampSync}). */ + startupStamp: string; + /** Fetch the upstream release tag names (throws on failure — the monitor is fail-soft). */ + fetchTags: () => Promise; + /** Recompute the on-disk bridge source stamp for the staleness check. */ + bridgeStamp: () => string; + store: UpdateStore; + now: () => number; + /** Whether update pushes are enabled (the `updates` notify pref — the user's off-switch). */ + updatesEnabled: () => boolean; + /** Fire the update-available push for `latest`. */ + notify: (latest: string) => void; +} + +export class UpdateMonitor { + private latest: string | null = null; + private checkedAt: number | null = null; + private staleAt = Number.NEGATIVE_INFINITY; + private staleValue = false; + private inFlight: Promise | null = null; + + constructor(private readonly deps: UpdateMonitorDeps) {} + + /** + * Trigger a release-check cycle. De-dupes concurrent callers (the periodic timer and a manual + * "check now" landing together await the SAME fetch, never two), so the on-demand endpoint can't + * hammer the API. Always fail-soft — see {@link runCheck}. + */ + checkRelease(): Promise { + if (this.inFlight) return this.inFlight; + this.inFlight = this.runCheck().finally(() => { + this.inFlight = null; + }); + return this.inFlight; + } + + /** + * One release-check cycle: fetch tags, recompute `latest`, and fire an at-most-once push for a new + * version. Fail-soft — a fetch error keeps the previous `latest`/`checkedAt`. The push is gated on + * the `updates` pref and de-duped against `lastNotified`; the version is persisted BEFORE the send + * so a crash mid-send leaves an under-delivered nag rather than a duplicate. + */ + private async runCheck(): Promise { + let tags: string[]; + try { + tags = await this.deps.fetchTags(); + } catch { + return; // network / timeout — keep prior state, retry next tick + } + this.latest = latestReleaseTag(tags); + this.checkedAt = this.deps.now(); + + const { current, store } = this.deps; + if ( + this.latest && + this.deps.updatesEnabled() && + shouldNotify({ current, latest: this.latest, lastNotified: store.lastNotified() }) + ) { + await store.setLastNotified(this.latest); + this.deps.notify(this.latest); + } + } + + /** Recompute (throttled) whether the running process is behind the on-disk bridge source. */ + private bridgeStale(): boolean { + const now = this.deps.now(); + if (now - this.staleAt < STALE_TTL_MS) return this.staleValue; + this.staleValue = this.deps.bridgeStamp() !== this.deps.startupStamp; + this.staleAt = now; + return this.staleValue; + } + + /** The snapshot-facing status. Cheap: `latest` is cached from the last check, `bridgeStale` throttled. */ + status(): UpdateStatus { + const { current } = this.deps; + return { + current, + latest: this.latest, + latestUrl: this.latest ? githubReleaseUrl(this.deps.repo, this.latest) : null, + releaseAvailable: this.latest !== null && compareSemver(this.latest, current) > 0, + bridgeStale: this.bridgeStale(), + checkedAt: this.checkedAt, + }; + } +} diff --git a/web/src/components/notify-prefs-control.test.tsx b/web/src/components/notify-prefs-control.test.tsx index ba28e50..7ac6058 100644 --- a/web/src/components/notify-prefs-control.test.tsx +++ b/web/src/components/notify-prefs-control.test.tsx @@ -11,11 +11,11 @@ import { NotifyPrefsControl } from "@/components/notify-prefs-control"; // merged prefs back; a failing POST must leave the switch where it started (revert). let lastPatch: Record | undefined; -let currentPrefs: { blocked: boolean; done: boolean }; +let currentPrefs: { blocked: boolean; done: boolean; updates: boolean }; beforeEach(() => { lastPatch = undefined; - currentPrefs = { blocked: true, done: false }; + currentPrefs = { blocked: true, done: false, updates: true }; server.use( http.get("/api/notifications/prefs", () => HttpResponse.json(currentPrefs)), http.post("/api/notifications/prefs", async ({ request }) => { @@ -31,8 +31,21 @@ describe("NotifyPrefsControl", () => { render(); const needs = await screen.findByRole("switch", { name: /needs input/i }); const finished = await screen.findByRole("switch", { name: /finished/i }); + const updates = await screen.findByRole("switch", { name: /app updates/i }); expect(needs).toBeChecked(); // blocked default on expect(finished).not.toBeChecked(); // done default off + expect(updates).toBeChecked(); // updates default on + }); + + test("toggling App updates POSTs the single-key partial update", async () => { + const user = userEvent.setup(); + render(); + const updates = await screen.findByRole("switch", { name: /app updates/i }); + + await user.click(updates); // on → off + + await waitFor(() => expect(lastPatch).toEqual({ updates: false })); + await waitFor(() => expect(updates).not.toBeChecked()); }); test("toggling a row POSTs the single-key partial update", async () => { diff --git a/web/src/components/notify-prefs-control.tsx b/web/src/components/notify-prefs-control.tsx index 82a84cc..de93c30 100644 --- a/web/src/components/notify-prefs-control.tsx +++ b/web/src/components/notify-prefs-control.tsx @@ -5,13 +5,15 @@ import { Switch } from "@/components/ui/switch"; import { useNotifyPrefs } from "@/hooks/use-notify-prefs"; import type { NotifyPrefs } from "@/lib/api"; -// Which agent lifecycle events are worth a push. Bridge-wide (fans out to every device, like the -// snooze), so the copy says so. Two switches: "Needs input" (blocked, default on) and "Finished" -// (done, default off). Optimistic toggle with revert on failure — see useNotifyPrefs. +// Which lifecycle events are worth a push. Bridge-wide (fans out to every device, like the snooze), +// so the copy says so. Three switches: "Needs input" (blocked, default on), "Finished" (done, +// default off), and "App updates" (updates, default on). Optimistic toggle with revert on failure — +// see useNotifyPrefs. const ROWS: ReadonlyArray<{ key: keyof NotifyPrefs; label: string; hint: string }> = [ { key: "blocked", label: "Needs input", hint: "an agent is waiting on you" }, { key: "done", label: "Finished", hint: "an agent completes its task" }, + { key: "updates", label: "App updates", hint: "a new Collie version is available" }, ]; export function NotifyPrefsControl() { diff --git a/web/src/components/update-banner.test.tsx b/web/src/components/update-banner.test.tsx new file mode 100644 index 0000000..4974d5d --- /dev/null +++ b/web/src/components/update-banner.test.tsx @@ -0,0 +1,112 @@ +import { render, screen } from "@testing-library/react"; +import { createMemoryRouter, RouterProvider } from "react-router"; +import { describe, expect, it } from "vitest"; + +import { ROOT_ROUTE_ID, type HomeData } from "@/lib/loaders"; +import type { UpdateInfo } from "@/lib/types"; +import { UpdateBanner, updateNotice } from "./update-banner"; + +// A real, existing Collie release (kept `current` below it so the "available" scenario is honest). +const RELEASE_URL = "https://github.com/AltanS/collie/releases/tag/v0.10.3"; +const someUpdate = (over: Partial): UpdateInfo => ({ + current: "0.9.0", + latest: "0.10.3", + latestUrl: RELEASE_URL, + releaseAvailable: false, + bridgeStale: false, + checkedAt: null, + ...over, +}); + +// The precedence is the load-bearing bit, so it's unit-tested pure. bridgeStale (restart the +// running process) outranks releaseAvailable (upgrade) since restarting is the cheaper, more urgent +// fix; an absent `update` (older bridge) and a "nothing pending" update both fall through to null. +describe("updateNotice", () => { + it("returns null when update is absent (older bridge / no info)", () => { + expect(updateNotice(undefined)).toBeNull(); + }); + + it("returns null when nothing is pending", () => { + expect(updateNotice(someUpdate({}))).toBeNull(); + }); + + it("prefers the bridge restart over a release when both are pending", () => { + expect(updateNotice(someUpdate({ bridgeStale: true, releaseAvailable: true }))).toEqual({ + line: "Bridge restart needed", + command: "herdr plugin action invoke restart --plugin herdr.collie", + }); + }); + + it("names the available release and links to it, with no command (the release page carries them)", () => { + expect(updateNotice(someUpdate({ releaseAvailable: true, latest: "0.10.3" }))).toEqual({ + line: "Collie 0.10.3 available", + href: RELEASE_URL, + }); + }); + + it("stays silent when a release is flagged but no version is known", () => { + expect(updateNotice(someUpdate({ releaseAvailable: true, latest: null }))).toBeNull(); + }); +}); + +function homeData(update: UpdateInfo | undefined): HomeData { + return { + bridge: "connected", + device: undefined, + agents: [], + shellPanes: [], + workspaces: [], + tabs: [], + sessions: [], + session: undefined, + snoozedUntil: null, + update, + error: false, + }; +} + +// The banner reads its `update` from the root loader data, so drive it through a memory router that +// serves a HomeData with the field set — mirroring the real route nesting. +function renderBanner(update: UpdateInfo | undefined) { + const router = createMemoryRouter( + [ + { + id: ROOT_ROUTE_ID, + path: "/", + loader: () => homeData(update), + element: ( +
+ +
+ ), + }, + ], + { initialEntries: ["/"] }, + ); + return render(); +} + +describe("UpdateBanner", () => { + it("shows the release notice as a link to the release, with no command (the page carries it)", async () => { + renderBanner(someUpdate({ releaseAvailable: true, latest: "0.10.3" })); + const link = await screen.findByRole("link", { name: "Collie 0.10.3 available" }); + expect(link).toHaveAttribute("href", RELEASE_URL); + expect(screen.queryByRole("button")).toBeNull(); // no copyable command for the release case + }); + + it("shows the restart line (no link) when the running bridge is stale", async () => { + renderBanner(someUpdate({ bridgeStale: true })); + expect(await screen.findByText("Bridge restart needed")).toBeInTheDocument(); + expect(screen.queryByRole("link")).toBeNull(); // restart isn't a release — no GitHub link + expect( + screen.getByText("herdr plugin action invoke restart --plugin herdr.collie"), + ).toBeInTheDocument(); + }); + + it("renders nothing when there is no update info", async () => { + renderBanner(undefined); + await screen.findByTestId("root"); // wait for the loader to resolve + expect(screen.queryByRole("button")).toBeNull(); + expect(screen.queryByText(/available|restart/i)).toBeNull(); + }); +}); diff --git a/web/src/components/update-banner.tsx b/web/src/components/update-banner.tsx new file mode 100644 index 0000000..d317c75 --- /dev/null +++ b/web/src/components/update-banner.tsx @@ -0,0 +1,108 @@ +import { useState } from "react"; +import { Check, Copy } from "lucide-react"; +import { useRouteLoaderData } from "react-router"; + +import { cn } from "@/lib/utils"; +import { ROOT_ROUTE_ID, type HomeData } from "@/lib/loaders"; +import type { UpdateInfo } from "@/lib/types"; + +// The footer "update available" chip, sitting next to the build stamp. It reads the snapshot's +// optional `update` field (surfaced on the root loader data) and, when there's something to do, +// names it with the one command that fixes it. Everything renders as plain React text nodes. + +export interface UpdateNotice { + /** The human line, e.g. "Bridge restart needed" / "Collie 0.12.0 available". */ + line: string; + /** A copyable command that resolves it — a Herdr plugin action, so it runs from ANY directory + * (Herdr resolves the plugin's checkout). Only the RESTART case carries one: it has no page to link + * to. The release case sends you to `href` instead, where the release notes carry the commands. */ + command?: string; + /** GitHub release page for the available version — the line links to it. Absent for the restart case. */ + href?: string; +} + +/** + * Decide what (if anything) the footer should nudge, from the snapshot's `update`. Precedence: a + * stale running PROCESS outranks an available release — restarting is the cheaper, more urgent fix, + * and a release upgrade restarts the service anyway. `null` = nothing to say (an older bridge omits + * `update`, or you're current). Kept pure and exported so the precedence is unit-tested directly. + */ +export function updateNotice(update: UpdateInfo | undefined): UpdateNotice | null { + if (!update) return null; + if (update.bridgeStale) { + // No release page for "restart needed" — show the Herdr restart action to copy. + return { + line: "Bridge restart needed", + command: "herdr plugin action invoke restart --plugin herdr.collie", + }; + } + // Guard on `latest` too: without a version string there's nothing meaningful to name. The release + // page (linked) carries the update commands, so the footer just links there. + if (update.releaseAvailable && update.latest) { + return { line: `Collie ${update.latest} available`, href: update.latestUrl ?? undefined }; + } + return null; +} + +export function UpdateBanner({ className }: { className?: string }) { + // Home is the root route; space/settings are its children — so the root loader data (and its + // `update`) is in scope for all three footers via one read. + const data = useRouteLoaderData(ROOT_ROUTE_ID) as HomeData | undefined; + const notice = updateNotice(data?.update); + const [copied, setCopied] = useState(false); + + if (!notice) return null; + + async function copy() { + if (!notice?.command) return; + try { + await navigator.clipboard?.writeText(notice.command); + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + } catch { + // Clipboard blocked (insecure context / denied) — the command stays readable regardless. + } + } + + return ( +
+ {notice.href ? ( + // Links to the GitHub release page for the available version (its notes carry the update + // commands). External navigation — new tab. + + {notice.line} + + ) : ( + {notice.line} + )} + {notice.command ? ( + <> + {" · "} + + + ) : null} +
+ ); +} diff --git a/web/src/components/update-check-control.test.tsx b/web/src/components/update-check-control.test.tsx new file mode 100644 index 0000000..1089060 --- /dev/null +++ b/web/src/components/update-check-control.test.tsx @@ -0,0 +1,134 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { createMemoryRouter, RouterProvider } from "react-router"; +import { http, HttpResponse } from "msw"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { server } from "@/test/setup"; +import { ROOT_ROUTE_ID, type HomeData } from "@/lib/loaders"; +import type { UpdateInfo } from "@/lib/types"; +import { UpdateCheckControl } from "./update-check-control"; + +// UpdateCheckControl reads the running version / last-checked from the root loader's `update`, and on +// tap forces an upstream check then revalidates the snapshot. Drive it through a memory router (for +// the loader data) plus MSW (for POST /api/update/check). + +const upToDate: UpdateInfo = { + current: "0.11.0", + latest: "0.11.0", + latestUrl: null, + releaseAvailable: false, + bridgeStale: false, + checkedAt: 1_700_000_000_000, +}; + +function homeData(update: UpdateInfo | undefined): HomeData { + return { + bridge: "connected", + device: undefined, + agents: [], + shellPanes: [], + workspaces: [], + tabs: [], + sessions: [], + session: undefined, + snoozedUntil: null, + update, + error: false, + }; +} + +function renderControl(update: UpdateInfo | undefined, onLoad?: () => void) { + const router = createMemoryRouter( + [ + { + id: ROOT_ROUTE_ID, + path: "/", + loader: () => { + onLoad?.(); + return homeData(update); + }, + element: ( +
+ +
+ ), + }, + ], + { initialEntries: ["/"] }, + ); + return render(); +} + +beforeEach(() => { + server.use(http.post("/api/update/check", () => HttpResponse.json(upToDate))); +}); + +describe("UpdateCheckControl", () => { + it("shows the running version and 'Up to date' when nothing is pending", async () => { + renderControl(upToDate); + expect(await screen.findByText(/running v0\.11\.0/i)).toBeInTheDocument(); + expect(screen.getByText(/up to date/i)).toBeInTheDocument(); + }); + + it("forces a check and revalidates the snapshot on tap", async () => { + let posted = false; + server.use( + http.post("/api/update/check", () => { + posted = true; + // checkedAt ADVANCES past the prior state → a genuine successful check → revalidate. + return HttpResponse.json({ ...upToDate, checkedAt: 1_700_000_000_500 }); + }), + ); + let loads = 0; + const user = userEvent.setup(); + renderControl(upToDate, () => { + loads += 1; + }); + const btn = await screen.findByRole("button", { name: /check for updates/i }); + const before = loads; // loader ran once for the initial render + + await user.click(btn); + + await waitFor(() => expect(posted).toBe(true)); + await waitFor(() => expect(loads).toBeGreaterThan(before)); // revalidation re-ran the loader + expect(btn).not.toBeDisabled(); + }); + + it("surfaces a check failure and re-enables the button", async () => { + server.use(http.post("/api/update/check", () => new HttpResponse(null, { status: 500 }))); + const user = userEvent.setup(); + renderControl(upToDate); + const btn = await screen.findByRole("button", { name: /check for updates/i }); + + await user.click(btn); + + expect(await screen.findByText(/couldn't check/i)).toBeInTheDocument(); + expect(btn).not.toBeDisabled(); + }); + + it("treats a 200 whose checkedAt didn't advance as a silent failure (no false 'Up to date')", async () => { + // The bridge is fail-soft: a GitHub error keeps prior state and STILL returns 200 with the same + // checkedAt. The button must surface that, not read it as an authoritative "checked, all good". + server.use(http.post("/api/update/check", () => HttpResponse.json(upToDate))); // same checkedAt as prior + let loads = 0; + const user = userEvent.setup(); + renderControl(upToDate, () => { + loads += 1; + }); + const btn = await screen.findByRole("button", { name: /check for updates/i }); + const before = loads; + + await user.click(btn); + + expect(await screen.findByText(/couldn't check/i)).toBeInTheDocument(); + expect(loads).toBe(before); // did NOT revalidate on a non-advancing check + expect(btn).not.toBeDisabled(); + }); + + it("prompts to check when the bridge reports no update info", async () => { + renderControl(undefined); + expect(await screen.findByText(/whether a new collie version is available/i)).toBeInTheDocument(); + expect(screen.queryByText(/up to date/i)).toBeNull(); + }); +}); diff --git a/web/src/components/update-check-control.tsx b/web/src/components/update-check-control.tsx new file mode 100644 index 0000000..1592233 --- /dev/null +++ b/web/src/components/update-check-control.tsx @@ -0,0 +1,85 @@ +import { useState } from "react"; +import { Loader2, RefreshCw } from "lucide-react"; +import { useRevalidator, useRouteLoaderData } from "react-router"; + +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { checkForUpdates } from "@/lib/api"; +import { timeAgo } from "@/lib/format"; +import { ROOT_ROUTE_ID, type HomeData } from "@/lib/loaders"; +import type { UpdateInfo } from "@/lib/types"; + +// "Check for updates" — a manual, on-demand upstream check. The bridge only polls upstream every few +// hours, so this forces a fresh look (which can take ~10s). It reads the current version + last-checked +// time from the snapshot's `update` (via the root loader), then after a check revalidates so the footer +// UpdateBanner reflects the new state. The actionable "available"/"restart needed" lines live in that +// banner; here we only confirm an up-to-date result or surface a check failure. + +function describe(update: UpdateInfo | undefined): string { + if (!update) return "Check whether a new Collie version is available."; + const checked = update.checkedAt ? ` · checked ${timeAgo(update.checkedAt)}` : ""; + return `Running v${update.current}${checked}`; +} + +export function UpdateCheckControl() { + const data = useRouteLoaderData(ROOT_ROUTE_ID) as HomeData | undefined; + const update = data?.update; + const revalidator = useRevalidator(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(false); + + async function check() { + setBusy(true); + setError(false); + const priorCheckedAt = update?.checkedAt ?? null; + try { + const result = await checkForUpdates(); + // The bridge is fail-soft: a GitHub fetch error keeps prior state and STILL returns 200. If + // `checkedAt` didn't advance (or never ran), the check silently failed — surface that rather + // than let the stale state read as an authoritative "Up to date". + if (result.checkedAt === null || result.checkedAt === priorCheckedAt) { + setError(true); + return; + } + revalidator.revalidate(); // pull the fresh snapshot so the footer UpdateBanner updates + } catch { + setError(true); + } finally { + setBusy(false); + } + } + + const upToDate = Boolean(update && !update.releaseAvailable && !update.bridgeStale); + + return ( + +
+
+ +
+
Updates
+

{describe(update)}

+
+
+
+ +
+ + {/* Lightweight result — the actionable "available"/"restart" case is left to the UpdateBanner. */} + {!busy && error && Couldn't check.} + {!busy && !error && upToDate && ( + Up to date + )} +
+
+ ); +} diff --git a/web/src/hooks/use-polling.test.ts b/web/src/hooks/use-polling.test.ts index 674b92b..abd35a9 100644 --- a/web/src/hooks/use-polling.test.ts +++ b/web/src/hooks/use-polling.test.ts @@ -55,6 +55,7 @@ function makeData(agents: AgentView[], shellPanes: AgentView[] = []): HomeData { sessions: [], session: undefined, snoozedUntil: null, + update: undefined, error: false, }; } diff --git a/web/src/lib/api.test.ts b/web/src/lib/api.test.ts index 082ed4e..8e01fe6 100644 --- a/web/src/lib/api.test.ts +++ b/web/src/lib/api.test.ts @@ -1,7 +1,15 @@ import { http, HttpResponse } from "msw"; import { server } from "@/test/setup"; -import { createTab, fetchPane, fetchSnapshot, sendReply, uploadImage, withTimeout } from "./api"; +import { + checkForUpdates, + createTab, + fetchPane, + fetchSnapshot, + sendReply, + uploadImage, + withTimeout, +} from "./api"; // The default happy-path handlers live in test/handlers.ts; here we focus on the write paths and the // ApiError-on-non-2xx contract that every mutation depends on (and uploadImage's separate code path). @@ -38,6 +46,33 @@ describe("api client", () => { const file = new File(["x"], "x.png", { type: "image/png" }); await expect(uploadImage("w1:p1", file)).rejects.toThrow(/413/); }); + + it("checkForUpdates POSTs (no body) and returns the fresh UpdateInfo", async () => { + const info = { + current: "0.11.0", + latest: "0.12.0", + releaseAvailable: true, + bridgeStale: false, + checkedAt: 1_700_000_000_000, + }; + let method: string | undefined; + let body: string | null = null; + server.use( + http.post("/api/update/check", async ({ request }) => { + method = request.method; + body = await request.text(); + return HttpResponse.json(info); + }), + ); + await expect(checkForUpdates()).resolves.toEqual(info); + expect(method).toBe("POST"); + expect(body).toBe(""); // no request body + }); + + it("checkForUpdates throws on a non-2xx response", async () => { + server.use(http.post("/api/update/check", () => new HttpResponse("down", { status: 503 }))); + await expect(checkForUpdates()).rejects.toThrow(/503/); + }); }); // Every request carries a deadline so a black-holed connection can't leave a fetch pending forever. diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index a228f3c..5d0a217 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -9,10 +9,11 @@ import type { NotifyPrefs, PaneReadResponse, SnapshotResponse, + UpdateInfo, UploadResponse, } from "./types"; -export type { NotifyPrefs }; +export type { NotifyPrefs, UpdateInfo }; class ApiError extends Error { readonly status: number; @@ -246,6 +247,15 @@ export function setNotifyPrefs(patch: Partial): Promise { + return req("/api/update/check", { method: "POST" }); +} + /** * Upload an image; the bridge saves it to a host file and returns the path to reference in a * message. Uses multipart/form-data (NOT the JSON `req` helper — the browser sets the boundary). diff --git a/web/src/lib/format.test.ts b/web/src/lib/format.test.ts index 3c3c07a..cf6eb64 100644 --- a/web/src/lib/format.test.ts +++ b/web/src/lib/format.test.ts @@ -1,4 +1,4 @@ -import { initials, shortCwd } from "./format"; +import { initials, shortCwd, timeAgo } from "./format"; describe("shortCwd", () => { it("collapses /home/ to ~", () => { @@ -63,3 +63,24 @@ describe("initials", () => { expect(initials("x")).toBe("X"); }); }); + +describe("timeAgo", () => { + const now = 1_700_000_000_000; + + it("reads 'just now' under a minute (and for now / future)", () => { + expect(timeAgo(now, now)).toBe("just now"); + expect(timeAgo(now - 30_000, now)).toBe("just now"); + expect(timeAgo(now + 5_000, now)).toBe("just now"); // future clamps to 0 + }); + + it("counts whole minutes, hours, then days", () => { + expect(timeAgo(now - 5 * 60_000, now)).toBe("5m ago"); + expect(timeAgo(now - 2 * 3_600_000, now)).toBe("2h ago"); + expect(timeAgo(now - 3 * 86_400_000, now)).toBe("3d ago"); + }); + + it("floors at the unit boundaries", () => { + expect(timeAgo(now - 119_000, now)).toBe("1m ago"); // 1m59s → 1m + expect(timeAgo(now - 90 * 60_000, now)).toBe("1h ago"); // 90m → 1h + }); +}); diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index 7b22252..5bd0dc1 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -13,3 +13,17 @@ export function initials(name: string): string { const clean = name.replace(/[^a-zA-Z0-9]/g, ""); return (clean.slice(0, 2) || "AI").toUpperCase(); } + +/** + * Compact "time ago" for a past epoch-ms timestamp — "just now" under a minute, then "5m"/"2h"/"3d" + * ago. A future or now timestamp reads "just now". Deliberately coarse: it's a footnote, not a clock. + */ +export function timeAgo(ts: number, now: number = Date.now()): string { + const secs = Math.max(0, Math.round((now - ts) / 1000)); + if (secs < 60) return "just now"; + const mins = Math.floor(secs / 60); + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + return `${Math.floor(hrs / 24)}d ago`; +} diff --git a/web/src/lib/loaders.test.ts b/web/src/lib/loaders.test.ts index e5959a1..299bcbb 100644 --- a/web/src/lib/loaders.test.ts +++ b/web/src/lib/loaders.test.ts @@ -49,6 +49,28 @@ describe("rootLoader", () => { expect(data.agents).toEqual([]); expect(data.bridge).toBeUndefined(); }); + + it("surfaces the snapshot's optional update field onto the loader data", async () => { + const update = { + current: "0.11.0", + latest: "0.12.0", + releaseAvailable: true, + bridgeStale: false, + checkedAt: 123, + }; + server.use( + http.get("/api/snapshot", () => HttpResponse.json({ ...fixtureSnapshot, update })), + ); + const { rootLoader } = await import("./loaders"); + const data = await rootLoader(); + expect(data.update).toEqual(update); + }); + + it("leaves update undefined when the snapshot omits it (older bridge)", async () => { + const { rootLoader } = await import("./loaders"); + const data = await rootLoader(); + expect(data.update).toBeUndefined(); + }); }); describe("paneLoader", () => { diff --git a/web/src/lib/loaders.ts b/web/src/lib/loaders.ts index c0a3ec7..d92fd51 100644 --- a/web/src/lib/loaders.ts +++ b/web/src/lib/loaders.ts @@ -14,6 +14,7 @@ import type { SessionSummary, SnapshotResponse, TabView, + UpdateInfo, WorkspaceView, } from "@/lib/types"; @@ -60,6 +61,8 @@ export interface HomeData { session: string | undefined; /** Active notification snooze deadline (epoch ms), or null when not snoozed. */ snoozedUntil: number | null; + /** Version / upgrade status for the footer update banner; undefined on an older bridge. */ + update: UpdateInfo | undefined; /** True when this render is the last-good snapshot after a failed refresh. */ error: boolean; } @@ -95,6 +98,7 @@ function toHomeData(snap: SnapshotResponse, session: string | undefined, error: sessions: snap.sessions ?? [], session, snoozedUntil: snap.notifications?.snoozedUntil ?? null, + update: snap.update, error, }; } @@ -121,6 +125,7 @@ export async function rootLoader({ request }: { request?: Request } = {}): Promi sessions: [], session, snoozedUntil: null, + update: undefined, error: true, }; } diff --git a/web/src/lib/push-decision.test.ts b/web/src/lib/push-decision.test.ts index 027796d..e550baf 100644 --- a/web/src/lib/push-decision.test.ts +++ b/web/src/lib/push-decision.test.ts @@ -55,6 +55,30 @@ describe("decidePush", () => { paneId: undefined, }); }); + + test("carries a settings target through so the tap can route there", () => { + expect( + decidePush( + { + title: "Collie 0.12.0 available", + body: "collie-ctl.sh update", + data: { target: "settings" }, + }, + false, + ), + ).toMatchObject({ + kind: "show", + title: "Collie 0.12.0 available", + target: "settings", + paneId: undefined, + }); + }); + + test("an agent push carries no target (defaults to the pane deep-link path)", () => { + const decision = decidePush({ title: "claude needs you", data: { paneId: "p1" } }, false); + expect(decision).toMatchObject({ kind: "show", paneId: "p1" }); + expect((decision as { target?: string }).target).toBeUndefined(); + }); }); describe("tagFor", () => { diff --git a/web/src/lib/push-decision.ts b/web/src/lib/push-decision.ts index 9596f88..974b9ae 100644 --- a/web/src/lib/push-decision.ts +++ b/web/src/lib/push-decision.ts @@ -14,8 +14,12 @@ export interface PushPayload { tag?: string; /** Re-alert when replacing the slot (a new agent arrived) vs. update it silently (a retraction). */ renotify?: boolean; - /** `session` is the registry name the pane lives in — carried so the click deep-links into it. */ - data?: { paneId?: string; session?: string }; + /** + * `session` is the registry name the pane lives in — carried so the click deep-links into it. + * `target` names a non-pane destination for the tap (e.g. "settings" for an update notification); + * absent = the default agent deep-link path. + */ + data?: { paneId?: string; session?: string; target?: string }; } export type PushDecision = @@ -32,6 +36,8 @@ export type PushDecision = paneId?: string; /** Registry name of the pane's session (undefined = primary) — for the click deep-link. */ session?: string; + /** Non-pane tap destination (e.g. "settings"); undefined = the default agent deep-link. */ + target?: string; renotify: boolean; }; @@ -47,6 +53,7 @@ export const tagFor = (paneId?: string): string => (paneId ? `collie:${paneId}` export function decidePush(payload: PushPayload, hasVisibleClient: boolean): PushDecision { const paneId = payload.data?.paneId; const session = payload.data?.session; + const target = payload.data?.target; const tag = payload.tag ?? tagFor(paneId); if (payload.type === "clear") return { kind: "clear", tag }; if (hasVisibleClient) return { kind: "suppress" }; @@ -57,6 +64,7 @@ export function decidePush(payload: PushPayload, hasVisibleClient: boolean): Pus tag, paneId, session, + target, renotify: payload.renotify ?? false, }; } diff --git a/web/src/lib/pwa.ts b/web/src/lib/pwa.ts index ddb787a..8122887 100644 --- a/web/src/lib/pwa.ts +++ b/web/src/lib/pwa.ts @@ -37,6 +37,24 @@ function reloadOnce() { window.location.reload(); } +// Last-resort reload that BYPASSES a wedged service worker: unregister every registration first, so +// the ensuing navigation fetches straight from the bridge instead of being answered from the stale +// precache (a plain reload stays controlled by the active worker and would re-serve the SAME old +// bundle — the "keeps saying new build, won't update" trap). The SW re-registers clean on the fresh +// load. Used ONLY when the normal worker-swap didn't confirm a newly-activated worker — never on the +// happy path, where the new precache is already in place and reloadOnce() is correct and lighter. +async function forceReload(): Promise { + if (reloaded) return; + reloaded = true; // set before awaiting so a racing reloadOnce() can't double-fire + try { + const regs = (await navigator.serviceWorker?.getRegistrations?.()) ?? []; + await Promise.all(regs.map((r) => r.unregister())); + } catch { + /* ignore — reload regardless */ + } + window.location.reload(); +} + function onControllerChange() { if (hadController) reloadOnce(); else hadController = true; @@ -74,20 +92,27 @@ registerSW({ }); // Force an immediate update check — the footer's manual "tap to update". A newer SW installs, -// skip-waits, activates, and watchWorker reloads us onto it. If we're already on the latest SW -// there's nothing to activate, so reload now (served by the active SW) — the button is never a -// no-op. With no SW at all (plain HTTP / insecure context) a plain reload still re-fetches. +// skip-waits, activates, and watchWorker reloads us onto it (the happy path). The ONE path that +// forceReload()s — unregistering the worker so the reload bypasses a stale precache — is when +// update() SUCCEEDS (so we're online) but finds nothing to activate while the footer shows us stale: +// the wedged-precache trap. Network-failure paths (a thrown update(), or the stuck-guard) fall back to +// a PLAIN reload instead — unregistering there would strand an offline PWA on an error page with its +// precache gone. With no SW at all (plain HTTP / insecure context) a plain reload already re-fetches. export async function checkForUpdate(): Promise { if (!("serviceWorker" in navigator) || !registration) { window.location.reload(); return; } const reg = registration; - // Set the stuck-guard before awaiting, so even a hung update() check can't strand the button. + // Stuck-guard: if no fresh worker has activated in time, reload from the active worker so the button + // never hangs. A plain reload (not forceReload) — a hung activation may just be a flaky network, and + // dropping the precache offline would be worse than staying on the current build. setTimeout(reloadOnce, STUCK_GUARD_MS); try { await reg.update(); } catch { + // A thrown update() is a NETWORK failure (the browser fetches sw.js directly, bypassing the SW) — + // not a wedged worker. Keep the precache and reload from it, so an offline PWA still works. reloadOnce(); return; } @@ -96,6 +121,8 @@ export async function checkForUpdate(): Promise { watchWorker(reg.waiting); reg.waiting.postMessage({ type: "SKIP_WAITING" }); } - // Nothing new to activate → already current; reload to surface the latest from the active SW. - if (!reg.installing && !reg.waiting) reloadOnce(); + // update() succeeded yet found nothing to activate, while the button only shows when we're provably + // stale → the active worker is behind and won't self-update. The one place unregister-then-reload is + // both safe (we're online) and necessary — bypass the wedged precache rather than re-serve it. + if (!reg.installing && !reg.waiting) await forceReload(); } diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 0756803..2c510d6 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -79,6 +79,26 @@ export interface SessionSummary { blocked: number; } +/** + * Version / upgrade status for the running Collie (mirrors UpdateInfo in bridge/types.ts). Optional + * on the snapshot — an older bridge omits it entirely, which the client treats as "no info" (the + * update banner renders nothing). `latest` is null when the newest upstream release isn't known. + */ +export interface UpdateInfo { + /** The version this bridge is running, e.g. "0.11.0". */ + current: string; + /** Newest upstream release, e.g. "0.12.0", or null when unknown. */ + latest: string | null; + /** GitHub release page for `latest` (the banner links to it), or null when `latest` is unknown. */ + latestUrl: string | null; + /** A newer release than `current` exists upstream — the update action will fetch it. */ + releaseAvailable: boolean; + /** The running bridge PROCESS is behind the on-disk code — a `systemctl restart` picks it up. */ + bridgeStale: boolean; + /** When the upstream check last ran (epoch ms), or null if it hasn't. */ + checkedAt: number | null; +} + export interface SnapshotResponse { bridge: BridgeStatus; /** Per-device authorisation for the requesting client; absent when the feature is off. */ @@ -91,6 +111,8 @@ export interface SnapshotResponse { notifications?: { snoozedUntil: number | null }; /** The bridge's session registry (primary-first). Absent on a single-session / older bridge. */ sessions?: SessionSummary[]; + /** Version / upgrade status. Absent on an older bridge that doesn't report it. */ + update?: UpdateInfo; ts: number; } @@ -138,6 +160,8 @@ export interface NotifyPrefs { blocked: boolean; /** Push when an agent finishes its task. Default off. */ done: boolean; + /** Push when a new Collie version is available (a restart or upgrade is waiting). Default on. */ + updates: boolean; } /** Lower sorts first — "needs you" at the top. Mirrors STATUS_RANK on the server. */ diff --git a/web/src/routes/detail.test.tsx b/web/src/routes/detail.test.tsx index 15fe7a9..eebd808 100644 --- a/web/src/routes/detail.test.tsx +++ b/web/src/routes/detail.test.tsx @@ -44,6 +44,7 @@ const connected = (agents: AgentView[], shellPanes: AgentView[] = []): HomeData sessions: [], session: undefined, snoozedUntil: null, + update: undefined, error: false, }); diff --git a/web/src/routes/home.tsx b/web/src/routes/home.tsx index 2ef70d4..035f62c 100644 --- a/web/src/routes/home.tsx +++ b/web/src/routes/home.tsx @@ -8,6 +8,7 @@ import { SpaceOverview } from "@/components/space-overview"; import { NewSpaceSheet } from "@/components/new-space-sheet"; import { StatusArea } from "@/components/status-area"; import { BuildStamp } from "@/components/build-stamp"; +import { UpdateBanner } from "@/components/update-banner"; import { useLoadingStalled } from "@/hooks/use-loading-stalled"; import { useOnline } from "@/hooks/use-online"; import { useSpaceActions } from "@/hooks/use-spaces"; @@ -72,7 +73,9 @@ export function HomeRoute() { - {/* Build stamp: which bundle you're running, with a stale-cache nudge. */} + {/* An available update / needed restart, then the build stamp (which bundle you're + running, with a stale-cache nudge). */} +
diff --git a/web/src/routes/settings.tsx b/web/src/routes/settings.tsx index 093c09f..9a50374 100644 --- a/web/src/routes/settings.tsx +++ b/web/src/routes/settings.tsx @@ -4,10 +4,12 @@ import { useNavigate, useRouteLoaderData } from "react-router"; import { Button } from "@/components/ui/button"; import { BuildStamp } from "@/components/build-stamp"; +import { UpdateBanner } from "@/components/update-banner"; import { ConnectionInfo } from "@/components/connection-info"; import { Card } from "@/components/ui/card"; import { NotifyPrefsControl } from "@/components/notify-prefs-control"; import { SnoozeControl } from "@/components/snooze-control"; +import { UpdateCheckControl } from "@/components/update-check-control"; import { Switch } from "@/components/ui/switch"; import { fetchConfig } from "@/lib/api"; import { usePushControl } from "@/hooks/use-push"; @@ -108,9 +110,16 @@ export function SettingsRoute() { )} + {/* On-demand upstream update check (independent of push) — drives the footer UpdateBanner. */} + + - + {/* Update nudge + build stamp, grouped and pinned to the bottom of the page. */} +
+ + +
); diff --git a/web/src/routes/space.tsx b/web/src/routes/space.tsx index 5707d91..6175a13 100644 --- a/web/src/routes/space.tsx +++ b/web/src/routes/space.tsx @@ -9,6 +9,7 @@ import { TabStrip } from "@/components/tab-strip"; import { NewSpaceSheet } from "@/components/new-space-sheet"; import { StatusArea } from "@/components/status-area"; import { BuildStamp } from "@/components/build-stamp"; +import { UpdateBanner } from "@/components/update-banner"; import { useLoadingStalled } from "@/hooks/use-loading-stalled"; import { useOnline } from "@/hooks/use-online"; import { useSpaceActions } from "@/hooks/use-spaces"; @@ -111,7 +112,9 @@ export function SpaceRoute() { )} - {/* Build stamp: which bundle you're running, with a stale-cache nudge. */} + {/* An available update / needed restart, then the build stamp (which bundle you're + running, with a stale-cache nudge). */} +
diff --git a/web/src/sw.ts b/web/src/sw.ts index 7a26f24..77085ef 100644 --- a/web/src/sw.ts +++ b/web/src/sw.ts @@ -68,7 +68,7 @@ async function handlePush(event: PushEvent): Promise { // support it (and it needs a tag). const options: NotificationOptions & { renotify?: boolean } = { body: decision.body, - data: { paneId: decision.paneId, session: decision.session }, + data: { paneId: decision.paneId, session: decision.session, target: decision.target }, icon: ICON, badge: ICON, tag: decision.tag, @@ -81,6 +81,8 @@ interface NotifData { paneId?: string; /** Registry name of the pane's session (undefined = primary) — the deep-link scopes to it. */ session?: string; + /** Non-pane tap destination (e.g. "settings"); absent = the default agent deep-link. */ + target?: string; } // Session query builder, inlined so the SW bundle stays dependency-free (it imports only @@ -89,18 +91,28 @@ function sessionSearchParam(session?: string): string { return session ? `?s=${encodeURIComponent(session)}` : ""; } -// Tap a notification: deep-link to the agent's pane (never act on it blind — the reply lives in-app). +// Tap a notification: an update push routes to Settings; everything else deep-links to the agent's +// pane (never act on it blind — the reply lives in-app). An old cached SW that predates `target` +// simply ignores it and takes the pane path, opening "/" for a pushed update — acceptable. self.addEventListener("notificationclick", (event: NotificationEvent) => { event.notification.close(); const data = (event.notification.data as NotifData | null) ?? {}; + if (data.target === "settings") { + event.waitUntil(openPath("/settings")); + return; + } event.waitUntil(openPane(data.paneId, data.session)); }); -// Focus an existing Collie tab (navigating it to the agent) or open a new one — the body-tap path. -// The session rides along as `?s=` so the deep-link lands in the right herd (omitted for primary). +// Deep-link to the agent's pane — the body-tap path. The session rides along as `?s=` so it lands in +// the right herd (omitted for primary). Delegates the focus/navigate/open to openPath. async function openPane(paneId: string | undefined, session?: string): Promise { const base = paneId && paneId !== "test" ? `/pane/${encodeURIComponent(paneId)}` : "/"; - const path = `${base}${sessionSearchParam(session)}`; + await openPath(`${base}${sessionSearchParam(session)}`); +} + +// Focus an existing Collie tab (navigating it to `path`) or open a new one. `path` is origin-relative. +async function openPath(path: string): Promise { const url = new URL(path, self.location.origin).href; const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); for (const client of windows) { diff --git a/web/src/test/handlers.ts b/web/src/test/handlers.ts index a9b98d1..09c326d 100644 --- a/web/src/test/handlers.ts +++ b/web/src/test/handlers.ts @@ -132,9 +132,21 @@ export const handlers = [ const { snoozedUntil } = (await request.json()) as { snoozedUntil: number | null }; return HttpResponse.json({ snoozedUntil }); }), - http.get("/api/notifications/prefs", () => HttpResponse.json({ blocked: true, done: false })), + http.get("/api/notifications/prefs", () => + HttpResponse.json({ blocked: true, done: false, updates: true }), + ), http.post("/api/notifications/prefs", async ({ request }) => { const patch = (await request.json()) as Record; - return HttpResponse.json({ blocked: true, done: false, ...patch }); + return HttpResponse.json({ blocked: true, done: false, updates: true, ...patch }); }), + http.post("/api/update/check", () => + HttpResponse.json({ + current: "0.11.0", + latest: "0.11.0", + latestUrl: null, + releaseAvailable: false, + bridgeStale: false, + checkedAt: Date.now(), + }), + ), ];