diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 22848a0a..47212ae2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -90,7 +90,7 @@ jobs: # Dependency order; already-published versions are skipped so reruns are safe. # Dependency order matters: the bundle depends on every bay, so a bay missing from # this list publishes a bundle whose dependency does not exist (the 0.1.0 incident). - for dir in protocol daemon client shell status review updater subagents trust opencode; do + for dir in protocol daemon client shell status review updater subagents trail trust opencode; do name=$(jq -r .name "packages/$dir/package.json") version=$(jq -r .version "packages/$dir/package.json") if npm view "$name@$version" version >/dev/null 2>&1; then diff --git a/CHANGELOG.md b/CHANGELOG.md index df5c9fea..34d8a942 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,203 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +### Added + +- **Trail — a new bay: what a conversation made.** `@opencode-cockpit/trail`, also in the bundle + (`features.trail: false` to switch it off), on OpenCode 1 and 2 alike. The pull requests, tickets, + pages and deploys the agent created or changed, kept per conversation, grouped by the ticket they + were for, and one click from the page. + - **No setup.** No account, no token, no list of tools. The agent already knows what it just did, + with whatever it uses — `gh`, an MCP server, a company CLI — so it records it with `trail_add` + (`title`, a `url` or a `ref`, and free-text `kind`, `action`, `for`, `note`); the same link again + updates the record (`created → updated`) instead of adding a row. It is told so on every request, + subagents included, and what the conversation produced is rebuilt into its system prompt each + time, so it still knows after a compaction. + - **A safety net, never automatic:** when something the agent *ran* printed a PR or issue link it + never recorded, its next request says so, as a choice. Links in files it read or pages it fetched + are ignored, and nothing is added without the agent or you. + - **In the sidebar,** on by default after Shells: each thing's name, title, system and what this + conversation last did — history, not a status, because a PR's state belongs to GitHub. Click a + row with `↗` to open the page; `+ N more · /trail` opens the rest. + - **Its time says when: `now`, `12m ago`, `2h ago`** — in the sidebar and `/trail` — because the + column sits under Shells' and Subagents' durations, where a bare `2h` read as two hours of work. + A narrow sidebar drops the "ago" before it cuts the ref. + - **A record with no ref gives its title the ref's column.** Its kind used to stand there, cut + (`Confluenc… Release notes for…`), and `/trail` said it again beside the `Confluence` chip. + - **`/trail`** (`ctrl+x f`): this conversation, or every conversation in the project (`tab`), with + the conversations that touched each thing under it. `enter` opens the page, `g` goes back to the + conversation that made it, `c` copies the link, `m` the whole trail as markdown, `x` removes, + `/` searches. + - **`/link`** adds one yourself: it asks for the link, and a note if you like. A link you add + shows in the sidebar at once. + - The system comes from the link (GitHub, Jira, Confluence, Linear, Claude, else the domain); + query parameters that look like secrets are dropped before anything is stored, and only + `http(s)` links are opened. A deleted conversation keeps its records, under the title it had. + - `trail_list` gives the agent the same facts and order as `/trail`. Kept append-only in + `~/.local/share/opencode-cockpit/trail/`, shared by every window on the project. +- **`/cockpit-setup` — the agent sets Cockpit up with you.** Cockpit now ships a `cockpit-setup` + skill and a `cockpit_settings` tool, so the command — or a plain "make my sidebar quieter" — has + the agent read what is installed and written now (every value and where it came from, every name + from before 0.9, OpenCode's own sidebar blocks), fix the old names first, offer a starting point + (everything visible, quiet, minimal, Status as a line), ask only what is left, one question at a + time, write the smallest file that does it and check it reads back with no notices. It asks before + touching OpenCode's own files, and never suggests turning the Todo block off. From the home screen + the command opens a conversation; while the agent is answering it waits its turn (`1 queued`) + instead of cutting the reply off. In the palette as well, whichever Cockpit packages you installed. + The skill's settings reference is written from the code, and a test fails when the two disagree. + - **Then, if you want it: "tune it to how you work."** A tour of each bay you have on, with its + real key and command, then your project's conventions: which commands keep running (found in + `package.json` scripts, a Makefile, a compose file or a Procfile) and belong in a background + shell, your ticket prefix (offered from your branches and commits) so Trail groups by ticket, where + PRs go, whether to explore in background subagents. Written, after you agree, as one + `## Cockpit conventions` section in this project's `AGENTS.md` or OpenCode's global one, by a + `cockpit_conventions` tool that replaces that section in place on a rerun and keeps every other + byte of the file. Conventions only: how to use each bay is already in every request. + - **Every OpenCode sidebar block, by its id, for the version you run:** Context (suggested off when + Status's table is in the sidebar), MCP (neutral: Status's table warns when a server fails), + Footer, and on OpenCode 1 LSP and Files; Todo is never suggested off. `status-setup` lists the + same. +- **Review shows images.** A changed binary is read as bytes instead of being skipped or shown as + `U+FFFD`. An image says what changed — `PNG 2880×1800 · 807 KB → 789 KB` (PNG, APNG, JPEG, GIF, + WebP, BMP; any other binary its sizes) — and PNG and GIF of the same size get a pixel diff (how much + changed, and where) and a preview, before and after side by side in half-block characters with the + changes lit. `o` opens both versions in your system viewer. Decoding runs in slices off the draw + path, so a pair of screenshots never freezes the window. +- **`[?] Keys` in Review and Subagents**, like Trust's: every key the pane takes, and `esc` back. +- **Every sidebar block says it is there.** Shells, Subagents and Trail draw their heading and + `none yet` before anything has run, in the row the first item will take, so a new user can tell + they are installed and the blocks below do not jump. `"hideWhenEmpty": true` brings the silence + back, per bay. +- **Status's sidebar table is built in.** `title`, `in`, `out`, `cache`, `write`, `sep`, `spend`, + `avail` and `git` are built-in segments now (`git` counts what is uncommitted; `"against": "branch"` + counts the branch against main), and the table leaves out the turn's `working` clock while still + showing a retry. `context` gets style `solid` and `tokens` style + `row`, so the table needs no module. `spend` and `avail` read a proxy's budget file and draw nothing + without one; a hairline draws only between two rows. +- **Status's `override` changes a row or two and keeps the rest.** `{ "status": { "override": { "git": + { "against": "branch" } } } }` changes the table's `git` row and leaves the other thirteen following + the preset, where it used to take a copy of the whole list in `segments`. `false` drops a segment, a + name swaps it in place, an object merges into its settings; a line in `lines` takes its own. A name + that matches no segment is a `!` row naming the closest (`override "gti" … did you mean "git"?`). +- **Status's preview reads a file as OpenCode does.** `preview --config ` goes through the + plugin's own loader and resolution — `preset`, `sidebarRows`, `override` and the `!` rows — and stops + on a file it cannot read or a flag it does not know rather than drawing other settings. `--config -` + reads a candidate on stdin as the file it will become (`--as global|project`), so a change is seen + before it is written anywhere — the `status-setup` skill previews this way, with no temporary file. + The setup skills run the preview that came with your install — `cockpit_settings` names it under + Previews — never `bunx`, which fetches another release. + `--surface sidebar|bottom` draws there whatever the file says; the sidebar is 34 columns unless + `--width` says otherwise. `--debug` names every row: `✓git` drew, `✗spend` drew nothing, `?gti` is + no segment at all — where `⟨?title⟩` and `⟨todo⟩` used to differ by one character in the same + brackets. + +### Changed +- **New default keys, the same on OpenCode 1 and 2: Subagents `ctrl+x d` (was `w`), the shell + console `ctrl+x j` (was `i`), Review's placement `ctrl+x k` (was `r`).** The old ones were OpenCode's + own — on OpenCode 2 `ctrl+x w` closes the tab and `ctrl+x i` shows image attachments, and + `ctrl+x r` is redo on both. A test now holds every Cockpit default clear of OpenCode's. To keep + the old keys, set them in the bay's `keybinds`, e.g. + `"subagents": { "keybinds": { "cockpit.subagents.open": "w" } }`. +- **The agent knows where you see its work.** One line per window, written from the bays you have + on and your keys: shells, subagents and this conversation's trail in the sidebar, review threads in + Review — so it points you there instead of pasting the lists. +- **Shell's guidance:** the agent checks for a running dev server or watcher before starting one and + reuses it, and gives shells short names you recognise (`dev`, `test`, `build`). +- **Trail's guidance:** records group under the ticket with `for`, and the agent calls `trail_list` + before saying what the work produced instead of answering from memory. +- **On OpenCode 2, every bay's guidance names tools as Code Mode calls them** (`tools.shell_start`). +- **Review's `enabled: false` turns off its agent side too** — its tools and guidance. +- **Behaviour, measured:** `AGENT=1` smoke runs real turns that must end in the right tool — + `shell_start` for a dev server and no second one, `review_list`/`review_reply` for a waiting comment, + `trail_add` for a new PR — so a wording change that stops working cannot ship unnoticed. Trail's + passes on two of three turns (`measure/agent.ts --runs 3 --pass 2`): a free model misses about one + in six. +- **Settings: one shape, one loader, one file for both halves.** "Configure them in one file, read by + both halves of the plugin and by every project" was true for Shell only; now it is true for every + bay. Every bay reads `~/.config/opencode-cockpit/config.json` and a project's `.cockpit.json` + through the same loader, one section per bay — `status`, `subagents`, `shell`, `trail`, `trust`, + `review`, `updater` — with the same shared keys in each: `enabled`, `keybinds`, `sidebar`, + `sidebarRows`, `hideWhenEmpty`. Time keys carry their unit. + [Configuration](https://codestz.github.io/opencode-cockpit/configuration/) is the whole reference. + - **Subagents and Review read the files.** Before, their settings existed only on the plugin + entry — `tui.json` for the interface and `opencode.json` for the agent, two places for one bay. + - **Shell's keys moved into a `shell` section**, and `ui.*` with them: `ui.historyMinutes` is + `shell.hideFinishedAfterMinutes`. **Status's section is `status`**, not `statusline`. + Subagents' `hideFinishedAfter` / `hideNestedAfter` are `hideFinishedAfterMinutes` / + `hideNestedAfterSeconds`. The Updater reads `updater.updateCheck`, not `ui.updateCheck`. + - **The old names are not read.** Each one a file still carries is a `!` row in its bay's block — + `! settings: "statusline" is no longer read — run /cockpit-setup` — a line in `doctor`, and the + first thing `/cockpit-setup` fixes. Detection only, removed in 0.10. + - **One order:** the top-level `"sidebar"` list, default `["status", "subagents", "shell", + "trail", "trust"]`, is the only one; each bay's `sidebarOrder` is gone (Trust now sits below + Shells by default). An entry that is not a bay asks whether you meant the closest one + (`"shells"` → `"shell"`). On OpenCode 2 the bundle applies the list; separately installed + packages draw in the order `cli.json` lists them. + - Status's keys at a file's root are no longer read as Status's (a root `"enabled": false` meant + for something else used to turn the statusline off). +- **Status lives in the sidebar by default, as a table.** The `sidebar` preset is now the budget + table — headed `Status`, the window as one solid bar, the tokens in named rows with their share, a + proxy's spend and what is left, what is uncommitted — and it is what you get + with no configuration. `{ "status": { "sidebar": false } }` (or `"surface": "bottom"`) puts the + line under the prompt again. A preset that does not exist, or a config pointing at a removed + example, gets a `!` row naming the presets there are, never a blank column. +- **`/statusline` is `/status-setup`**, and loads the `status-setup` skill that ships with Status: + presets as starting points, every segment, the design rules, and the preview that came with your + install before anything is called done. The old name works for one release and says the new one + first. From the home screen it opens a conversation; behind a reply it waits its turn. Status gains + an agent side for this (`@opencode-cockpit/status/server`), included in the bundle; installed on its + own, its install line (`opencode plugin @opencode-cockpit/status@… --global --force`) now adds the + agent-side entry too. +- **Every subagent says what it is.** Each row names its agent, muted — `general` too — and a + subagent with no title is named by its task's first words. + - **The agents are one column, so the titles line up.** As wide as the longest agent shown, eight + cells at most, so `general`, `explore` and `build` read whole; each row used to shorten its own, so one title started at column 9 and + the next at column 7. + - **A finished subagent says how long it ran, and nothing else** (`â—� general Plan login… 1m04s`). + `17 calls · 1m04s` cut its title to twelve cells; the calls and rounds are in the pane. +- **Settings notices are drawn, not only logged.** Status, Shell, Subagents, Trail and Trust draw + theirs as `!` rows in their blocks, wrapped to the sidebar's width so the fix is not cut off; + Review draws its in the pane. Status also draws the ones that belong to no bay: a file that is not + valid JSON, a top-level name nothing reads. +- **`ctrl+p` works while Review is open**: the review steps aside for the host's palette. + +### Fixed + +- **A config file with a comment or a trailing comma was dropped whole, in silence**, by Shell, + Status, Trust and the sidebar order — while doctor called it fine. Every bay reads JSONC now, and a + file that truly cannot be read is a `!` row and a doctor line, and the defaults. +- **Review drew two edits more than 2,000 lines apart as a rewrite** of everything between them. A + long stretch is now split on lines unique to both sides and each piece aligned on its own. +- **Shell offered "show fewer" with one shell left** after you had expanded the list. The toggle + shows only while folding hides something, and an expansion the list outgrew folds itself back. +- **An empty Subagents block took a row of sidebar space** with `hideWhenEmpty` on OpenCode 1. +- **The `diagnostics` segment flagged every MCP server on OpenCode 2** (#34). OpenCode 2 hands an MCP + server's status as a tagged object (`{ status: "connected" }`), which was read as text, so every + connected server showed as broken. Both versions' shapes are read now; a server that is disabled or + still connecting is not an alarm, one that failed or needs auth is. `diagnostics` also joins the + sidebar table: nothing while every server is healthy, `! name` in red when one breaks, and a cut + keeps the `+N` count. +- **OpenCode 2 kept running the old Cockpit after an update.** Its background service loads plugins + once, when it starts, so the agent kept the old tools and skills (no `trail_add`, no skills) while + the windows drew the new ones. **After updating on OpenCode 2, run `opencode service restart`.** + `doctor` now warns when the service started before the install, `update` and `/plugins-update` say + to restart it, and the repository's `dev:install` restarts it itself. +- **A window now says when OpenCode 2's service runs an older Cockpit than it does:** one toast, + `Cockpit was updated — OpenCode's background service still runs the old one. Run: opencode + service restart` (with both versions when they differ). Never restarted for you — that would cut + every open window — and nothing is said when it cannot tell. The agent side records which install + it loaded; doctor reads the same record, so it no longer guesses from clocks. +- **`/cockpit-setup` said "Notices: none" while the sidebar still warned.** `cockpit_settings` and + doctor saw only the loader's notices, not each bay's own: Status's `override "gti" matches no + segment`, Trust's `threshold: "3"`. Each bay's notices now come from the bay — its keys' kinds, and + Status's own check of presets, surfaces and overrides — so "none" there means no `!` row anywhere. + +### Removed + +- The `examples/sidebar.ts`, `sidebar-full.ts` and `sidebar-budget.ts` Status modules: the `sidebar` + preset is the table they drew. +- The old `sidebar` preset, and each bay's `sidebarOrder`. + ## [0.8.0] - 2026-10-02 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8e1ded97..edea33d7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -156,8 +156,9 @@ must carry explicit types or stay module-private, or `tsc` cannot name them in d 1. `packages/` named `@opencode-cockpit/`, exporting `./server` and/or `./tui` whose default export is a plugin built from factories (`createServer`, `createTui`) that accept a `source` label and start with `claimFeature`. -2. Add it to `FEATURES` in `packages/opencode/src/features.ts` and call its factories in the - bundle's `server.ts` / `tui.ts`. +2. Add it to `BAYS` in `packages/client/src/settings.ts` — the bundle's `FEATURES`, the settings + file and doctor all read that one list — and call its factories in the bundle's `server.ts` / + `tui.ts`. 3. Add the directory to `PACKAGES` in `scripts/pack-check.ts` and to the publish loop in `.github/workflows/release.yml`, before `opencode`. 4. A brand-new npm package cannot use Trusted Publishing until it exists. Before its first release, diff --git a/README.md b/README.md index 08b392a5..e3165198 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,14 @@ [![npm](https://img.shields.io/npm/v/@opencode-cockpit/review?color=%23cb3837&label=%40opencode-cockpit%2Freview)](https://www.npmjs.com/package/@opencode-cockpit/review) [![npm](https://img.shields.io/npm/v/@opencode-cockpit/updater?color=%23cb3837&label=%40opencode-cockpit%2Fupdater)](https://www.npmjs.com/package/@opencode-cockpit/updater) [![npm](https://img.shields.io/npm/v/@opencode-cockpit/subagents?color=%23cb3837&label=%40opencode-cockpit%2Fsubagents)](https://www.npmjs.com/package/@opencode-cockpit/subagents) +[![npm](https://img.shields.io/npm/v/@opencode-cockpit/trail?color=%23cb3837&label=%40opencode-cockpit%2Ftrail)](https://www.npmjs.com/package/@opencode-cockpit/trail) [![npm](https://img.shields.io/npm/v/@opencode-cockpit/trust?color=%23cb3837&label=%40opencode-cockpit%2Ftrust)](https://www.npmjs.com/package/@opencode-cockpit/trust) [![Docs](https://img.shields.io/badge/docs-codestz.github.io-9d7cd8)](https://codestz.github.io/opencode-cockpit/) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) **Give [OpenCode](https://opencode.ai) the instruments it does not ship with.** -**[Documentation →](https://codestz.github.io/opencode-cockpit/)** · [Install](https://codestz.github.io/opencode-cockpit/start/install/) · [Shell](https://codestz.github.io/opencode-cockpit/shell/overview/) · [Review](https://codestz.github.io/opencode-cockpit/review/overview/) · [Statusline](https://codestz.github.io/opencode-cockpit/status/overview/) · [Updater](https://codestz.github.io/opencode-cockpit/updater/overview/) · [Subagents](https://codestz.github.io/opencode-cockpit/subagents/overview/) · [Trust](https://codestz.github.io/opencode-cockpit/trust/overview/) · [Changelog](CHANGELOG.md) +**[Documentation →](https://codestz.github.io/opencode-cockpit/)** · [Install](https://codestz.github.io/opencode-cockpit/start/install/) · [Shell](https://codestz.github.io/opencode-cockpit/shell/overview/) · [Review](https://codestz.github.io/opencode-cockpit/review/overview/) · [Statusline](https://codestz.github.io/opencode-cockpit/status/overview/) · [Updater](https://codestz.github.io/opencode-cockpit/updater/overview/) · [Subagents](https://codestz.github.io/opencode-cockpit/subagents/overview/) · [Trail](https://codestz.github.io/opencode-cockpit/trail/overview/) · [Trust](https://codestz.github.io/opencode-cockpit/trust/overview/) · [Configuration](#configuration) · [Changelog](CHANGELOG.md) A tool call has to finish. A dev server does not, and neither does the context window filling up behind you. Cockpit is the instrument panel: things your agent can use, and things that tell you @@ -38,7 +39,7 @@ every one of them reports its own health. [`bun run record`](CONTRIBUTING.md) and re-run on release, so none of them can drift from what ships.* -**9 agent tools · 35 watch presets · [docs](https://codestz.github.io/opencode-cockpit/shell/overview/) · [`@opencode-cockpit/shell`](packages/shell)** +**8 agent tools · 34 watch presets · [docs](https://codestz.github.io/opencode-cockpit/shell/overview/) · [`@opencode-cockpit/shell`](packages/shell)** --- @@ -63,13 +64,29 @@ stays open for you. How full is the context? Where did the tokens go? What has changed? OpenCode answers the first in a corner and the rest not at all. **Statusline** answers them where you are already looking. +With no configuration it is a table at the top of the sidebar — the window as one bar, the tokens +broken into named rows, a proxy's budget when one writes it, and the branch's diff: + +``` +Status +████████████████ +tokens 85.2k · 43% +in 265 · 0% +out 60 · 0% +cache 84.9k · 100% +────────────── +git 5f +312 -48 +``` + +Or a line under the prompt, `{ "status": { "sidebar": false } }`: + ![The statusline under an OpenCode conversation: a context bar at 40%, the token total with its cache, input and output parts, what is uncommitted, elapsed time and todo progress](media/statusline.png) -*The default line — no configuration written at all. Every part is a segment you can reshape, -recolour or remove, or write yourself in TypeScript. Your Claude Code statusline script runs here -unchanged, colours and all.* +*Every part is a segment you can reshape, recolour or remove, or write yourself in TypeScript. Your +Claude Code statusline script runs here unchanged, colours and all. `/status-setup` has the agent +change it with you.* -**14 segments · 2 surfaces · [docs](https://codestz.github.io/opencode-cockpit/status/overview/) · [`@opencode-cockpit/status`](packages/status)** +**23 segments · 2 surfaces · [docs](https://codestz.github.io/opencode-cockpit/status/overview/) · [`@opencode-cockpit/status`](packages/status)** --- @@ -111,6 +128,38 @@ can read any of them in full with `subagents_read`, and wait on the ones in the --- +### 🧭 Trail — what a conversation made + +A conversation opens a pull request, comments on a ticket, publishes a page — and a day later the +links are somewhere in a scrolled-away chat. **Trail** keeps them: the agent records what it creates +or changes outside the repository with `trail_add`, and Trail lists it in the sidebar, grouped by +the ticket it was for, one click from the page. And the other way round: `/trail` across every +conversation in the project says which one opened PR #33, and `g` goes back into it. + +![Trail at work: the agent opens a PR for COM-1736 and records it on its own, the sidebar groups it under the ticket, another conversation asks what was shipped, and g jumps back](media/trail.gif) + +``` +Trail 9 + +COM-1801 + a1b2c3d Bump the pr… 12m ago + ENG-42 Retry the s… Linear 15m ago ↗ +COM-1736 Bundle desy… Jira 2h ago ↗ + PR #33 0.8: Trust,… GitHub 1h ago ↗ + PR #12 Landing: Tr… GitHub 2h ago ↗ ++ 4 more · /trail +``` + +No setup: no account, no token, no list of tools. The agent already knows what it just did with +whatever it uses — `gh`, an MCP server, a company CLI — so the agent writes the trail and Trail keeps +it. When something it ran printed a PR link it never recorded, its next request says so, as a +choice; nothing is added without the agent or you. To add one yourself, `/link`, then paste the +link (and a note); `m` copies the trail as markdown for a PR description or a standup. + +**Sidebar + `/trail` · 2 agent tools · no setup · OpenCode 1 and 2 · [docs](https://codestz.github.io/opencode-cockpit/trail/overview/) · [`@opencode-cockpit/trail`](packages/trail)** + +--- + ### ðŸ”� Trust — permissions that learn `"bash": "ask"` means approving `git status` for the hundredth time; OpenCode's own "Always" means @@ -172,7 +221,7 @@ changes, never about a thousand identical recompiles: shell_start command="tsc --watch --noEmit" description="type checker" watch=true → tsc: ok → fail · src/auth.ts(42,3): error TS2339: Property 'id' does not exist ``` -Presets cover about 35 tools (tsc, vitest, jest, eslint, cargo, go, gradle, pytest, vite, next, +Presets cover 34 tools (tsc, vitest, jest, eslint, cargo, go, gradle, pytest, vite, next, docker compose…), and anything else takes three regexes of its own. A watched process that dies counts as a failure, so a crashed dev server is reported too. @@ -200,12 +249,23 @@ keeping line numbers and highlighting matches — and output keeps the colours t |---|---| | `/shells` | Every shell in view, plus "New shell": pick one to open its console | | `ctrl+x o` · `/shells-dock` | Toggle the shells panel under the chat | -| `ctrl+x i` · `/shell` | Reopen the last shell's console | +| `ctrl+x j` · `/shell` | Reopen the last shell's console | | `/shell-new` | Start a shell yourself | | `/shells-clear` | Remove finished shells | | `/plugins-update` | Every plugin you have installed: what runs, what is published, and an update checked against disk | - -Status reads the same everywhere — `RUN` (with a spinner), `FAIL`, `STOP`, `DONE` — running shells +| `/cockpit-setup` | The agent sets Cockpit up with you: which bays show, in the sidebar or at the bottom, in what order, quiet or present when empty — and fixes any setting from before 0.9 | +| `/status-setup` | The agent designs the Status line with you: a preset, its segments, the sidebar or the bottom (`/statusline` until 0.9; the old name still works for one release and says the new one) | +| `ctrl+x d` · `/subagents` | Open the subagent working now, in a pane beside the chat | +| `ctrl+x v` · `/changes` | Open or close the review of what changed | +| `ctrl+x k` | Move the review between the right pane and full screen | +| `ctrl+x f` · `/trail` | What this conversation made, or every conversation in the project (`tab`) | +| `/link` | Add a link to this conversation's trail yourself: paste the link, and a note if you like | +| `ctrl+x p` · `/trust` | What Trust answered for you, and the ledger of what it has learned | + +Every key is the same on OpenCode 1 and 2, none of them is one of OpenCode's own, and each bay's +`keybinds` changes it ([Keys](https://codestz.github.io/opencode-cockpit/configuration/#keys)). + +A shell's status reads the same everywhere — `RUN` (with a spinner), `FAIL`, `STOP`, `DONE` — running shells and recent failures stay in view, the rest folds behind `â–¸ N more`. In the console: `i` types straight into the program (`ctrl+]` to stop), `c` sends ctrl+c, `r` restarts, `x` stops, `tab` switches between the live screen and the scrollback, `?` shows details. @@ -253,34 +313,221 @@ An existing v1 `opencode.json` with `plugin` is read by OpenCode 2 as well. To u the version in that entry — `/plugins-update` and `npx opencode-cockpit update` edit OpenCode 1's files only. -**Turn features off** (in both `opencode.json` and `tui.json`): +**After installing or updating on OpenCode 2, restart its background service:** -```json -{ - "plugin": [["opencode-cockpit", { "features": { "shell": true } }]] -} +```sh +opencode service restart ``` -**Configure them** in one file, read by both halves of the plugin and by every project: +OpenCode 2 runs the agent side in a background service that loads plugins once, when it starts, and +keeps running when you close OpenCode. Until it restarts, the windows draw the new Cockpit while the +agent keeps the old one's tools and skills. `npx opencode-cockpit@latest doctor` says when the +service started before the install. + +**Configure them** in one file, read by both halves of every bay and by every project — see +[Configuration](#configuration), or type `/cockpit-setup` and let the agent write it with you. When +the blocks are set it offers to tune Cockpit to how you work: a tour of each bay's keys, then your +project's conventions — the dev server to keep in a background shell, your ticket prefix for Trail — +written as one `## Cockpit conventions` section in `AGENTS.md`, which a rerun updates in place. + +## Configuration + +Every bay reads the same two files, and nothing else needs touching: ``` -~/.config/opencode-cockpit/config.json → /.cockpit.json → plugin-entry options +~/.config/opencode-cockpit/config.json → /.cockpit.json ``` -```json +The global file applies everywhere (`$XDG_CONFIG_HOME` is honoured); a project's file wins over it, +section by section and key by key, so it can change one setting without restating the rest. A +list replaces the one before it. Both halves of a bay — the agent's tools and the interface — read +the same section, so a bay is configured in one place, not once in `opencode.json` and again in +`tui.json`. Comments and trailing commas are fine. Everything is optional: with no file at all you +get the defaults below. + +**The easy way: `/cockpit-setup`** — or just ask, "make my sidebar quieter", "hide the shells block +when it's empty". The agent loads the `cockpit-setup` skill that ships with Cockpit and reads what +is installed and written now with its `cockpit_settings` tool; it fixes anything from before 0.9 +first, offers a starting point (everything visible, quiet, minimal, or Status as a line under the +prompt), asks only what is left, one question at a time, writes the smallest file that does it, and +checks the result. From the home screen the command opens a conversation; while the agent is +answering it waits its turn. `/status-setup` does the same for what the Status line shows. Both are +in the palette (`ctrl+p`, "cockpit") too. + +### The whole shape + +```jsonc +// ~/.config/opencode-cockpit/config.json — a project's .cockpit.json takes the same shape { - "kinds": { "e2e": "playwright|cypress" }, - "defaults": { "logFile": true, "timeoutSeconds": 900 }, - "ui": { "dockHeight": 16, "historyMinutes": 60 } + "sidebar": ["status", "subagents", "shell", "trail", "trust"], // the order, top to bottom + "features": { "trust": false }, // switch a whole bay off + + "status": { "preset": "sidebar", "sidebarRows": 14 }, + "subagents": { "sidebarRows": 6, "hideWhenEmpty": false, "hideFinishedAfterMinutes": 60 }, + "shell": { "sidebarRows": 5, "dockHeight": 16, "lifecycle": { "onExit": "keep" } }, + "trail": { "sidebar": true, "sidebarRows": 5 }, + "trust": { "sidebar": true, "threshold": 3 }, + "review": { "variant": "right", "source": "worktree" }, + "updater": { "updateCheck": true } } ``` -Later sources win key by key, and an invalid file is ignored rather than fatal. You can categorize -your own commands, define watch rules, cap how long shells live, choose what may interrupt the -agent, and trade context tokens for accuracy. Each feature's README documents its own settings: -[Shell](packages/shell#configuration), [Statusline](packages/status#configuration), -[Updater](packages/updater#settings), [Subagents](packages/subagents#settings), -[Trust](packages/trust#settings). +One section per bay, and nothing at the top level but `sidebar` and `features`. + +### Keys every bay shares + +Spelled the same in every section: + +| Key | | Default | +| --- | --- | --- | +| `enabled` | The bay's off switch, both halves. `features.: false` does the same | `true` | +| `sidebar` | Draw the bay's sidebar block — a boolean here; the top-level `sidebar` is the order. For Status, `false` puts its line at the bottom, under the prompt | `true`; Trust `false` | +| `sidebarRows` | Rows the block lists before the rest fold into `+ N more` | Status 8 (its table 14), Subagents 6, Shell 5, Trail 5, Trust 3 | +| `hideWhenEmpty` | Subagents, Shell and Trail. `false`: with nothing to list the block still says it is there — its heading and `none yet`. `true`: no block at all until there is something | `false` | +| `keybinds` | Keys for the bay's commands, `{ "": "" }`; `"none"` unbinds one | Subagents `d`; Shell `o` dock, `j` console; Trail `f`; Trust `p`; Review `v` open, `k` placement | + +Time keys carry their unit: `hideFinishedAfterMinutes`, `hideNestedAfterSeconds`. + +### The sidebar order + +One list, at the top of either file, and nowhere else: + +```json +{ "sidebar": ["status", "subagents", "shell", "trail", "trust"] } +``` + +That is the default. A project's list replaces the global one (it is an order, not a set), and a bay +the list leaves out keeps its default place after the ones it names. On OpenCode 1 Cockpit's blocks +sit together under OpenCode's own Context block and above the rest. An entry that is not a bay — +`"shells"` — is not silently ignored: a `!` row asks whether you meant `"shell"`. + +On OpenCode 2 the bundle applies the list. **Installed as separate packages on OpenCode 2, the +blocks draw in the order the packages are listed in `cli.json`**, so list them in the order you want +them. + +### Each bay + +**`status`** — the Status bay ([all of it](packages/status#configuration)). + +| Key | | Default | +| --- | --- | --- | +| `preset` | A whole line by name: `sidebar` (the table), `minimal`, `default`, `detailed` (bottom lines). Anything written beside it wins | `sidebar` | +| `surface` | `sidebar` or `bottom`; `"sidebar": false` says the same | `sidebar` | +| `segments` | The line's parts, built-ins or your own — the whole list, replacing the preset's | the preset's | +| `override` | Changes to the preset's segments by name, the rest kept: `false` drops one, a name swaps it, an object merges into its settings — `{ "git": { "against": "branch" } }` | none | +| `lines` | More than one line, each with its own `surface`, `segments`, `maxRows`… | one | +| `separator`, `stack`, `icons`, `debug`, `padding*` | How a line is laid out | per surface | +| `commands` | Shell commands usable as segments — your Claude Code statusline script, unchanged | none | +| `modules` | Your own segments in TypeScript; a project's add to the global ones | none | + +**`subagents`** — [Subagents](packages/subagents#settings). + +| Key | | Default | +| --- | --- | --- | +| `hideFinishedAfterMinutes` | Minutes a finished subagent stays in the sidebar | unset: the whole conversation | +| `hideNestedAfterSeconds` | Seconds a finished *nested* subagent stays; negative keeps them | `30` | +| `guidance` | Tell the agent about background subagents and follow-ups | `true` | + +**`shell`** — [Shell](packages/shell#configuration). + +| Key | | Default | +| --- | --- | --- | +| `kinds` | Your own shell categories, name → regex on the command | none | +| `watch` | `presets` (your own rules) and `auto` (attach one to every shell) | `auto: false` | +| `defaults` | Applied to every shell the agent starts: `watch`, `logFile`, `idleTimeoutSeconds`, `timeoutSeconds`, `notifyOnExit` | none | +| `lifecycle` | `onExit` (`stopMine` or `keep`), `orphanAfterMinutes`, `removeFinishedAfterMinutes` | `stopMine`, `60`, `30` | +| `notify` | What may interrupt the agent: `exit`, `watch`, `tailLines` | on | +| `guidance`, `listRunningShells` | The system-prompt paragraph, and how many running shells it names | `true`, `15` | +| `dockHeight`, `dockOpen`, `defaultView`, `colors` | The panel under the chat and the console | `14`, as last left, `screen`, `true` | +| `hideFinishedAfterMinutes` | How long a finished shell stays in the folded views | `30` | + +**`trail`** — [Trail](packages/trail#settings). Nothing beyond the shared keys; its block is on by +default, and `ctrl+x f` (`cockpit.trail.open`) opens `/trail`. + +**`trust`** — [Trust](packages/trust#settings). + +| Key | | Default | +| --- | --- | --- | +| `threshold` | Approvals in a row, by you, before Trust answers | `3` | +| `dangerExtra` | What a dangerous command costs on top | `5` | +| `expireDays` | Days unused before trust has to be earned again; `0` never | `30` | + +Its block is off by default (`"sidebar": true` shows it; the palette flips it for the session). + +**`review`** — no sidebar block. + +| Key | | Default | +| --- | --- | --- | +| `variant` | Where the panel opens: `right` or `full` | `right` | +| `source` | What it reviews on open: `worktree` (uncommitted) or `branch` | `worktree` | + +**`updater`** — [Updater](packages/updater#settings). + +| Key | | Default | +| --- | --- | --- | +| `updateCheck` | Check for plugin updates once a day and say so | `true` | + +### Names from before 0.9 + +0.9 gave every bay the same shape, so some names changed. **The old ones are not read.** Each one a +file still carries is drawn as a `!` row in its bay's block, printed by +`npx opencode-cockpit@latest doctor`, and fixed first by `/cockpit-setup`: + +``` +! settings: "statusline" is no longer read — run /cockpit-setup +``` + +| Before 0.9 | Now | +| --- | --- | +| `statusline` | `status` | +| `status.maxRows` | `status.sidebarRows` | +| Shell's keys at the file's root (`kinds`, `watch`, `defaults`, `lifecycle`, `notify`, `guidance`, `listRunningShells`) | the same keys under `shell` | +| `ui.dockHeight`, `ui.dockOpen`, `ui.sidebarRows`, `ui.colors`, `ui.keybinds`… | the same keys under `shell` | +| `ui.historyMinutes` | `shell.hideFinishedAfterMinutes` | +| `ui.updateCheck` | `updater.updateCheck` | +| `ui.sidebarOrder`, `.sidebarOrder` | the top-level `sidebar` list | +| `subagents.hideFinishedAfter`, `subagents.hideNestedAfter` | `…Minutes`, `…Seconds` | +| Status's keys at the file's root (`preset`, `segments`, `enabled`…) | the same keys under `status` | + +A file that is not valid JSON, a top-level name nothing reads, or a value of the wrong kind gets a +`!` row too, and the defaults — never a silently blank sidebar. + +### OpenCode's own sidebar blocks + +Status's table carries what OpenCode's own Context block says. To keep only one, switch the host's +off — it is OpenCode's setting, in OpenCode's file, and the name differs by version: + +```jsonc +// OpenCode 1 — ~/.config/opencode/tui.json +{ "plugin_enabled": { "internal:sidebar-context": false } } +``` + +```jsonc +// OpenCode 2 — ~/.config/opencode/cli.json +{ "plugins": ["opencode-cockpit@0.8.0", "-opencode.sidebar.context"] } +``` + +The other blocks switch the same way, by these ids (an `internal:` id in OpenCode 2's `cli.json` +does nothing, silently). Hiding them is a matter of taste: Status's table +already warns when an MCP or language server fails, and `opencode mcp list` still lists them all. + +| Block | OpenCode 1 | OpenCode 2 | +| --- | --- | --- | +| Context | `internal:sidebar-context` | `opencode.sidebar.context` | +| MCP | `internal:sidebar-mcp` | `opencode.sidebar.mcp` | +| Footer (path and branch) | `internal:sidebar-footer` | `opencode.sidebar.footer` | +| LSP | `internal:sidebar-lsp` | — | +| Files | `internal:sidebar-files` | — | +| Todo | `internal:sidebar-todo` | — | + +Leave OpenCode's Todo block on: nothing in Cockpit replaces it. + +### Advanced: options on the plugin entry + +The same keys can also go on the plugin entry — the bundle's `["opencode-cockpit", { "shell": { … } }]` +or a single bay's own `["@opencode-cockpit/shell", { … }]` — where they win over both files. It is +rarely worth it: on OpenCode 1 the interface's options belong in `tui.json` and the agent's in +`opencode.json`, so the same bay ends up configured in two places. The files are read by both. ## Troubleshooting @@ -330,6 +577,7 @@ Each shell's output feeds three views at once: a normalized **log** for the agen | [`@opencode-cockpit/updater`](packages/updater) | Bay 04 — every plugin, and an update checked against disk | [README](packages/updater/README.md) · [docs](https://codestz.github.io/opencode-cockpit/updater/overview/) | | [`@opencode-cockpit/subagents`](packages/subagents) | Bay 05 — every subagent visible, reachable and reused | [README](packages/subagents/README.md) · [docs](https://codestz.github.io/opencode-cockpit/subagents/overview/) | | [`@opencode-cockpit/trust`](packages/trust) | Bay 06 — permissions that learn, visibly | [README](packages/trust/README.md) · [docs](https://codestz.github.io/opencode-cockpit/trust/overview/) | +| [`@opencode-cockpit/trail`](packages/trail) | Bay 07 — what a conversation made, one click from the page | [README](packages/trail/README.md) · [docs](https://codestz.github.io/opencode-cockpit/trail/overview/) | | [`@opencode-cockpit/daemon`](packages/daemon) | `cockpitd`, the shared process host | [README](packages/daemon/README.md) | | [`@opencode-cockpit/client`](packages/client) | Typed, auto-spawning client | [README](packages/client/README.md) | | [`@opencode-cockpit/protocol`](packages/protocol) | Wire contracts and schemas | [README](packages/protocol/README.md) | diff --git a/bun.lock b/bun.lock index 1245fa73..aece04a6 100644 --- a/bun.lock +++ b/bun.lock @@ -5,8 +5,8 @@ "": { "name": "opencode-cockpit-monorepo", "devDependencies": { - "@babel/core": "7.28.0", - "@babel/preset-typescript": "7.27.1", + "@babel/core": "7.29.7", + "@babel/preset-typescript": "7.29.7", "@biomejs/biome": "2.5.14", "@types/bun": "^1.4.2", "@xterm/addon-serialize": "^0.14.0", @@ -19,7 +19,7 @@ "name": "@opencode-cockpit/client", "version": "0.8.0", "dependencies": { - "@opencode-ai/plugin": "1.18.31", + "@opencode-ai/plugin": "1.18.33", "@opencode-cockpit/protocol": "workspace:*", }, "devDependencies": { @@ -45,12 +45,13 @@ "opencode-cockpit": "./dist/bin.js", }, "dependencies": { - "@opencode-ai/plugin": "1.18.31", + "@opencode-ai/plugin": "1.18.33", "@opencode-cockpit/client": "workspace:*", "@opencode-cockpit/review": "workspace:*", "@opencode-cockpit/shell": "workspace:*", "@opencode-cockpit/status": "workspace:*", "@opencode-cockpit/subagents": "workspace:*", + "@opencode-cockpit/trail": "workspace:*", "@opencode-cockpit/trust": "workspace:*", "@opencode-cockpit/updater": "workspace:*", }, @@ -73,7 +74,7 @@ "review": "./dist/cli/preview.js", }, "dependencies": { - "@opencode-ai/plugin": "1.18.31", + "@opencode-ai/plugin": "1.18.33", "@opencode-cockpit/client": "workspace:*", }, "devDependencies": { @@ -86,8 +87,12 @@ "packages/shell": { "name": "@opencode-cockpit/shell", "version": "0.8.0", + "bin": { + "opencode-shell": "./dist/cli/preview.js", + "shell": "./dist/cli/preview.js", + }, "dependencies": { - "@opencode-ai/plugin": "1.18.31", + "@opencode-ai/plugin": "1.18.33", "@opencode-cockpit/client": "workspace:*", "@opencode-cockpit/daemon": "workspace:*", "@opencode-cockpit/protocol": "workspace:*", @@ -107,7 +112,7 @@ "status": "./dist/cli/preview.js", }, "dependencies": { - "@opencode-ai/plugin": "1.18.31", + "@opencode-ai/plugin": "1.18.33", "@opencode-cockpit/client": "workspace:*", }, "devDependencies": { @@ -125,7 +130,25 @@ "subagents": "./dist/cli/preview.js", }, "dependencies": { - "@opencode-ai/plugin": "1.18.31", + "@opencode-ai/plugin": "1.18.33", + "@opencode-cockpit/client": "workspace:*", + }, + "devDependencies": { + "@opentui/core": "0.4.5", + "@opentui/keymap": "0.4.5", + "@opentui/solid": "0.4.5", + "solid-js": "1.9.12", + }, + }, + "packages/trail": { + "name": "@opencode-cockpit/trail", + "version": "0.8.0", + "bin": { + "opencode-trail": "./dist/cli/preview.js", + "trail": "./dist/cli/preview.js", + }, + "dependencies": { + "@opencode-ai/plugin": "1.18.33", "@opencode-cockpit/client": "workspace:*", }, "devDependencies": { @@ -143,7 +166,7 @@ "trust": "./dist/cli/preview.js", }, "dependencies": { - "@opencode-ai/plugin": "1.18.31", + "@opencode-ai/plugin": "1.18.33", "@opencode-cockpit/client": "workspace:*", }, "devDependencies": { @@ -161,7 +184,7 @@ "updater": "./dist/cli/update.js", }, "dependencies": { - "@opencode-ai/plugin": "1.18.31", + "@opencode-ai/plugin": "1.18.33", "@opencode-cockpit/client": "workspace:*", }, "devDependencies": { @@ -181,7 +204,7 @@ "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], - "@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], "@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="], @@ -225,7 +248,7 @@ "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/plugin-syntax-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw=="], - "@babel/preset-typescript": ["@babel/preset-typescript@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ=="], + "@babel/preset-typescript": ["@babel/preset-typescript@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "@babel/plugin-syntax-jsx": "^7.29.7", "@babel/plugin-transform-modules-commonjs": "^7.29.7", "@babel/plugin-transform-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ=="], "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], @@ -253,6 +276,8 @@ "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], @@ -271,9 +296,9 @@ "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], - "@opencode-ai/plugin": ["@opencode-ai/plugin@1.18.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "1.18.31", "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.4.5", "@opentui/keymap": ">=0.4.5", "@opentui/solid": ">=0.4.5" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-Rdc1bPK06PByaGyGd0kf7JUZ4pTkexz2OOUNlqZWLpHOiMEZ+/rFGjt46ypZ3QwA697gNdWwwwQbHbKG5NMwGA=="], + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.18.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "1.18.33", "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.4.5", "@opentui/keymap": ">=0.4.5", "@opentui/solid": ">=0.4.5" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-fmhqCBJvNt+Vfbx6ckKP19S3xwMBhUXLCBQaK92R4wnv3wsWTZYkJQwYf2egiWfJNOl47jhs3vP7uQkl0mvHQw=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.31", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Raouthf8Lhe9edjvYeeSK7SgvdoU6bBjH9qV3f70dHoa6h+z0X2TMz/e22/wKp/StlFUZ4kIRpYYxFnY8/k01w=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.33", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Nyurky9+AA2tvZ6my8UtO5pxPoXxrNypqjeEqMshBGiR542glOy+KE1Y+3lwWU+Z6HC/hSmrdXNbX81+qiuTzg=="], "@opencode-cockpit/client": ["@opencode-cockpit/client@workspace:packages/client"], @@ -289,6 +314,8 @@ "@opencode-cockpit/subagents": ["@opencode-cockpit/subagents@workspace:packages/subagents"], + "@opencode-cockpit/trail": ["@opencode-cockpit/trail@workspace:packages/trail"], + "@opencode-cockpit/trust": ["@opencode-cockpit/trust@workspace:packages/trust"], "@opencode-cockpit/updater": ["@opencode-cockpit/updater@workspace:packages/updater"], @@ -541,6 +568,10 @@ "@opencode-ai/plugin/zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + "@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], + + "@opentui/solid/@babel/preset-typescript": ["@babel/preset-typescript@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ=="], + "@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.12", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.6" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.12" }, "optionalPeers": ["solid-js"] }, "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg=="], "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], diff --git a/media/trail.gif b/media/trail.gif new file mode 100644 index 00000000..c95405d6 Binary files /dev/null and b/media/trail.gif differ diff --git a/media/trail.png b/media/trail.png new file mode 100644 index 00000000..2340c725 Binary files /dev/null and b/media/trail.png differ diff --git a/package.json b/package.json index 2d7dbdb8..38c5df54 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,8 @@ "dev:install": "bun run build && bun scripts/dev-install.ts" }, "devDependencies": { - "@babel/core": "7.28.0", - "@babel/preset-typescript": "7.27.1", + "@babel/core": "7.29.7", + "@babel/preset-typescript": "7.29.7", "@biomejs/biome": "2.5.14", "@types/bun": "^1.4.2", "@xterm/addon-serialize": "^0.14.0", diff --git a/packages/client/README.md b/packages/client/README.md index 0de7a0de..38d7d5aa 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -123,6 +123,13 @@ Subpaths, for bays: | `@opencode-cockpit/client/server` | `ServerHost`, `dualServer`, `composeParts` — the agent half, the same way | | `@opencode-cockpit/client/log` | `createLog`, `Log` — the shared `cockpit.log`; `COCKPIT_DEBUG=1` for detail | | `@opencode-cockpit/client/feature` | the duplicate-load guard on its own | +| `@opencode-cockpit/client/settings` | `baySettings`, `loadSettings` — the one loader every bay reads `config.json` and `.cockpit.json` through, with its `!` notices | +| `@opencode-cockpit/client/catalog` | every setting Cockpit reads, with its type and default, and each bay's default keys and commands | +| `@opencode-cockpit/client/checks` | every notice the bays draw, for `cockpit_settings` and doctor | +| `@opencode-cockpit/client/setup` | `/cockpit-setup`: the `cockpit_settings` and `cockpit_conventions` tools and the `cockpit-setup` skill (in `skills/`) | +| `@opencode-cockpit/client/sidebar` | where a bay's block sits, from the top-level `sidebar` list | +| `@opencode-cockpit/client/design`, `/elements` | the tones, glyphs, key rows and empty blocks every bay draws with | +| `@opencode-cockpit/client/service` | OpenCode 2: whether its background service runs an older Cockpit than this window | ## Requirements diff --git a/packages/client/package.json b/packages/client/package.json index 855ce5bf..fd06f59f 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -47,11 +47,52 @@ "./design": { "types": "./types/design.d.ts", "default": "./dist/design.js" + }, + "./elements": { + "types": "./types/elements.d.ts", + "default": "./dist/elements.js" + }, + "./settings": { + "types": "./types/settings.d.ts", + "default": "./dist/settings.js" + }, + "./catalog": { + "types": "./types/catalog.d.ts", + "default": "./dist/catalog.js" + }, + "./setup": { + "types": "./types/setup.d.ts", + "default": "./dist/setup.js" + }, + "./brief": { + "types": "./types/brief.d.ts", + "default": "./dist/brief.js" + }, + "./jsonc": { + "types": "./types/jsonc.d.ts", + "default": "./dist/jsonc.js" + }, + "./plugin-entries": { + "types": "./types/plugin-entries.d.ts", + "default": "./dist/plugin-entries.js" + }, + "./opener": { + "types": "./types/opener.d.ts", + "default": "./dist/opener.js" + }, + "./checks": { + "types": "./types/checks.d.ts", + "default": "./dist/checks.js" + }, + "./service": { + "types": "./types/service.d.ts", + "default": "./dist/service.js" } }, "files": [ "dist", "types", + "skills", "README.md", "LICENSE" ], @@ -60,7 +101,7 @@ }, "dependencies": { "@opencode-cockpit/protocol": "workspace:*", - "@opencode-ai/plugin": "1.18.31" + "@opencode-ai/plugin": "1.18.33" }, "devDependencies": { "@opencode-cockpit/daemon": "workspace:*", diff --git a/packages/client/skills/cockpit-setup/SKILL.md b/packages/client/skills/cockpit-setup/SKILL.md new file mode 100644 index 00000000..4c07c859 --- /dev/null +++ b/packages/client/skills/cockpit-setup/SKILL.md @@ -0,0 +1,185 @@ +--- +name: cockpit-setup +description: Set up opencode-cockpit ("Cockpit") with the user — which of its bays run, which show a block in OpenCode's sidebar, in what order, how quiet they are when empty — fix settings from before 0.9, and, when they want it, tune Cockpit to how they work (a tour of its keys and the project's conventions (dev server, ticket keys) written to AGENTS.md). Use it whenever the user runs /cockpit-setup or asks to configure, tidy or change Cockpit or its sidebar, even in passing - "make my sidebar quieter", "hide the shells block when it's empty", "move trail above subagents", "turn subagents off", "show trust in the sidebar", "configure cockpit", "tell cockpit our dev server is bun dev", "our tickets are COM-…", or a question about ~/.config/opencode-cockpit/config.json or .cockpit.json. To design what the Status line itself shows, use the status-setup skill instead. +--- + +# Setting up Cockpit + +Cockpit is a set of **bays** that add to OpenCode: `status` (a statusline table), `subagents`, +`shell` (background shells), `trail` (what a conversation made), `trust` (approvals it answers for +you), `review` (a changes pane) and `updater`. Most draw a block in the sidebar. Everything is set in +one JSONC file; your job is to write the smallest correct file for what the person wants, with them. + +People run this once, or when something annoys them. Keep it short: fix what is broken, offer a +starting point, ask only what matters, write, verify. + +## 1. Read the live state first + +Call **`cockpit_settings`** before saying anything about the setup. It answers what is installed and +on, every value and where it came from, the sidebar order, the file paths, every setting that is not +read, and OpenCode's own sidebar blocks — for *this* install. Never guess any of that from memory or +from this skill: versions differ, and the tool reads the same files the bays do. + +If the tool is not there, Cockpit's agent side is not loaded: say so, and point to +`npx opencode-cockpit@latest doctor`. + +## 2. Fix the notices first + +If the tool lists notices, fix them before anything else, in the file each one names: move the value +to the new name and remove the old key (an old order number is just removed — the order is the +top-level `sidebar` list). These names are **not read**, so the value under each is doing nothing +today. Tell the person what you changed in one line each, e.g. `"statusline" → "status"`. Keep their +comments and every other key. + +## 3. Offer a starting point + +Most people want a feel, not twenty keys. Offer these, with the one closest to their current file +pre-selected (no file at all: "Everything visible"). Each is the exact JSON to merge into the file; +keys already written that a preset does not mention stay as they are. + +**Everything visible** — the defaults. Every installed block shows, with `none yet` while empty, so +you can see each bay is there. Nothing to write: + +```json +{} +``` + +**Quiet** — blocks appear only when they have something to show, and list fewer rows: + +```json +{ + "subagents": { "hideWhenEmpty": true, "sidebarRows": 4 }, + "shell": { "hideWhenEmpty": true, "sidebarRows": 3 }, + "trail": { "hideWhenEmpty": true, "sidebarRows": 3 } +} +``` + +**Minimal** — only Status and Trail in the sidebar. Subagents and Shell keep working (their panes, +commands and agent tools); only their blocks go: + +```json +{ + "subagents": { "sidebar": false }, + "shell": { "sidebar": false }, + "trail": { "hideWhenEmpty": true } +} +``` + +**Classic** — Status as one line under the prompt instead of a table in the sidebar: + +```json +{ + "status": { "sidebar": false } +} +``` + +Then ask whether they want to adjust anything. If not, go to step 5. + +## 4. Ask only the questions that matter + +One question at a time, each with the **current value pre-selected** (from `cockpit_settings`, which +shows the default when nothing is written). Skip anything already answered, and never ask about a +bay the tool says is not installed. If you have a `question` tool, use it for multiple choice, the +current value first and marked as current; otherwise ask in plain text with numbered options. + +The two switches people mix up — say which one you mean: + +- **`enabled: false`** turns a bay **off entirely**: no block, no commands, no agent tools. +- **`sidebar: false`** hides **only the block**. The bay keeps working. +- **`hideWhenEmpty: true`** keeps the block but draws nothing until there is something to list. + +Useful questions, in this order, only where they apply: + +1. Which bays they do not want at all → `enabled: false`. +2. When turning a bay **on**: "Show it in the sidebar?" with its default pre-selected (yes for all, + except Trust: no). +3. Status in the sidebar (a table) or under the prompt (a line) → `status.sidebar`. What the line + *shows* is the status-setup skill's job: offer it afterwards, do not design it here. +4. The order of the blocks, top to bottom → the top-level `"sidebar"` list. Only the bays they want to + move need naming; the rest keep their default places after them. +5. For each block: shown with `none yet`, or hidden while empty (`hideWhenEmpty`); rows before it + folds (`sidebarRows`). +6. Which file: the **global** one (every project, the default) or this project's `.cockpit.json` + ("just this repo"). The project file wins key by key. + +Keys, timers, guidance and anything else only if the person brings them up — every key, its type and +default is in [references/settings.md](references/settings.md). Read it before writing a key you +have not seen in the tool's answer. + +## 5. OpenCode's own sidebar blocks + +These are OpenCode's settings in OpenCode's files, so **ask before editing them**, and keep +everything else in the file. `cockpit_settings` lists every block this version has, by its exact id, +with what it is set to now and the exact edit — use those; never guess an id +(`"-internal:sidebar-context"` does nothing on OpenCode 2). + +| Block | OpenCode 1 (`tui.json` → `"plugin_enabled": { "": false }`) | OpenCode 2 (`cli.json` → `"-"` in `"plugins"`) | What to say | +| --- | --- | --- | --- | +| Context | `internal:sidebar-context` | `opencode.sidebar.context` | with Status's table in the sidebar, "Context" shows twice: suggest turning OpenCode's off | +| MCP | `internal:sidebar-mcp` | `opencode.sidebar.mcp` | optional, neutral: Status's table already warns when a server fails; `opencode mcp list` shows them all | +| Footer | `internal:sidebar-footer` | `opencode.sidebar.footer` | optional, neutral: the project's path and git branch at the bottom of the sidebar | +| LSP | `internal:sidebar-lsp` | — | optional, neutral | +| Files | `internal:sidebar-files` | — | optional, neutral | +| Todo | `internal:sidebar-todo` | — | **never suggest turning it off** — nothing in Cockpit replaces it. Off: offer it back | + +On OpenCode 1 that is `{ "plugin_enabled": { "internal:sidebar-context": false } }`; on OpenCode 2, +add `"-opencode.sidebar.context"` to the `"plugins"` list, keeping the entries already there. + +## 6. Write the file + +- Write **JSONC** to the file they chose, creating it (and its folder) if needed: the global file by + default, `/.cockpit.json` for this project only. The tool gives both paths. +- Write **only keys that differ from the defaults**. A key set to its default is noise that hides the + ones that matter, and freezes a default that a later release may improve. +- Merge into what is there: keep comments, keep keys you were not asked about. +- One section per bay (`"shell": { … }`); the order is the top-level `"sidebar"` list, never a key + inside a bay. + +## 7. Verify + +Call `cockpit_settings` again. It must say **`Notices: none`**, and the bays must read the way the +person asked (on/off, block shown or hidden, the order). If a notice appears, you wrote something +the bays do not read: fix it and call again. + +## 8. Close + +Tell them, briefly: + +- what changed, key by key, and in which file; +- **it applies after restarting OpenCode** — settings are read when it starts; +- how to undo: remove those keys (or the file) and restart; `/cockpit-setup` again any time; +- to design what the Status line shows, `/status-setup`. + +Then offer the second phase in one line: **"Want me to tune it to how you work?"** If not, stop +there. Someone who only wanted a quieter sidebar is done. + +## 9. Make it fit how they work (only if they said yes) + +Call `cockpit_settings` with **`tune: true`**. It adds the tour, this project's long-running +commands, ticket keys and remotes, and what each `AGENTS.md`'s Cockpit section says now. + +1. **The tour.** One line per bay that is on, from the tool: what it does for them, with its real key + and command. No more; they asked for a tour, not a manual. +2. **Ask about the project**, one question at a time, pre-filled from the tool's answer, only for + the bays they have on: + - **Shell** — which commands keep running (dev server, `docker compose up`, a test watcher)? Offer + the ones the tool found, and a name for each ("dev server", "database"): the shell's + description, which is how it is found again. + - **Trail** — their ticket system and key prefix (offer the prefixes the history shows), the repos + PRs go to, how they name things. + - **Subagents** — explore in background subagents and keep the conversation free, or not. +3. **Write conventions only.** Every request already tells the agent how to use each bay; never + write how to use Cockpit, only what no bay can know: *this* project's commands, keys, repos. The + lines and their shape are in [references/conventions.md](references/conventions.md) — read it + before drafting. +4. **Choose the file and ask.** This project's `AGENTS.md` (the default for commands; the team gets it + if committed) or OpenCode's global one (every project — for a ticket prefix or a habit they use + everywhere). Show the exact section and the file, and ask. If the tool says creating the file + would stop OpenCode 1 reading a `CLAUDE.md`, say so first. +5. **Write it with `cockpit_conventions`** (`file`, `conventions` = the section's lines without its + heading). It replaces the one marked section in place on a rerun and keeps everything else in the + file byte for byte. Never edit the section by hand. +6. **Close:** what was written where; it applies to new conversations; `cockpit_conventions` with + empty `conventions` removes it. + +A rerun of this phase starts from the section the tool shows: change what they ask, keep the rest. diff --git a/packages/client/skills/cockpit-setup/references/conventions.md b/packages/client/skills/cockpit-setup/references/conventions.md new file mode 100644 index 00000000..6e591118 --- /dev/null +++ b/packages/client/skills/cockpit-setup/references/conventions.md @@ -0,0 +1,81 @@ +# Cockpit conventions: what goes in the section + +The section is a project's (or a person's) **conventions**, in the agent's terms: which command is the +dev server, what a ticket key looks like. It never explains how to use a bay — every request already +carries each bay's guidance, and a second copy in AGENTS.md only goes stale. + +Short imperative lines, one fact each, grouped by bay with a bold label. Only the bays they have on, +only what they told you or confirmed from the tool's answer. Hand `cockpit_conventions` the lines +below the heading: the tool adds the heading and the markers. + +**The examples below are shapes, not content.** Every `<…>` is theirs to fill; a line whose blank +they did not fill is left out. Never carry over an example's ticket system, repo or branch pattern. + +## Shell — the commands that keep running + +One line per long-running command: the exact command, the description that names the shell (the +agent and the person find it again by that), and when to start it: + +```markdown +**Shells** +- Start the dev server with `` as a background shell described "dev server". Reuse it if it is already running; never start a second one. +- Run the tests with `` as a background shell described "test watcher". Read its output instead of running the suite again. +- Start the database with `` as a background shell described "database", before anything that needs it. +``` + +Add `with watch: true` to a line when they want to hear when a run passes or fails: the Shell picks a +matching rule for 35 common tools (vite, next, vitest, jest, tsc, playwright, docker-compose, cargo, +go…). Only for a tool none of them fits, offer a rule of their own in Cockpit's settings — the +`shell.watch.presets` key, `{ "": { "done": "", "fail": "" } }`, written with the +rest of the settings file — and name it in the line: `with watch: "e2e"`. + +## Trail — tickets, repos, names + +```markdown +**Trail** +- Tickets are `-` keys, like `-123`. When a PR, branch or page is for a ticket, put its key in `for`. +- Pull requests go to ``. +- Name branches ``. +``` + +The `for` line is the one that matters: it is what groups the trail by ticket. Several prefixes +go in one line. Name the ticket system (Jira, Linear…) only if they did. Leave out a line they have +no answer for — a branch pattern above all, which few people state. + +## Subagents — how they like work split + +Only when they have a preference; the default guidance already covers how to launch and wait: + +```markdown +**Subagents** +- Explore the codebase in background subagents and keep this conversation free for me. +``` + +or + +```markdown +**Subagents** +- Do not use subagents unless I ask; work in this conversation. +``` + +## A whole section + +For someone who said "the dev server and the test watcher keep running, tickets are ENG-": + +```markdown +**Shells** +- Start the dev server with `bun run dev` as a background shell described "dev server". Reuse it if it is already running. +- Run the tests with `bun run test:watch` as a background shell described "test watcher", with watch: true. + +**Trail** +- Tickets are `ENG-` keys, like `ENG-412`. When a PR is for a ticket, put its key in `for`. +``` + +The commands came from the tool's answer (`package.json` scripts), confirmed with them — never ask +for a command the tool already found; offer it. + +## Not here + +- How to use a bay's tools (`shell_start`, `trail_add`, `subagents_wait`): already in every request. +- Cockpit's settings (sidebar, keys, `hideWhenEmpty`): those go in Cockpit's settings file. +- Anything they did not say. A guessed convention is worse than none. diff --git a/packages/client/skills/cockpit-setup/references/settings.md b/packages/client/skills/cockpit-setup/references/settings.md new file mode 100644 index 00000000..80d2fd3f --- /dev/null +++ b/packages/client/skills/cockpit-setup/references/settings.md @@ -0,0 +1,148 @@ + + +# Cockpit settings reference + +Two files, both JSONC (comments and trailing commas are fine), the same shape: + +- global, every project: `~/.config/opencode-cockpit/config.json` (`$XDG_CONFIG_HOME/opencode-cockpit/config.json` when that is set) +- one project: `/.cockpit.json`, which wins over the global file key by key + +Plugin-entry options win over both, but put settings in the files: on OpenCode 1 a plugin entry's +options are split across `opencode.json` and `tui.json`. `cockpit_settings` gives the exact paths. +Settings are read when OpenCode starts: a change applies after a restart. + +## The top level + +| Key | Type | Default | What it does | +| --- | --- | --- | --- | +| `sidebar` | list of `"status"`, `"subagents"`, `"shell"`, `"trail"`, `"trust"` | `["status","subagents","shell","trail","trust"]` | the order of the sidebar blocks, top to bottom. A bay left out keeps its default place after the named ones. A project's list replaces the global one. Only an order: it turns nothing on or off | +| `features` | `{ "": false }` | all on | turns a bay off, like its `enabled: false` | +| `""` | object | | one section per bay: `status`, `subagents`, `shell`, `trail`, `trust`, `review`, `updater` | + +## `enabled` and `sidebar` are different switches + +- `enabled: false` turns the bay off: no block, no commands, no agent tools. Use it for a bay you do not want at all. +- `sidebar: false` hides only the bay's block. The bay keeps working: its commands, panes and agent tools stay. + For Status, `"sidebar": false` moves its line under the prompt (`"surface": "bottom"`). +- `hideWhenEmpty: true` keeps the block but draws nothing while there is nothing to list. + +## `status` — the statusline: context, tokens, spend, git — a table in the sidebar, or a line under the prompt + +| Key | Type | Default | What it does | +| --- | --- | --- | --- | +| `enabled` | boolean | `true` | the bay runs at all, both halves. `false` turns it off entirely: no block, no commands, no tools | +| `sidebar` | boolean | true (the table in the sidebar) | draw the bay's block in the sidebar. Only the block: with `false` the bay still runs, its commands and tools still work | +| `sidebarRows` | number | 14 with the `sidebar` preset, else 8 | rows the block lists before the rest fold into `+ N more` | +| `surface` | "sidebar" \| "bottom" | "sidebar" | where the line draws; `"sidebar": false` says `"bottom"` too | +| `preset` | string | the surface's own: `sidebar` in the sidebar, `default` at the bottom | a whole line by name; anything written beside it wins. The `status-setup` skill has them all | +| `segments` | list | the preset's | the line's parts, built-ins or your own — the whole list, replacing the preset's. To change a row or two, `override` | +| `override` | object | none | changes to the preset's segments by name, the rest kept: `false` drops one, a name swaps it, an object merges into its settings — `{ "git": { "against": "branch" } }` | +| `lines` | list | one line | more than one line, each with its own `surface`, `segments`, `maxRows`… | +| `separator` | string | `" │ "` across, nothing down | drawn between segments | +| `stack` | "horizontal" \| "vertical" | vertical in the sidebar | segments across or down | +| `icons` | boolean | `true` | built-in icons; off for a terminal missing the glyphs | +| `debug` | boolean | `false` | draw a placeholder where a segment said nothing | +| `paddingLeft` | number | 3 at the bottom, 0 in the sidebar | columns of space left of the line | +| `paddingRight` | number | 2 at the bottom, 0 in the sidebar | columns of space right of the line | +| `paddingTop` | number | 0 | rows of space above the line | +| `paddingBottom` | number | 1 at the bottom, 0 in the sidebar | rows of space below the line | +| `commands` | object | none | shell commands usable as segments — a Claude Code statusline script works unchanged | +| `modules` | string[] | none | your own segments in TypeScript; a project's add to the global ones | + +## `subagents` — the subagents a conversation launched, live + +| Key | Type | Default | What it does | +| --- | --- | --- | --- | +| `enabled` | boolean | `true` | the bay runs at all, both halves. `false` turns it off entirely: no block, no commands, no tools | +| `sidebar` | boolean | `true` | draw the bay's block in the sidebar. Only the block: with `false` the bay still runs, its commands and tools still work | +| `sidebarRows` | number | `6` | rows the block lists before the rest fold into `+ N more` | +| `hideWhenEmpty` | boolean | `false` | `true`: no block at all while there is nothing to list. `false`: the heading and `none yet`, so you can see the bay is there | +| `keybinds` | object | `cockpit.subagents.open`: `d` | keys for its commands, `{ "": "" }` | +| `hideFinishedAfterMinutes` | number | unset: kept for the conversation | minutes a finished subagent stays in the sidebar (still reachable from `/subagents`) | +| `hideNestedAfterSeconds` | number | `30` | seconds a finished nested subagent stays in the sidebar; negative keeps them | +| `guidance` | boolean | `true` | tell the agent how to follow, wait on and read its subagents (system prompt) | + +## `shell` — background shells the agent started, with a dock under the chat and a console + +| Key | Type | Default | What it does | +| --- | --- | --- | --- | +| `enabled` | boolean | `true` | the bay runs at all, both halves. `false` turns it off entirely: no block, no commands, no tools | +| `sidebar` | boolean | `true` | draw the bay's block in the sidebar. Only the block: with `false` the bay still runs, its commands and tools still work | +| `sidebarRows` | number | `5` | rows the block lists before the rest fold into `+ N more` | +| `hideWhenEmpty` | boolean | `false` | `true`: no block at all while there is nothing to list. `false`: the heading and `none yet`, so you can see the bay is there | +| `keybinds` | object | `cockpit.shells.dock`: `o`, `cockpit.shells.console`: `j` | keys for its commands, `{ "": "" }` | +| `hideFinishedAfterMinutes` | number | `30` | minutes a finished shell stays in the folded views | +| `dockHeight` | number | `14` | rows of the shells panel under the chat | +| `dockOpen` | boolean | as you last left it | the panel starts open | +| `defaultView` | "screen" \| "log" | `"screen"` | what the console opens on: the live screen or the clean log | +| `colors` | boolean | `true` | paint the colours programs print | +| `guidance` | boolean | `true` | tell the agent how to use shells (system prompt, ~120 tokens) | +| `listRunningShells` | number | `15` | running shells named in the system prompt each turn; `0` off | +| `lifecycle` | object | `onExit: "stopMine"`, `orphanAfterMinutes: 60`, `removeFinishedAfterMinutes: 30` | when shells end: `onExit` (`"stopMine"` or `"keep"`, which survives a restart) and the two timers | +| `defaults` | object | none | applied to every shell the agent starts: `watch`, `logFile`, `idleTimeoutSeconds`, `timeoutSeconds`, `notifyOnExit` | +| `notify` | object | `exit: true`, `watch: true`, `tailLines: 15` | what may interrupt the agent | +| `watch` | object | `auto: false` | health watching: `presets` (your own rules), `auto` (attach one to every shell) | +| `kinds` | object | none | your own shell categories, name → regular expression on the command | + +## `trail` — what a conversation made or changed: PRs, branches, issues, links + +| Key | Type | Default | What it does | +| --- | --- | --- | --- | +| `enabled` | boolean | `true` | the bay runs at all, both halves. `false` turns it off entirely: no block, no commands, no tools | +| `sidebar` | boolean | `true` | draw the bay's block in the sidebar. Only the block: with `false` the bay still runs, its commands and tools still work | +| `sidebarRows` | number | `5` | rows the block lists before the rest fold into `+ N more` | +| `hideWhenEmpty` | boolean | `false` | `true`: no block at all while there is nothing to list. `false`: the heading and `none yet`, so you can see the bay is there | +| `keybinds` | object | `cockpit.trail.open`: `f` | keys for its commands, `{ "": "" }` | + +## `trust` — what Trust answered for you instead of asking; its block is off by default + +| Key | Type | Default | What it does | +| --- | --- | --- | --- | +| `enabled` | boolean | `true` | the bay runs at all, both halves. `false` turns it off entirely: no block, no commands, no tools | +| `sidebar` | boolean | `false` | draw the bay's block in the sidebar. Only the block: with `false` the bay still runs, its commands and tools still work | +| `sidebarRows` | number | `3` | rows the block lists before the rest fold into `+ N more` | +| `keybinds` | object | `cockpit.trust.ledger`: `p` | keys for its commands, `{ "": "" }` | +| `threshold` | number | `3` | approvals in a row, by you, before Trust answers | +| `dangerExtra` | number | `5` | what a dangerous command costs on top | +| `expireDays` | number | `30` | days unused before trust has to be earned again; `0` never | + +## `review` — the pane for reviewing changes; no sidebar block + +| Key | Type | Default | What it does | +| --- | --- | --- | --- | +| `enabled` | boolean | `true` | the bay runs at all, both halves. `false` turns it off entirely: no block, no commands, no tools | +| `keybinds` | object | `cockpit.review.open`: `v`, `cockpit.review.place`: `k` | keys for its commands, `{ "": "" }` | +| `variant` | "right" \| "full" | `"right"` | where the pane opens | +| `source` | "worktree" \| "branch" | `"worktree"` | what it reviews on open: uncommitted work, or the whole branch | + +## `updater` — checks for plugin updates once a day; no sidebar block + +| Key | Type | Default | What it does | +| --- | --- | --- | --- | +| `enabled` | boolean | `true` | the bay runs at all, both halves. `false` turns it off entirely: no block, no commands, no tools | +| `updateCheck` | boolean | `true` | check for plugin updates once a day and say so | + +## Names from before 0.9 + +Not read at all. Each one found is a notice in `cockpit_settings` and a `!` row in its bay. In the +same file, move the value to the new name and remove the old one: + +| Old | New | +| --- | --- | +| `statusline` | `status` | +| `status.maxRows` | `status.sidebarRows` | +| `watch` | `shell.watch` | +| `kinds` | `shell.kinds` | +| `defaults` | `shell.defaults` | +| `lifecycle` | `shell.lifecycle` | +| `notify` | `shell.notify` | +| `guidance` | `shell.guidance` | +| `listRunningShells` | `shell.listRunningShells` | +| `ui.` | `shell.` | +| `ui.historyMinutes` | `shell.hideFinishedAfterMinutes` | +| `ui.updateCheck` | `updater.updateCheck` | +| `ui.sidebarOrder` | the top-level `sidebar` list (remove it) | +| `.sidebarOrder` | the top-level `sidebar` list (remove it) | +| `subagents.hideFinishedAfter` | `subagents.hideFinishedAfterMinutes` | +| `subagents.hideNestedAfter` | `subagents.hideNestedAfterSeconds` | +| Status keys at the file's root (`preset`, `segments`, `enabled`…) | the same keys under `status` | diff --git a/packages/client/src/brief.ts b/packages/client/src/brief.ts new file mode 100644 index 00000000..8efc9dd1 --- /dev/null +++ b/packages/client/src/brief.ts @@ -0,0 +1,66 @@ +/** Sending a line to the agent from the interface, on either OpenCode. */ + +import type { Host } from "./host.ts" + +/** The session on screen, if there is one. */ +function sessionOnScreen(host: Host): string | undefined { + const route = host.route.current + return route.name === "session" + ? (route.params as { sessionID?: string } | undefined)?.sessionID + : undefined +} + +/** + * Hands `text` to the agent from wherever the person is, with a toast saying it did — the effect can + * land somewhere the screen is not showing yet. Measured on 1.18.32 and 2.0.18: + * + * - in a conversation, idle: sent, and the turn starts; + * - the agent busy: queued behind the running turn, shown as queued in the conversation; + * - home, no conversation: v1's prompt starts one when submitted; v2 gets one made and opened here. + * + * On the next tick, because running a slash command clears the prompt it was typed into: anything + * written during the command itself is wiped a moment later. + */ +export function briefAgent(host: Host, text: string, title: string, done = "Asked the agent."): void { + setTimeout(() => { + const failed = (error?: unknown) => { + host.log.warn("setup: could not reach the agent", { error }) + host.ui.toast({ variant: "error", title, message: "Could not reach the agent." }) + } + const sent = () => host.ui.toast({ variant: "info", title, message: done }) + if (host.v1) { + const tui = host.v1.client.tui + void tui + .appendPrompt({ text }) + .then(() => tui.submitPrompt()) + .then(sent) + .catch(failed) + return + } + void toConversation(host, text).then( + (where) => (where ? sent() : host.ui.toast({ title, message: "Open a conversation first." })), + failed, + ) + }, 0) +} + +/** OpenCode 2: the conversation on screen, or a new one opened for it from home. */ +async function toConversation(host: Host, text: string): Promise { + const session = host.v2?.data.session + let id = sessionOnScreen(host) + if (!id) { + const created = session?.create?.({}) + if (!created) return undefined + await created.request + host.v2?.ui.router.navigate?.({ type: "session", sessionID: created.id }) + id = created.id + } + /** + * Busy, it waits its turn. v2's default hands a message to the turn that is running ("steer"), + * which cut a reply off mid-sentence to start on this; queued, the reply finishes and the line + * shows as `1 queued` under the conversation — what v1 does with a prompt submitted while busy. + */ + const busy = session?.status?.(id) === "running" + await session?.prompt?.({ sessionID: id, text, ...(busy ? { delivery: "queue" as const } : {}) }) + return id +} diff --git a/packages/client/src/catalog.ts b/packages/client/src/catalog.ts new file mode 100644 index 00000000..5baec239 --- /dev/null +++ b/packages/client/src/catalog.ts @@ -0,0 +1,472 @@ +/** + * Every setting Cockpit reads, with its type, its default and a line on what it does: the one list + * that `cockpit_settings` resolves against and that the `cockpit-setup` skill's reference is written + * from (`references/settings.md`, `bun packages/client/src/cli/reference.ts` writes it). + * + * The shared keys and their defaults come from the loader itself (`SHARED_DEFAULTS`). Each bay's own + * keys live in that bay's package, which this one cannot import, so they are listed here — and a test + * in `packages/opencode`, which depends on every bay, fails when a bay's own defaults and this list + * disagree. The skill's reference fails a test of its own when it is not what `settingsReference()` + * writes, so neither can rot without a red test. + */ + +import { BAYS, type Bay, OLD_NAMES, SHARED_DEFAULTS, type SharedSettings, SIDEBAR_BAYS } from "./settings.ts" + +export interface KeyInfo { + key: string + /** As a reader writes it: `boolean`, `number`, `"sidebar" | "bottom"`, `string[]`… */ + type: string + /** What the bay uses when nothing sets it. `undefined` is "unset", which the bay reads as such. */ + default: unknown + /** The default in words, where a value alone would mislead (`14 with the sidebar preset, else 8`). */ + defaultText?: string + about: string +} + +/** What each shared key does. Typed by the interface, so a key added there and not here fails to build. */ +export const SHARED_ABOUT: Readonly> = { + enabled: "the bay runs at all, both halves. `false` turns it off entirely: no block, no commands, no tools", + keybinds: 'keys for its commands, `{ "": "" }`', + sidebar: + "draw the bay's block in the sidebar. Only the block: with `false` the bay still runs, its commands and tools still work", + sidebarRows: "rows the block lists before the rest fold into `+ N more`", + hideWhenEmpty: + "`true`: no block at all while there is nothing to list. `false`: the heading and `none yet`, so you can see the bay is there", +} + +const SHARED_TYPE: Readonly> = { + enabled: "boolean", + keybinds: "object", + sidebar: "boolean", + sidebarRows: "number", + hideWhenEmpty: "boolean", +} + +/** What a bay is, in a line. */ +export const BAY_ABOUT: Readonly> = { + status: "the statusline: context, tokens, spend, git — a table in the sidebar, or a line under the prompt", + subagents: "the subagents a conversation launched, live", + shell: "background shells the agent started, with a dock under the chat and a console", + trail: "what a conversation made or changed: PRs, branches, issues, links", + trust: "what Trust answered for you instead of asking; its block is off by default", + review: "the pane for reviewing changes; no sidebar block", + updater: "checks for plugin updates once a day; no sidebar block", +} + +/** Which shared keys each bay reads. Status has no `hideWhenEmpty` (it always has rows), Trust neither. */ +const SHARED_KEYS: Readonly> = { + status: ["enabled", "sidebar", "sidebarRows"], + subagents: ["enabled", "sidebar", "sidebarRows", "hideWhenEmpty", "keybinds"], + shell: ["enabled", "sidebar", "sidebarRows", "hideWhenEmpty", "keybinds"], + trail: ["enabled", "sidebar", "sidebarRows", "hideWhenEmpty", "keybinds"], + trust: ["enabled", "sidebar", "sidebarRows", "keybinds"], + review: ["enabled", "keybinds"], + updater: ["enabled"], +} + +/** + * The keys each bay's commands take, by default — each bay's interface binds exactly these + * (`defaultKeys`). Leader-prefixed and few; `` is OpenCode's own prefix, `ctrl+x` by default. + * Every one is free on both OpenCodes (1.18.32's and 2.0.18's defaults) and in Cockpit: on 2 `w` + * closes a tab and `i` shows image attachments, and `r` is redo on both. + */ +export const DEFAULT_KEYS: Readonly>>>> = { + subagents: { "cockpit.subagents.open": "d" }, + shell: { "cockpit.shells.dock": "o", "cockpit.shells.console": "j" }, + trail: { "cockpit.trail.open": "f" }, + /** `p`, for permissions. */ + trust: { "cockpit.trust.ledger": "p" }, + review: { "cockpit.review.open": "v", "cockpit.review.place": "k" }, +} + +/** A bay's default keys, by command: none for a bay without commands. */ +export const defaultKeys = (bay: Bay): Readonly> => DEFAULT_KEYS[bay] ?? {} + +/** One thing a person can do with a bay: its command's key (from `DEFAULT_KEYS` and `keybinds`) and slash name. */ +export interface BayCommand { + /** The command's id, whose key `keybinds` sets — none for a command with no key. */ + command?: string + /** Typed in the prompt, without the `/`. */ + slash?: string + does: string +} + +/** + * What each bay offers a person, for the tour `/cockpit-setup` gives: the commands worth knowing, not + * every one (Shell has eight; the panes list their own keys under `?`). Read off each bay's interface + * entry; the agent-side commands (`/status-setup`, `/cockpit-setup`) are its server's. + */ +export const BAY_COMMANDS: Readonly> = { + status: [{ slash: "status-setup", does: "design what the line shows, with the agent" }], + subagents: [ + { + command: "cockpit.subagents.open", + slash: "subagents", + does: "follow each subagent live, message it, stop it, move it to the background", + }, + ], + shell: [ + { + command: "cockpit.shells.dock", + slash: "shells-dock", + does: "show or hide the shells panel under the chat", + }, + { + command: "cockpit.shells.console", + slash: "shell", + does: "open the console: a shell's live screen and log", + }, + { slash: "shells", does: "pick a shell, or start one yourself" }, + ], + trail: [ + { + command: "cockpit.trail.open", + slash: "trail", + does: "what this conversation made, or every conversation's", + }, + { slash: "link", does: "add a link to the trail yourself" }, + ], + trust: [{ command: "cockpit.trust.ledger", slash: "trust", does: "what Trust answers for you, and why" }], + review: [ + { + command: "cockpit.review.open", + slash: "changes", + does: "the diff, with comments on its lines; `s` hands them to the agent", + }, + { command: "cockpit.review.place", does: "the changes full screen" }, + ], + updater: [{ slash: "plugins-update", does: "update every plugin (OpenCode 1)" }], +} + +/** + * Each bay's own keys. Defaults must equal what the bay hands `baySettings` — tested in + * `packages/opencode/test/catalog.test.ts` against every bay's own `DEFAULTS`. + */ +export const OWN_KEYS: Readonly> = { + status: [ + { + key: "surface", + type: '"sidebar" | "bottom"', + default: undefined, + defaultText: '"sidebar"', + about: 'where the line draws; `"sidebar": false` says `"bottom"` too', + }, + { + key: "preset", + type: "string", + default: undefined, + defaultText: "the surface's own: `sidebar` in the sidebar, `default` at the bottom", + about: "a whole line by name; anything written beside it wins. The `status-setup` skill has them all", + }, + { + key: "segments", + type: "list", + default: undefined, + defaultText: "the preset's", + about: + "the line's parts, built-ins or your own — the whole list, replacing the preset's. To change a row or two, `override`", + }, + { + key: "override", + type: "object", + default: undefined, + defaultText: "none", + about: + 'changes to the preset\'s segments by name, the rest kept: `false` drops one, a name swaps it, an object merges into its settings — `{ "git": { "against": "branch" } }`', + }, + { + key: "lines", + type: "list", + default: undefined, + defaultText: "one line", + about: "more than one line, each with its own `surface`, `segments`, `maxRows`…", + }, + { + key: "separator", + type: "string", + default: undefined, + defaultText: '`" │ "` across, nothing down', + about: "drawn between segments", + }, + { + key: "stack", + type: '"horizontal" | "vertical"', + default: undefined, + defaultText: "vertical in the sidebar", + about: "segments across or down", + }, + { + key: "icons", + type: "boolean", + default: true, + about: "built-in icons; off for a terminal missing the glyphs", + }, + { + key: "debug", + type: "boolean", + default: false, + about: "draw a placeholder where a segment said nothing", + }, + { + key: "paddingLeft", + type: "number", + default: undefined, + defaultText: "3 at the bottom, 0 in the sidebar", + about: "columns of space left of the line", + }, + { + key: "paddingRight", + type: "number", + default: undefined, + defaultText: "2 at the bottom, 0 in the sidebar", + about: "columns of space right of the line", + }, + { + key: "paddingTop", + type: "number", + default: undefined, + defaultText: "0", + about: "rows of space above the line", + }, + { + key: "paddingBottom", + type: "number", + default: undefined, + defaultText: "1 at the bottom, 0 in the sidebar", + about: "rows of space below the line", + }, + { + key: "commands", + type: "object", + default: undefined, + defaultText: "none", + about: "shell commands usable as segments — a Claude Code statusline script works unchanged", + }, + { + key: "modules", + type: "string[]", + default: undefined, + defaultText: "none", + about: "your own segments in TypeScript; a project's add to the global ones", + }, + ], + subagents: [ + { + key: "hideFinishedAfterMinutes", + type: "number", + default: undefined, + defaultText: "unset: kept for the conversation", + about: "minutes a finished subagent stays in the sidebar (still reachable from `/subagents`)", + }, + { + key: "hideNestedAfterSeconds", + type: "number", + default: 30, + about: "seconds a finished nested subagent stays in the sidebar; negative keeps them", + }, + { + key: "guidance", + type: "boolean", + default: true, + about: "tell the agent how to follow, wait on and read its subagents (system prompt)", + }, + ], + shell: [ + { + key: "hideFinishedAfterMinutes", + type: "number", + default: 30, + about: "minutes a finished shell stays in the folded views", + }, + { key: "dockHeight", type: "number", default: 14, about: "rows of the shells panel under the chat" }, + { + key: "dockOpen", + type: "boolean", + default: undefined, + defaultText: "as you last left it", + about: "the panel starts open", + }, + { + key: "defaultView", + type: '"screen" | "log"', + default: "screen", + about: "what the console opens on: the live screen or the clean log", + }, + { key: "colors", type: "boolean", default: true, about: "paint the colours programs print" }, + { + key: "guidance", + type: "boolean", + default: true, + about: "tell the agent how to use shells (system prompt, ~120 tokens)", + }, + { + key: "listRunningShells", + type: "number", + default: 15, + about: "running shells named in the system prompt each turn; `0` off", + }, + { + key: "lifecycle", + type: "object", + default: {}, + defaultText: '`onExit: "stopMine"`, `orphanAfterMinutes: 60`, `removeFinishedAfterMinutes: 30`', + about: + 'when shells end: `onExit` (`"stopMine"` or `"keep"`, which survives a restart) and the two timers', + }, + { + key: "defaults", + type: "object", + default: {}, + defaultText: "none", + about: + "applied to every shell the agent starts: `watch`, `logFile`, `idleTimeoutSeconds`, `timeoutSeconds`, `notifyOnExit`", + }, + { + key: "notify", + type: "object", + default: {}, + defaultText: "`exit: true`, `watch: true`, `tailLines: 15`", + about: "what may interrupt the agent", + }, + { + key: "watch", + type: "object", + default: {}, + defaultText: "`auto: false`", + about: "health watching: `presets` (your own rules), `auto` (attach one to every shell)", + }, + { + key: "kinds", + type: "object", + default: {}, + defaultText: "none", + about: "your own shell categories, name → regular expression on the command", + }, + ], + trail: [], + trust: [ + { + key: "threshold", + type: "number", + default: 3, + about: "approvals in a row, by you, before Trust answers", + }, + { key: "dangerExtra", type: "number", default: 5, about: "what a dangerous command costs on top" }, + { + key: "expireDays", + type: "number", + default: 30, + about: "days unused before trust has to be earned again; `0` never", + }, + ], + review: [ + { key: "variant", type: '"right" | "full"', default: "right", about: "where the pane opens" }, + { + key: "source", + type: '"worktree" | "branch"', + default: "worktree", + about: "what it reviews on open: uncommitted work, or the whole branch", + }, + ], + updater: [ + { + key: "updateCheck", + type: "boolean", + default: true, + about: "check for plugin updates once a day and say so", + }, + ], +} + +/** Status's block holds up to 14 rows with its `sidebar` preset; the loader's 8 is any other column's. */ +const SHARED_DEFAULT_TEXT: Readonly>>>> = { + status: { sidebarRows: "14 with the `sidebar` preset, else 8", sidebar: "true (the table in the sidebar)" }, +} + +/** Every key a bay reads, shared first, with its default. */ +export function bayKeys(bay: Bay): KeyInfo[] { + const shared = SHARED_KEYS[bay].map((key): KeyInfo => { + const text = SHARED_DEFAULT_TEXT[bay]?.[key] + const keys = DEFAULT_KEYS[bay] + return { + key, + type: SHARED_TYPE[key], + default: key === "keybinds" ? (keys ?? {}) : SHARED_DEFAULTS[bay][key], + ...(text ? { defaultText: text } : {}), + about: SHARED_ABOUT[key], + } + }) + return [...shared, ...OWN_KEYS[bay]] +} + +/** A default as a reader reads it: `true`, `8`, `"right"`, or the words for one that is unset. */ +export function shownDefault(info: Pick): string { + if (info.defaultText) return info.defaultText + const value = info.default + if (value === undefined) return "unset" + if (typeof value === "object" && value !== null && Object.keys(value).length === 0) return "none" + if (typeof value === "object" && value !== null) + return Object.entries(value) + .map(([key, each]) => `\`${key}\`: \`${each}\``) + .join(", ") + return `\`${JSON.stringify(value)}\`` +} + +// ── The skill's reference ────────────────────────────────────────────────────────────────────── + +/** `references/settings.md` of the `cockpit-setup` skill, exactly. */ +export function settingsReference(): string { + const table = (bay: Bay) => [ + "| Key | Type | Default | What it does |", + "| --- | --- | --- | --- |", + ...bayKeys(bay).map( + (info) => + `| \`${info.key}\` | ${info.type.replaceAll("|", "\\|")} | ${shownDefault(info).replaceAll("|", "\\|")} | ${info.about} |`, + ), + ] + return [ + "", + "", + "# Cockpit settings reference", + "", + "Two files, both JSONC (comments and trailing commas are fine), the same shape:", + "", + "- global, every project: `~/.config/opencode-cockpit/config.json` (`$XDG_CONFIG_HOME/opencode-cockpit/config.json` when that is set)", + "- one project: `/.cockpit.json`, which wins over the global file key by key", + "", + "Plugin-entry options win over both, but put settings in the files: on OpenCode 1 a plugin entry's", + "options are split across `opencode.json` and `tui.json`. `cockpit_settings` gives the exact paths.", + "Settings are read when OpenCode starts: a change applies after a restart.", + "", + "## The top level", + "", + "| Key | Type | Default | What it does |", + "| --- | --- | --- | --- |", + `| \`sidebar\` | list of ${SIDEBAR_BAYS.map((bay) => `\`"${bay}"\``).join(", ")} | \`${JSON.stringify([...SIDEBAR_BAYS])}\` | the order of the sidebar blocks, top to bottom. A bay left out keeps its default place after the named ones. A project's list replaces the global one. Only an order: it turns nothing on or off |`, + `| \`features\` | \`{ "": false }\` | all on | turns a bay off, like its \`enabled: false\` |`, + `| \`""\` | object | | one section per bay: ${BAYS.map((bay) => `\`${bay}\``).join(", ")} |`, + "", + "## `enabled` and `sidebar` are different switches", + "", + "- `enabled: false` turns the bay off: no block, no commands, no agent tools. Use it for a bay you do not want at all.", + "- `sidebar: false` hides only the bay's block. The bay keeps working: its commands, panes and agent tools stay.", + ' For Status, `"sidebar": false` moves its line under the prompt (`"surface": "bottom"`).', + "- `hideWhenEmpty: true` keeps the block but draws nothing while there is nothing to list.", + "", + ...BAYS.flatMap((bay) => [`## \`${bay}\` — ${BAY_ABOUT[bay]}`, "", ...table(bay), ""]), + "## Names from before 0.9", + "", + "Not read at all. Each one found is a notice in `cockpit_settings` and a `!` row in its bay. In the", + "same file, move the value to the new name and remove the old one:", + "", + "| Old | New |", + "| --- | --- |", + ...OLD_NAMES.map((name) => + name.new === "sidebar" + ? `| \`${name.old}\` | the top-level \`sidebar\` list (remove it) |` + : `| \`${name.old}\` | \`${name.new}\` |`, + ), + "| Status keys at the file's root (`preset`, `segments`, `enabled`…) | the same keys under `status` |", + "", + ].join("\n") +} diff --git a/packages/client/src/checks.ts b/packages/client/src/checks.ts new file mode 100644 index 00000000..cf7913c4 --- /dev/null +++ b/packages/client/src/checks.ts @@ -0,0 +1,70 @@ +/** + * Every notice the bays draw, for what reports on the settings without being a bay: `cockpit_settings` + * and doctor. "Notices: none" there has to mean no `!` row in any block after a restart. + * + * The loader knows the files, the old names and the shared keys; it does not know a bay's own words. + * So each bay's notices come from the bay: its keys' kinds through the loader with its defaults (the + * catalog's, which a test holds equal to every bay's own), and — where a bay checks more than kinds, + * as Status does its presets, surfaces and overrides — its own check, offered here the way a bay + * offers its preview. Shared on `globalThis`: the bundle and a standalone package each carry a copy. + * + * Node APIs only: doctor runs this under `npx`. + */ + +import { bayKeys } from "./catalog.ts" +import { BAYS, type Bay, baySettings, type Settings, type SettingsNotice } from "./settings.ts" + +/** A bay's own check: every notice it draws for these settings and its plugin options. Pure. */ +export type SettingsCheck = (input: { settings: Settings; options?: unknown }) => SettingsNotice[] + +const CHECKS = Symbol.for("opencode-cockpit.settings-checks") +const registry = (): Map => { + const shared = globalThis as { [CHECKS]?: Map } + shared[CHECKS] ??= new Map() + return shared[CHECKS] +} + +/** A bay offers its check: Status's agent side does, and the bundle's doctor. */ +export function offerSettingsCheck(bay: Bay, check: SettingsCheck): void { + registry().set(bay, check) +} + +/** One notice once: the loader's are in every bay's list too. */ +export function uniqueNotices(notices: readonly SettingsNotice[]): SettingsNotice[] { + const seen = new Set() + return notices.filter((notice) => { + const key = `${notice.bay}\0${notice.file}\0${notice.text}` + if (seen.has(key)) return false + seen.add(key) + return true + }) +} + +/** Every notice one bay draws: the loader's for it, its keys' kinds, and its own check's. */ +export function bayNotices( + bay: Bay, + settings: Settings, + options?: unknown, + check: SettingsCheck | undefined = registry().get(bay), +): SettingsNotice[] { + const defaults = Object.fromEntries(bayKeys(bay).map((info) => [info.key, info.default])) + const kinds = baySettings(bay, defaults, { settings, ...(options !== undefined ? { options } : {}) }) + let own: SettingsNotice[] = [] + try { + own = check?.({ settings, ...(options !== undefined ? { options } : {}) }) ?? [] + } catch { + /** A check that throws says nothing: the report is worth more than one bay's word. */ + } + return uniqueNotices([...kinds.notices, ...own]) +} + +/** Every notice: the ones that belong to no bay, then each bay's. `options` by bay, as each entry has them. */ +export function everyNotice( + settings: Settings, + options: Partial> = {}, +): SettingsNotice[] { + return uniqueNotices([ + ...settings.notices, + ...BAYS.flatMap((bay) => bayNotices(bay, settings, options[bay])), + ]) +} diff --git a/packages/client/src/cli/preview.ts b/packages/client/src/cli/preview.ts new file mode 100644 index 00000000..a5c4c7b2 --- /dev/null +++ b/packages/client/src/cli/preview.ts @@ -0,0 +1,110 @@ +#!/usr/bin/env bun + +/** + * The see-it loop for what every bay shares: an empty sidebar block, and the `!` row a settings + * notice draws, at a narrow and a wide column. Colours from OpenCode's default theme. + * + * bun packages/client/src/cli/preview.ts + * bun packages/client/src/cli/preview.ts --width 28 + * bun packages/client/src/cli/preview.ts --config ./my.json notices for a config file of yours + * bun packages/client/src/cli/preview.ts --settings what the cockpit_settings tool answers + */ + +import { readFileSync } from "node:fs" +import { emptyBlock, GLYPH, type ToneRun } from "../design.ts" +import { loadSettings, noticeText, type SettingsNotice } from "../settings.ts" +import { settingsReport, settingsText } from "../setup.ts" + +const HEX: Record = { + text: "#eeeeee", + muted: "#808080", + accent: "#9d7cd8", + warning: "#f5a742", + error: "#e06c75", +} +const color = process.stdout.isTTY && !process.env.NO_COLOR +const rgb = (hex: string) => [1, 3, 5].map((i) => Number.parseInt(hex.slice(i, i + 2), 16)).join(";") + +function paint(row: readonly ToneRun[]): string { + if (!color) return row.map((run) => run.text).join("") + return row + .map( + (run) => + `\x1b[38;2;${rgb(HEX[run.tone ?? "text"] ?? "#eeeeee")}${run.bold ? ";1" : ""}m${run.text}\x1b[0m`, + ) + .join("") +} + +/** A notice as a bay draws it: `!` in the warning tone, then the sentence, cut to the column. */ +function noticeRow(notice: SettingsNotice, width: number): ToneRun[] { + const said = noticeText(notice) + const room = width - 2 + const text = said.length > room ? `${said.slice(0, Math.max(0, room - 1))}${GLYPH.more}` : said.padEnd(room) + return [ + { text: `${GLYPH.warn} `, tone: "warning" }, + { text, tone: "muted" }, + ] +} + +const arg = (name: string) => { + const at = process.argv.indexOf(name) + return at > 0 ? process.argv[at + 1] : undefined +} +const widths = arg("--width") ? [Number(arg("--width"))] : [24, 36] +const config = arg("--config") +const sample = { + statusline: { preset: "sidebar" }, + shell: { sidebarOrder: 170 }, + sidebar: ["status", "shells", "trust"], +} +const settings = loadSettings({ + env: {}, + home: "/nowhere", + directory: "/project", + read: (path) => + path.endsWith(".cockpit.json") + ? config + ? readFileSync(config, "utf8") + : JSON.stringify(sample) + : undefined, +}) + +/** + * `--settings`: what `cockpit_settings` answers, for the sample (or your) config, with the bundle + * installed. `--opencode 1` for OpenCode 1's own blocks; your own OpenCode files are not read. + */ +if (process.argv.includes("--settings")) { + const opencode = arg("--opencode") === "1" ? 1 : 2 + const claims = new Map( + ["shell", "status", "review", "subagents", "trail"].map((bay) => [bay, "opencode-cockpit"]), + ) + const report = settingsReport({ + opencode, + directory: "/project", + claims, + env: {}, + home: "/home/me", + read: (path) => { + if (path.endsWith(".cockpit.json")) + return config ? readFileSync(config, "utf8") : JSON.stringify(sample) + if (path === "/home/me/.config/opencode/opencode.json") + return JSON.stringify({ [opencode === 1 ? "plugin" : "plugins"]: ["opencode-cockpit@0.9.0"] }) + return undefined + }, + }) + console.log(settingsText(report)) + process.exit(0) +} + +for (const width of widths) { + const ruler = `── ${width} columns `.padEnd(width, "─") + console.log(ruler) + for (const title of ["Subagents", "Shells", "Trail"]) { + for (const row of emptyBlock(title, width)) console.log(`${paint(row)}│`) + console.log("") + } + console.log(`(hideWhenEmpty: ${emptyBlock("Shells", width, true).length} rows)`) + console.log("") + for (const notice of settings.notices) console.log(`${paint(noticeRow(notice, width))}│`) + console.log("") +} diff --git a/packages/client/src/cli/reference.ts b/packages/client/src/cli/reference.ts new file mode 100644 index 00000000..a0d4e905 --- /dev/null +++ b/packages/client/src/cli/reference.ts @@ -0,0 +1,17 @@ +#!/usr/bin/env bun + +/** + * Writes the `cockpit-setup` skill's settings reference from the code, so it cannot drift from what + * the bays read. `test/catalog.test.ts` fails when the file on disk is not what this writes. + * + * bun packages/client/src/cli/reference.ts + */ + +import { writeFileSync } from "node:fs" +import { join } from "node:path" +import { settingsReference } from "../catalog.ts" +import { SETUP_SKILL_DIR } from "../setup.ts" + +const file = join(SETUP_SKILL_DIR, "references", "settings.md") +writeFileSync(file, settingsReference()) +console.log(`wrote ${file}`) diff --git a/packages/client/src/conventions.ts b/packages/client/src/conventions.ts new file mode 100644 index 00000000..9cf42671 --- /dev/null +++ b/packages/client/src/conventions.ts @@ -0,0 +1,403 @@ +/** + * The second half of `/cockpit-setup`: making Cockpit fit how a person works. What the agent needs + * for it, read fresh — what this project runs that never ends, which ticket keys its history uses, + * where its pull requests go, what the instruction files say now — and the one write it makes: a + * marked `## Cockpit conventions` section in an `AGENTS.md`. + * + * Conventions only. How to use Cockpit is already in every request's system prompt (each bay's + * guidance); what a project's own instructions add is the part no bay can know — "the dev server is + * `bun dev`", "tickets are COM-…". + * + * The section is written here rather than by the agent's edit tool because it has to be the same + * section every time: a rerun replaces it in place, everything around it is kept byte for byte, and a + * model asked to "update the section" in someone's instructions file is one rewrite away from + * tidying the rest. + */ + +import { execFileSync } from "node:child_process" +import { join } from "node:path" + +// ── The section ──────────────────────────────────────────────────────────────────────────────── + +export const SECTION_START = + "" +export const SECTION_END = "" +export const SECTION_HEADING = "## Cockpit conventions" + +/** Matches a start marker however its tail was edited: the prefix is what marks it. */ +const START = //g +const END = //g + +export interface Section { + /** Offsets of the start marker and just past the end marker. */ + start: number + end: number + /** What is between the heading and the end marker, trimmed. */ + body: string +} + +export type Sections = { ok: true; sections: Section[] } | { ok: false; line: number } + +const lineAt = (text: string, offset: number) => text.slice(0, offset).split("\n").length + +/** Every marked section in a file, in order. A start with no end is an error naming its line. */ +export function findSections(text: string): Sections { + const sections: Section[] = [] + const ends = [...text.matchAll(END)] + for (const start of text.matchAll(START)) { + const from = start.index ?? 0 + if (sections.some((section) => from < section.end)) continue + const end = ends.find((each) => (each.index ?? 0) > from) + if (!end) return { ok: false, line: lineAt(text, from) } + const stop = (end.index ?? 0) + end[0].length + const inner = text.slice(from + start[0].length, end.index).trim() + const body = inner.startsWith(SECTION_HEADING) ? inner.slice(SECTION_HEADING.length).trim() : inner + sections.push({ start: from, end: stop, body }) + } + return { ok: true, sections } +} + +/** The body as the agent hands it, without a heading of its own (the section brings one). */ +export function cleanBody(body: string): string { + const text = body.replaceAll("\r\n", "\n").trim() + return text.startsWith(SECTION_HEADING) ? text.slice(SECTION_HEADING.length).trim() : text +} + +export function sectionText(body: string, eol = "\n"): string { + return [SECTION_START, SECTION_HEADING, "", ...cleanBody(body).split("\n"), SECTION_END].join(eol) +} + +export type WriteAction = "created" | "added" | "updated" | "unchanged" | "removed" | "absent" + +export type Written = + | { ok: true; action: WriteAction; text: string | undefined; merged: number } + | { ok: false; error: string } + +/** Cuts `[start, end)` and the blank line that set it apart, so removing a section undoes adding it. */ +function cut(text: string, start: number, end: number): string { + let from = start + while (from > 0 && (text[from - 1] === "\n" || text[from - 1] === "\r")) from-- + const before = text.slice(0, from) + let after = text.slice(end) + if (from === 0) after = after.replace(/^\r?\n/, "") + return before.length > 0 && after.length === 0 ? `${before}\n` : before + after +} + +/** + * The file with its section set to `body`: replaced where it is, added at the end where there is + * none, removed when `body` is empty. Everything outside the section is kept as it was. Several + * sections (two runs that raced, a paste) become one, where the first was. `text` undefined is a file + * that does not exist; `text: undefined` back means delete it — it held nothing but the section. + */ +export function writeSection(text: string | undefined, body: string): Written { + const content = cleanBody(body) + const found = findSections(text ?? "") + if (!found.ok) + return { + ok: false, + error: `the Cockpit section that starts at line ${found.line} has no end marker. Add \`${SECTION_END}\` on its own line where the section ends (or remove the start marker), then call this again.`, + } + /** CRLF only for a file written that way throughout; a stray `\r\n` in a LF file is not a style. */ + const eol = text?.includes("\r\n") && !/(^|[^\r])\n/.test(text) ? "\r\n" : "\n" + const [first, ...extra] = found.sections + if (!first) { + if (!content) return { ok: true, action: "absent", text, merged: 0 } + if (text === undefined || text.trim() === "") + return { + ok: true, + action: text === undefined ? "created" : "added", + text: `${sectionText(content, eol)}${eol}`, + merged: 0, + } + const sep = text.endsWith("\n") ? eol : `${eol}${eol}` + return { ok: true, action: "added", text: `${text}${sep}${sectionText(content, eol)}${eol}`, merged: 0 } + } + let out = text as string + for (const section of [...extra].reverse()) out = cut(out, section.start, section.end) + if (!content) { + out = cut(out, first.start, first.end) + return { ok: true, action: "removed", text: out.trim() === "" ? undefined : out, merged: extra.length } + } + out = out.slice(0, first.start) + sectionText(content, eol) + out.slice(first.end) + const action = extra.length === 0 && first.body === content && out === text ? "unchanged" : "updated" + return { ok: true, action, text: out, merged: extra.length } +} + +// ── Where it goes ────────────────────────────────────────────────────────────────────────────── + +export type InstructionScope = "project" | "global" + +export interface InstructionFile { + scope: InstructionScope + path: string + exists: boolean + /** The section's body when there is one; several are reported as a count. */ + sections: number + body?: string + /** The line of a start marker with no end. */ + unclosed?: number + /** + * OpenCode 1 reads the first file it finds of a list and stops there: `AGENTS.md` before the + * project's `CLAUDE.md` (and `CONTEXT.md`), the global `AGENTS.md` before `~/.claude/CLAUDE.md`. + * Read off 1.18.32's instruction loader. So creating this file stops OpenCode 1 reading that one. + */ + shadows?: string +} + +export function instructionPaths( + directory: string, + env: Readonly>, + home: string, +): Record { + return { + project: join(directory, "AGENTS.md"), + global: join(env.XDG_CONFIG_HOME || join(home, ".config"), "opencode", "AGENTS.md"), + } +} + +export function readInstructions( + opencode: 1 | 2, + directory: string, + env: Readonly>, + home: string, + read: (path: string) => string | undefined, +): InstructionFile[] { + const paths = instructionPaths(directory, env, home) + const fallbacks: Record = { + project: [join(directory, "CLAUDE.md"), join(directory, "CONTEXT.md")], + global: [join(home, ".claude", "CLAUDE.md")], + } + return (["project", "global"] as const).map((scope) => { + const path = paths[scope] + const text = read(path) + const found = findSections(text ?? "") + const shadows = + opencode === 1 && text === undefined + ? fallbacks[scope].find((file) => read(file) !== undefined) + : undefined + return { + scope, + path, + exists: text !== undefined, + sections: found.ok ? found.sections.length : 0, + ...(found.ok && found.sections[0] ? { body: found.sections[0].body } : {}), + ...(found.ok ? {} : { unclosed: found.line }), + ...(shadows ? { shadows } : {}), + } + }) +} + +// ── The project ──────────────────────────────────────────────────────────────────────────────── + +/** A command that does not end on its own: a dev server, a watcher, `docker compose up`. */ +export interface LongRunning { + command: string + /** Where it was found, as the person would recognise it: `package.json "dev": "vite"`. */ + from: string + /** A short name for the shell: the script's or the target's. */ + name: string +} + +export interface ProjectFacts { + /** `bun`, `pnpm`, `yarn` or `npm`, from the lockfile; undefined with no package.json. */ + packageManager?: string + longRunning: LongRunning[] + /** The package.json scripts not counted as long-running. */ + otherScripts: string[] + /** Ticket prefixes seen in branch names and recent commit subjects, most used first. */ + tickets: { prefix: string; count: number; example: string }[] + /** Where pull requests go: each remote as `host/owner/repo`, credentials removed. */ + remotes: { name: string; repo: string }[] +} + +const LONG_NAME = + /^(dev|start|serve|server|watch|preview|storybook)$|[:_-](dev|watch|serve|server)$|^(dev|watch|serve)[:_-]/ +const LONG_COMMAND = + /--watch\b|(^|\s)watch(\s|$)|\bnodemon\b|\bvite\b(?!\s+build)|\bnext (dev|start)\b|\bnuxt dev\b|\bastro dev\b|\bstorybook dev\b|\bwebpack serve\b|\bdocker[- ]compose up\b|\bwrangler dev\b|\bng serve\b|\bexpo start\b|\btsx watch\b|\bbun --hot\b|\bbun --watch\b/ + +const isLong = (name: string, command: string) => LONG_NAME.test(name) || LONG_COMMAND.test(command) + +const LOCKFILES: [string, string][] = [ + ["bun.lock", "bun"], + ["bun.lockb", "bun"], + ["pnpm-lock.yaml", "pnpm"], + ["yarn.lock", "yarn"], + ["package-lock.json", "npm"], +] + +const runScript = (manager: string, script: string) => + manager === "yarn" ? `yarn ${script}` : `${manager} run ${script}` + +function packageScripts( + directory: string, + read: (path: string) => string | undefined, +): Pick { + const text = read(join(directory, "package.json")) + if (text === undefined) return { longRunning: [], otherScripts: [] } + let scripts: Record = {} + let declared: string | undefined + try { + const parsed = JSON.parse(text) as { scripts?: Record; packageManager?: unknown } + scripts = parsed.scripts ?? {} + if (typeof parsed.packageManager === "string") declared = parsed.packageManager.split("@")[0] + } catch { + return { longRunning: [], otherScripts: [] } + } + const manager = + LOCKFILES.find(([file]) => read(join(directory, file)) !== undefined)?.[1] ?? declared ?? "npm" + const longRunning: LongRunning[] = [] + const otherScripts: string[] = [] + for (const [name, command] of Object.entries(scripts)) { + if (typeof command !== "string") continue + if (isLong(name, command)) + longRunning.push({ + command: runScript(manager, name), + from: `package.json "${name}": "${command}"`, + name, + }) + else otherScripts.push(name) + } + return { packageManager: manager, longRunning, otherScripts } +} + +/** `make dev`, `make watch`…: targets named for a server or a watcher, or whose recipe is one. */ +function makeTargets(directory: string, read: (path: string) => string | undefined): LongRunning[] { + const text = read(join(directory, "Makefile")) ?? read(join(directory, "makefile")) + if (text === undefined) return [] + const out: LongRunning[] = [] + const lines = text.split("\n") + lines.forEach((line, at) => { + const target = /^([A-Za-z][\w.-]*)\s*:(?!=)/.exec(line)?.[1] + if (!target || target.startsWith(".")) return + const recipe: string[] = [] + for (const next of lines.slice(at + 1)) { + if (!next.startsWith("\t")) break + recipe.push(next.trim()) + } + if (isLong(target, recipe.join(" "))) + out.push({ command: `make ${target}`, from: `Makefile target "${target}"`, name: target }) + }) + return out +} + +const COMPOSE_FILES = ["compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"] + +function composeUp(directory: string, read: (path: string) => string | undefined): LongRunning[] { + for (const file of COMPOSE_FILES) { + const text = read(join(directory, file)) + if (text === undefined) continue + const services: string[] = [] + let inServices = false + for (const line of text.split("\n")) { + if (/^\S/.test(line)) inServices = /^services:\s*$/.test(line) + else if (inServices) { + const name = /^ {2}([\w.-]+):\s*$/.exec(line)?.[1] + if (name) services.push(name) + } + } + const named = services.length > 0 ? ` (services: ${services.join(", ")})` : "" + return [{ command: "docker compose up", from: `${file}${named}`, name: "compose" }] + } + return [] +} + +function procfile(directory: string, read: (path: string) => string | undefined): LongRunning[] { + return ["Procfile.dev", "Procfile"].flatMap((file) => + (read(join(directory, file)) ?? "").split("\n").flatMap((line) => { + const match = /^([\w-]+):\s*(.+)$/.exec(line.trim()) + return match + ? [{ command: (match[2] as string).trim(), from: `${file} "${match[1]}"`, name: match[1] as string }] + : [] + }), + ) +} + +/** Looks like a ticket key but is a standard or an encoding. */ +const NOT_TICKETS = new Set([ + "UTF", + "ISO", + "SHA", + "RFC", + "HTTP", + "TLS", + "CVE", + "ES", + "IE", + "MD", + "AES", + "RSA", + "WCAG", + "PEP", +]) + +export function ticketPrefixes(lines: readonly string[]): ProjectFacts["tickets"] { + const seen = new Map() + for (const line of lines) { + for (const match of line.matchAll(/\b([A-Z][A-Z0-9]{1,9})-(\d{1,6})\b/g)) { + const prefix = match[1] as string + if (NOT_TICKETS.has(prefix)) continue + const entry = seen.get(prefix) ?? { count: 0, example: match[0] } + entry.count++ + seen.set(prefix, entry) + } + } + return [...seen.entries()] + .map(([prefix, { count, example }]) => ({ prefix, count, example })) + .filter((ticket) => ticket.count >= 2) + .sort((a, b) => b.count - a.count) + .slice(0, 5) +} + +/** `git@github.com:acme/app.git`, `https://user:tok@github.com/acme/app` → `github.com/acme/app`. */ +export function repoOf(url: string): string { + const scp = /^[\w.-]+@([^:/]+):(.+)$/.exec(url) + const path = scp ? `${scp[1]}/${scp[2]}` : url.replace(/^[a-z+]+:\/\//i, "").replace(/^[^@/]*@/, "") + return path.replace(/\.git$/, "").replace(/\/+$/, "") +} + +/** Runs git in the project, or says nothing. */ +export type GitRun = (args: string[]) => string | undefined + +export function gitIn(directory: string): GitRun { + return (args) => { + try { + return execFileSync("git", args, { + cwd: directory, + encoding: "utf8", + timeout: 3000, + stdio: ["ignore", "pipe", "ignore"], + }) + } catch { + return undefined + } + } +} + +export function projectFacts( + directory: string, + read: (path: string) => string | undefined, + git: GitRun = gitIn(directory), +): ProjectFacts { + const scripts = packageScripts(directory, read) + const history = [ + ...(git(["branch", "--all", "--format=%(refname:short)"]) ?? "").split("\n"), + ...(git(["log", "-200", "--format=%s"]) ?? "").split("\n"), + ] + const remotes = new Map() + for (const line of (git(["remote", "-v"]) ?? "").split("\n")) { + const [name, url] = line.split(/\s+/) + if (name && url && !remotes.has(name)) remotes.set(name, repoOf(url)) + } + return { + ...(scripts.packageManager ? { packageManager: scripts.packageManager } : {}), + longRunning: [ + ...scripts.longRunning, + ...makeTargets(directory, read), + ...composeUp(directory, read), + ...procfile(directory, read), + ], + otherScripts: scripts.otherScripts, + tickets: ticketPrefixes(history), + remotes: [...remotes.entries()].map(([name, repo]) => ({ name, repo })), + } +} diff --git a/packages/client/src/design.ts b/packages/client/src/design.ts index c2cd24cb..56c7997e 100644 --- a/packages/client/src/design.ts +++ b/packages/client/src/design.ts @@ -228,6 +228,94 @@ export const moreText = (count: number): string => `+ ${count} more` /** What an expanded block offers to fold back. */ export const FEWER_TEXT = "− fewer" +/** + * What a block with nothing to list says, muted, in the row its first item will take. + * + * Presence over silence. "Say nothing when there is nothing" was the rule, and it left a new user + * unable to tell whether Shells and Subagents were installed at all — so a block draws its heading + * and this one row from the start, and `hideWhenEmpty: true` brings the silence back for anyone who + * wants it. The row sits where the first item goes, so empty → one item replaces it instead of + * pushing every block below down a row (measured on both OpenCodes, docs/opencode/trail-interface.md). + */ +export const EMPTY_TEXT = "none yet" + +const cells = (runs: readonly ToneRun[]) => runs.reduce((sum, run) => sum + run.text.length, 0) + +/** + * A block's heading, exactly `width` wide, then its `HEADING_GAP` rows of air: the name bold on the + * left, the summary flush right. A summary with no room left beside the name is left out, and a name + * wider than the column is cut with `…`. + */ +export function headingRows(title: string, summary: readonly ToneRun[], width: number): ToneRun[][] { + const room = Math.max(0, width) + const name = title.length > room ? `${title.slice(0, Math.max(0, room - 1))}…`.slice(0, room) : title + const right = cells(summary) + 1 <= room - name.length ? summary : [] + const gap = room - name.length - cells(right) + const heading: ToneRun[] = [ + { text: name, ...HEADING }, + ...(gap > 0 ? [{ text: " ".repeat(gap) }] : []), + ...right, + ] + return [heading, ...Array.from({ length: HEADING_GAP }, () => [{ text: " ".repeat(room) }])] +} + +/** + * A block with nothing in it: its heading and one muted `none yet` row — as tall as the block with + * one single-row item — or no rows at all when the user asked for `hideWhenEmpty`. + */ +export function emptyBlock(title: string, width: number, hideWhenEmpty = false): ToneRun[][] { + if (hideWhenEmpty) return [] + const room = Math.max(0, width) + const text = EMPTY_TEXT.slice(0, room) + return [ + ...headingRows(title, [], room), + [{ text, tone: "muted" }, ...(room > text.length ? [{ text: " ".repeat(room - text.length) }] : [])], + ] +} + +/** + * A warning a block says about itself — a settings notice — as rows exactly `width` wide: `!` in the + * warning tone, then the words, wrapped at spaces with the rest indented under the first word. A + * sidebar is 24–36 cells and the sentence is wider, so it wraps rather than losing its last words + * (the fix it names). At most `maxRows`; the last one ends in `…` when there was more. + */ +export function warnRows(text: string, width: number, maxRows = 5): ToneRun[][] { + const room = Math.max(0, width) + const prefix = `${GLYPH.warn} ` + const indent = " ".repeat(prefix.length) + const room1 = Math.max(1, room - prefix.length) + const lines: string[] = [] + let line = "" + for (const word of text.split(/\s+/).filter(Boolean)) { + const next = line ? `${line} ${word}` : word + if (next.length <= room1 || !line) line = next + else { + lines.push(line) + line = word + } + } + if (line) lines.push(line) + const shown = lines.slice(0, Math.max(1, maxRows)) + const cut = lines.length > shown.length + return shown.map((words, i) => { + let body = + words.length > room1 || (cut && i === shown.length - 1) + ? `${words.slice(0, room1 - 1)}${GLYPH.more}` + : words + body = body.slice(0, room1) + const lead: ToneRun = i === 0 ? { text: prefix, tone: "warning" } : { text: indent } + const used = Math.min(room, prefix.length + body.length) + return [ + lead, + { text: body, tone: "warning" }, + ...(room > used ? [{ text: " ".repeat(room - used) }] : []), + ] + }) +} + +/** Whether a block draws at all: always, unless it is empty and asked to hide then. */ +export const blockShown = (count: number, hideWhenEmpty = false): boolean => count > 0 || !hideWhenEmpty + /** * How long something ran, in the fewest characters that still read: `4s`, `51s`, `2m04s`, `1h12m`. * diff --git a/packages/client/src/elements.tsx b/packages/client/src/elements.tsx index 88c611ea..1c9030b6 100644 --- a/packages/client/src/elements.tsx +++ b/packages/client/src/elements.tsx @@ -1,5 +1,7 @@ /** @jsxImportSource @opentui/solid */ +import type { ColorInput } from "@opentui/core" import type { JSX } from "solid-js" +import { EMPTY_TEXT } from "./design.ts" /** * Plain text as an element OpenCode 1 can place in one of its dialogs. @@ -10,3 +12,15 @@ import type { JSX } from "solid-js" * dialog, the first to pass a plain description. */ export const textElement = (text: string) => (): JSX.Element => {text} + +/** + * The `none yet` row of an empty sidebar block (`EMPTY_TEXT` in design.ts says why it is there), in + * the theme's muted colour. Draw it in the slot of the first item — as the fallback of the block's + * `` over its rows — so the first item takes its place and the blocks below do not move. One + * row, never wrapped: a wrapped empty line would be taller than the item that replaces it. + */ +export const EmptyRow = (props: { fg: ColorInput }): JSX.Element => ( + + {EMPTY_TEXT} + +) diff --git a/packages/client/src/feature.ts b/packages/client/src/feature.ts index 09751951..11eeb43c 100644 --- a/packages/client/src/feature.ts +++ b/packages/client/src/feature.ts @@ -50,6 +50,14 @@ export function claimFeature(scope: object, feature: string, source: string): Fe } } +/** + * Every feature claimed in a scope, and the copy that owns it — `{ shell: "opencode-cockpit" }`. What + * is actually loaded in this window, bundle or standalone, read by `/cockpit-setup` when it runs. + */ +export function claimedFeatures(scope: object): ReadonlyMap { + return new Map(registry().get(scope) ?? []) +} + export function duplicateFeatureMessage(feature: string, owner: string, skipped: string): string { return `${feature} is configured twice (${owner} and ${skipped}). Using ${owner}; remove one of them from your OpenCode config.` } diff --git a/packages/client/src/host.ts b/packages/client/src/host.ts index cb102c2a..a157dbff 100644 --- a/packages/client/src/host.ts +++ b/packages/client/src/host.ts @@ -16,6 +16,8 @@ import type { CliRenderer, KeyEvent } from "@opentui/core" import { createComponent, createRoot, getOwner, type JSX, type Owner, onCleanup } from "solid-js" import { textElement } from "./elements.tsx" import { cockpitVersion, createLog, type Log, silentLog } from "./log.ts" +import { registerServiceCheck } from "./service.ts" +import { registerSetup } from "./setup/palette.ts" type V1Layer = Parameters[0] export type Layer = V1Layer @@ -286,7 +288,16 @@ export interface V2Context { readonly mcp?: { readonly server: { list(location?: unknown): unknown[] | undefined } } } readonly session: { - prompt?(input: unknown): Promise + /** + * A message to a conversation. `delivery` (2.0.18): `"steer"`, the default, hands it to the turn + * that is running; `"queue"` waits for that turn to end. + */ + prompt?(input: { sessionID: string; text: string; delivery?: "steer" | "queue" }): Promise + /** A new conversation, here unless `location` says otherwise; `request` settles once it exists. */ + create?(input: { location?: { directory: string }; title?: string }): { + id: string + request: Promise + } get?(sessionID: string): unknown status?(sessionID: string): unknown sync?(sessionID: string): Promise @@ -325,7 +336,11 @@ export interface V2Context { readonly toast: { show(options: { title?: string; message: string; variant?: string; duration?: number }): void } - readonly router: { current(): { type: string; sessionID?: string } } + readonly router: { + current(): { type: string; sessionID?: string } + /** Present on 2.0.18: `{ type: "session", sessionID }` or `{ type: "home" }`. */ + navigate?(route: { type: string; sessionID?: string }): void + } slot(claim: { render: (input: never) => JSX.Element } & Record): () => void } } @@ -710,6 +725,18 @@ export function dualTui(id: string, start: Start) { opencodeVersion: opencode, cockpit: cockpitVersion(), }) + /** `/cockpit-setup`: every entry offers it, the first in a window registers it (setup.ts). */ + try { + registerSetup(host, id) + } catch (error) { + host.log.warn("setup: not registered", { entry: id, error }) + } + /** OpenCode 2: one toast when the background service still runs an older Cockpit (service.ts). */ + try { + registerServiceCheck(host, id) + } catch (error) { + host.log.warn("service: not checked", { entry: id, error }) + } try { await start(host, options) } catch (error) { diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index e43e53b6..13935c9f 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -5,5 +5,5 @@ export { compareBuilds, type OutdatedDaemon, } from "./client.ts" -export { claimFeature, duplicateFeatureMessage, type FeatureClaim } from "./feature.ts" +export { claimedFeatures, claimFeature, duplicateFeatureMessage, type FeatureClaim } from "./feature.ts" export type { SpawnOptions } from "./spawn.ts" diff --git a/packages/updater/src/core/jsonc.ts b/packages/client/src/jsonc.ts similarity index 75% rename from packages/updater/src/core/jsonc.ts rename to packages/client/src/jsonc.ts index e13a9984..185abf3c 100644 --- a/packages/updater/src/core/jsonc.ts +++ b/packages/client/src/jsonc.ts @@ -1,8 +1,12 @@ /** - * Reads `opencode.jsonc`: JSON plus comments and trailing commas. + * JSON plus comments and trailing commas, which is what people write in a config file. * - * Read-only on purpose. The updater never writes a config file — `opencode plugin -f` does, and it - * keeps the comments — so a parser that throws the comments away is all this needs. + * Every bay but the Updater read its settings with `JSON.parse` inside a `catch {}`, so one `//` line + * or a trailing comma dropped the whole file without a word — while doctor, which parsed JSONC, said + * the file was fine. One parser for the loader and doctor is what keeps them agreeing. + * + * Read-only on purpose: Cockpit never writes a config file, so a parser that throws the comments + * away is all this needs. Node APIs only — doctor runs it under `npx`. */ export type JsoncResult = { ok: true; value: unknown } | { ok: false; message: string } diff --git a/packages/client/src/opener.ts b/packages/client/src/opener.ts new file mode 100644 index 00000000..9a1f8f61 --- /dev/null +++ b/packages/client/src/opener.ts @@ -0,0 +1,83 @@ +/** + * Which program opens a link or a file in the system's own app — Trail's links, Review's images — as a + * command and its arguments. Deciding runs nothing; the bay spawns it, detached, so a click never + * stalls a frame. + * + * Neither OpenCode gives a plugin an opener of its own (docs/opencode/trail-interface.md, spike 5), so + * this is what OpenCode 2's bundled `open` does: `open` on macOS, `start` through `cmd` on Windows, + * `xdg-open` elsewhere. + * + * - **macOS tries `/usr/bin/open` before PATH**, for the reason gotchas.md gives about `ps`: OpenCode + * can be started with a PATH that has lost the system's directories, and a click that does nothing + * is worse than one that names what failed. + * - **Programs are found on the PATH the spawn is given** (`Bun.which` with it explicitly): `Bun.spawn` + * resolves a bare name from the parent's PATH, not the env passed to it. + * - **`COCKPIT_OPENER`** names a program to run instead, for a sandbox or a test (a stub that logs + * what it was handed). It is how a live check keeps a real browser or viewer from opening. + */ + +import { accessSync, constants } from "node:fs" + +export interface Opener { + command: string + args: string[] +} + +export interface OpenerWhere { + platform: string + /** Whether a file exists and can be run. */ + exists: (path: string) => boolean + /** A program's full path from PATH, or nothing. */ + which: (name: string) => string | undefined + /** `COCKPIT_OPENER`: a program to run instead. */ + override?: string +} + +/** The program each platform opens things with, by name: what to say when it is not there. */ +export const openerName = (platform: string): string => + platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open" + +/** + * `cmd` reads `&` as "run another command" in an argument it was handed unquoted — Node quotes one + * only when it has a space, a tab or a quote in it — so there it is escaped as `^&`. Quoted, `^` would + * be read as itself. + */ +const forCmd = (target: string): string => (/[\s"]/.test(target) ? target : target.replace(/&/g, "^&")) + +/** The command that opens `target`, a link or a path, or nothing when there is no opener here. */ +export function openerFor(target: string, where: OpenerWhere): Opener | undefined { + if (where.override) return { command: where.override, args: [target] } + if (where.platform === "darwin") { + const command = where.exists("/usr/bin/open") ? "/usr/bin/open" : where.which("open") + return command ? { command, args: [target] } : undefined + } + if (where.platform === "win32") { + /** `start` is `cmd`'s own; its first quoted argument is a window title, hence the empty one. */ + return { command: where.which("cmd") ?? "cmd", args: ["/c", "start", "", forCmd(target)] } + } + const command = where.which("xdg-open") + return command ? { command, args: [target] } : undefined +} + +/** Whether `path` exists and can be run: `exists` on this machine. */ +export const isRunnable = (path: string): boolean => { + try { + accessSync(path, constants.X_OK) + return true + } catch { + return false + } +} + +/** This machine, with programs found on `env.PATH`. */ +export function systemOpenerWhere( + env: Readonly> = process.env, + platform: string = process.platform, +): OpenerWhere { + return { + platform, + exists: isRunnable, + which: (name) => Bun.which(name, { PATH: env.PATH ?? "" }) ?? undefined, + ...(env.COCKPIT_OPENER ? { override: env.COCKPIT_OPENER } : {}), + } +} diff --git a/packages/client/src/plugin-entries.ts b/packages/client/src/plugin-entries.ts new file mode 100644 index 00000000..0e72e9ef --- /dev/null +++ b/packages/client/src/plugin-entries.ts @@ -0,0 +1,52 @@ +/** + * Cockpit's entries in OpenCode's plugin lists: one reader for `/cockpit-setup` and doctor, so the two + * never disagree about what is installed. + * + * Node APIs only — doctor runs it under `npx`. + */ + +import { basename } from "node:path" +import { BAYS, type Bay, isBay } from "./settings.ts" + +/** The bundle's package, and the prefix every single bay's package carries. */ +export const BUNDLE = "opencode-cockpit" +const SCOPE = "@opencode-cockpit/" + +const isObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +/** + * Every plugin entry in a config file, as either version writes it: v1's `"spec"` and + * `["spec", options]` under `plugin`, v2's `"spec"` and `{ package, options }` under `plugins`. Anything + * but an object has none. + */ +export function pluginEntries(config: unknown): { name: string; options?: unknown }[] { + if (!isObject(config)) return [] + const lists = [config.plugin, config.plugins].filter(Array.isArray) as unknown[][] + return lists.flat().flatMap((entry) => { + if (typeof entry === "string") return [{ name: entry }] + if (Array.isArray(entry) && typeof entry[0] === "string") return [{ name: entry[0], options: entry[1] }] + if (isObject(entry) && typeof entry.package === "string") + return [{ name: entry.package, options: entry.options }] + return [] + }) +} + +/** The package a name is, when it is one of ours: `opencode-cockpit`, `@opencode-cockpit/shell`… */ +export function bayOf(name: string | undefined): Bay | "bundle" | undefined { + if (name === BUNDLE) return "bundle" + const match = /^@opencode-cockpit\/([a-z]+)$/.exec(name ?? "") + return match && isBay(match[1]) ? match[1] : undefined +} + +/** The bays one entry brings, by its package — a name with or without a version, or a path. */ +export function baysOfEntry(name: string): Bay[] { + const path = name.replaceAll("\\", "/").replace(/\/+$/, "") + const scoped = path.lastIndexOf(SCOPE) + const pkg = + scoped >= 0 + ? `${SCOPE}${path.slice(scoped + SCOPE.length).replace(/@.*$/, "")}` + : basename(path).replace(/@[^/]*$/, "") + const bay = bayOf(pkg) + return bay === "bundle" ? [...BAYS] : bay ? [bay] : [] +} diff --git a/packages/client/src/server.ts b/packages/client/src/server.ts index 2a2c4028..5d5536ff 100644 --- a/packages/client/src/server.ts +++ b/packages/client/src/server.ts @@ -12,7 +12,8 @@ * structural types below. */ -import { isAbsolute, relative, resolve } from "node:path" +import { readFileSync } from "node:fs" +import { isAbsolute, join, relative, resolve } from "node:path" import { type Hooks, type PluginInput, @@ -21,6 +22,11 @@ import { tool, } from "@opencode-ai/plugin" import { cockpitVersion, createLog, type Log, silentLog } from "./log.ts" +import { recordAgent } from "./service.ts" +import { setupServer } from "./setup/server.ts" +import { registerSurfaces, type Surface } from "./surfaces.ts" + +export { keyText, openText, type Surface, surfacesLine } from "./surfaces.ts" export interface ServerHost { readonly version: 1 | 2 @@ -70,10 +76,57 @@ export interface ServerHost { readonly log: Log } +/** A tool call that finished, as `toolAfter` hears of it on either version. */ +export interface ToolCall { + sessionID: string + /** As the host names it: `bash`/`shell`, a plugin's `trail_add`, an MCP server's `_`. */ + tool: string + callID: string + /** What the model sent. */ + args: unknown + /** What the tool answered, as text — whichever field the host put it in. */ + output: string + /** The agent that made the call (`general`, `explore`…). OpenCode 2 only. */ + agent?: string +} + +/** + * A skill shipped in a package: the folder holding its `SKILL.md`, whose frontmatter names it. OpenCode 1 + * reads the folder (`skills.paths`), OpenCode 2 is handed the file's text (`ctx.skill`): either way the + * skill's files stay in the installed package and nothing is written to the user's config + * (docs/opencode/shipping-agents.md). + */ +export interface SkillSpec { + dir: string +} + +/** + * A slash command shipped from the agent side: one line of prompt. OpenCode then does what it does for + * its own commands — from home it opens a conversation, while the agent answers it queues — on both + * versions (measured on 1.18.32 and 2.0.18). Whatever is typed after the name follows the line. + */ +export interface CommandSpec { + name: string + description: string + prompt: string +} + export interface ServerParts { tools?: Record + skills?: SkillSpec[] + commands?: CommandSpec[] /** Added to the system prompt of each model request. The session is unknown on some v1 requests. */ system?: (sessionID: string | undefined) => Promise + /** + * What this feature shows the user and where (`surfaces.ts`), for the one Cockpit-wide line said + * to the main agent — once per window, whichever features are loaded. + */ + surfaces?: Surface[] + /** + * Every tool call that completed, any tool's — built-ins, MCP, other plugins'. Read-only: it hears + * what a tool answered and cannot change it. A throw is logged and never reaches the call. + */ + toolAfter?: (call: ToolCall) => Promise | void sessionDeleted?: (sessionID: string) => Promise /** Every event, as the host sends it: v1's `{ type, properties }`, v2's `{ type, data }`. */ event?: (event: unknown) => Promise | void @@ -91,10 +144,23 @@ export function composeParts(parts: ServerParts[]): ServerParts { tools[name] = def } } + const commands: CommandSpec[] = [] + for (const command of parts.flatMap((part) => part.commands ?? [])) { + if (commands.some((each) => each.name === command.name)) + throw new Error(`command "/${command.name}" is registered by more than one cockpit feature`) + commands.push(command) + } + const surfaces = parts.flatMap((part) => part.surfaces ?? []) + const skills = [ + ...new Map(parts.flatMap((part) => part.skills ?? []).map((skill) => [skill.dir, skill])).values(), + ] /** Only what some feature has: no features is no hooks at all, not hooks that do nothing. */ const any = (key: keyof ServerParts) => parts.some((part) => part[key] !== undefined) return { ...(Object.keys(tools).length > 0 ? { tools } : {}), + ...(skills.length > 0 ? { skills } : {}), + ...(commands.length > 0 ? { commands } : {}), + ...(surfaces.length > 0 ? { surfaces } : {}), ...(any("system") ? { system: async (sessionID: string | undefined) => { @@ -104,6 +170,22 @@ export function composeParts(parts: ServerParts[]): ServerParts { }, } : {}), + ...(any("toolAfter") + ? { + /** One feature's failure does not keep the call from the next; the first is reported. */ + toolAfter: async (call: ToolCall) => { + let failed: { error: unknown } | undefined + for (const part of parts) { + try { + await part.toolAfter?.(call) + } catch (error) { + failed ??= { error } + } + } + if (failed) throw failed.error + }, + } + : {}), ...(any("sessionDeleted") ? { sessionDeleted: async (sessionID: string) => { @@ -197,9 +279,76 @@ export function serverFromV1(input: PluginInput, log: Log = silentLog): ServerHo } } +/** + * What a v1 tool answered, as text. A built-in or plugin tool answers `{ title, output, metadata }`; + * an MCP tool answers `{ content: [{ type: "text", text }] }` with no `output` at all, so reading + * `output` alone missed every MCP call (docs/opencode/trail-server.md). + */ +export function v1ToolText(output: unknown): string { + const answer = output as { output?: unknown; content?: unknown } | undefined + if (typeof answer?.output === "string") return answer.output + return contentText(answer?.content) +} + +/** `[{ type: "text", text }, …]` as one string; anything that is not text is left out. */ +function contentText(content: unknown): string { + if (!Array.isArray(content)) return "" + return content + .flatMap((part) => + typeof (part as { text?: unknown })?.text === "string" ? [(part as { text: string }).text] : [], + ) + .join("\n") +} + +/** The slice of OpenCode 1's config the `config` hook edits. */ +interface V1Config { + command?: Record> + skills?: { paths?: string[] } & Record +} + +/** + * Skills and commands into OpenCode 1's config, in memory. The hook gets the config already holding + * the user's own entries: a command of the same name is merged *beneath* theirs, so what they wrote + * wins. A folder already listed is not listed twice. + */ +export function addToV1Config(config: V1Config, parts: Pick): void { + for (const command of parts.commands ?? []) { + config.command ??= {} + config.command[command.name] = { + template: command.prompt, + description: command.description, + ...config.command[command.name], + } + } + if (parts.skills?.length) { + config.skills ??= {} + const paths = config.skills.paths ?? [] + config.skills.paths = [ + ...paths, + ...parts.skills.map((skill) => skill.dir).filter((dir) => !paths.includes(dir)), + ] + } +} + export function partsToV1Hooks(parts: ServerParts): Hooks { return { ...(parts.tools ? { tool: parts.tools } : {}), + ...(parts.skills || parts.commands + ? { config: async (config: V1Config) => addToV1Config(config, parts) } + : {}), + ...(parts.toolAfter + ? { + "tool.execute.after": async (input, output) => { + await parts.toolAfter?.({ + sessionID: input.sessionID, + tool: input.tool, + callID: input.callID, + args: input.args, + output: v1ToolText(output), + }) + }, + } + : {}), ...(parts.system ? { "experimental.chat.system.transform": async (input, output) => { @@ -226,12 +375,22 @@ export function partsToV1Hooks(parts: ServerParts): Hooks { export interface V2ServerContext { options?: unknown location?: { directory: string } - tool?: { transform(edit: (editor: V2ToolEditor) => void): Promise } + tool?: { + transform(edit: (editor: V2ToolEditor) => void): Promise + /** 2.0.18: after every tool call, Code Mode's inner calls included (docs/opencode/trail-server.md). */ + hook?(name: "execute.after", run: (event: V2ToolAfter) => unknown): Promise + } + /** 2.0.15's skill registry: an added skill is offered to the model like the user's own. */ + skill?: { transform(edit: (editor: V2SkillEditor) => void): Promise } + /** 2.0.15's slash commands: code, not a template, so a shipped one prompts the session itself. */ + command?: { transform(edit: (editor: V2CommandEditor) => void): Promise } session: { get(input: { sessionID: string }): Promise<{ parentID?: string; title?: string; agent?: string } | undefined> synthetic(input: { sessionID: string; text: string; delivery?: "steer" | "queue" }): Promise + /** A message from the person: what a command sends. */ + prompt?(input: { sessionID: string; text: string; delivery?: "steer" | "queue" }): Promise /** A session's messages as the model is given them (2.0.15). */ context?(input: { sessionID: string }): Promise hook(name: "context", run: (event: { sessionID: string; system: unknown[] }) => unknown): Promise @@ -244,6 +403,45 @@ export interface V2Event { data?: { sessionID?: string } } +/** What v2's `execute.after` hands a plugin, measured on 2.0.18. */ +export interface V2ToolAfter { + tool: string + sessionID: string + agent?: string + messageID?: string + /** The call's id. A Code Mode call fires twice with the same one: the inner tool, then `execute`. */ + id: string + input?: unknown + status?: string + /** `content[].text` is the one field every tool has; `output` is a string for MCP, an object for `shell`. */ + result?: { output?: unknown; content?: unknown } + error?: unknown +} + +/** + * Code Mode's outer call. On v2 a plugin or MCP tool is called from inside `execute`, and the hook + * fires for both with the same id — the outer one carrying everything the code printed, `search(…)` + * results (the tool catalog, URLs and all) included. Only the inner call is delivered. + */ +const CODE_MODE = "execute" + +/** A v2 `execute.after` as a `ToolCall`, or undefined for Code Mode's outer call and a call that failed. */ +export function v2ToolCall(event: V2ToolAfter): ToolCall | undefined { + if (event.tool === CODE_MODE) return undefined + if (event.error !== undefined || (event.status !== undefined && event.status !== "completed")) + return undefined + const text = contentText(event.result?.content) + const output = text || (typeof event.result?.output === "string" ? event.result.output : "") + return { + sessionID: event.sessionID, + tool: event.tool, + callID: event.id, + args: event.input, + output, + ...(event.agent ? { agent: event.agent } : {}), + } +} + /** A tool as v2's editor takes one. */ export interface V2Tool { name: string @@ -256,6 +454,75 @@ interface V2ToolEditor { add(tool: V2Tool): void } +/** A skill as v2's registry holds one. */ +export interface V2Skill { + id: string + name: string + description?: string + /** The `SKILL.md`: the model is told its folder, so the skill's relative paths resolve. */ + path: string + content: string +} + +interface V2SkillEditor { + get(id: string): unknown + add(skill: V2Skill): void +} + +/** What v2 hands a command when it runs, measured on 2.0.18: `delivery` is `"steer"` even when idle. */ +export interface V2CommandInvocation { + sessionID: string + prompt?: { text?: string } + delivery?: "steer" | "queue" +} + +interface V2CommandEditor { + add(command: { + name: string + description?: string + execute(input: V2CommandInvocation): Promise + }): void +} + +/** + * A skill's folder as v2 takes it: name and description from the frontmatter, the text without it + * (v1 strips it too). Undefined when the file is missing or names nothing — logged by the caller, never + * thrown: no skill is worth the agent side. + */ +export function readSkill(spec: SkillSpec): V2Skill | undefined { + const path = join(spec.dir, "SKILL.md") + let text: string + try { + text = readFileSync(path, "utf8") + } catch { + return undefined + } + const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text) + const field = (name: string) => + match?.[1] + ?.split(/\r?\n/) + .find((line) => line.startsWith(`${name}:`)) + ?.slice(name.length + 1) + .trim() + .replace(/^(["'])(.*)\1$/, "$2") + const name = field("name") + if (!name) return undefined + const description = field("description") + return { + id: name, + name, + ...(description ? { description } : {}), + path, + content: match ? text.slice(match[0].length) : text, + } +} + +/** The line a v2 command sends: the command's own, then whatever was typed after its name. */ +export function commandText(command: CommandSpec, invocation: V2CommandInvocation): string { + const typed = invocation.prompt?.text?.trim() + return typed ? `${command.prompt}\n\n${typed}` : command.prompt +} + export interface V2ToolContext { sessionID: string agent: string @@ -404,6 +671,41 @@ export async function follow( } } +/** + * The Cockpit-wide line (`surfaces.ts`) ahead of the entry's own guidance, when this entry is the one + * in the window that says it. Said to the main agent only — a subagent answers its caller, not the + * user — and to a request whose session is unknown, as the guidance is. + */ +function withSurfaces(host: ServerHost, parts: ServerParts): ServerParts { + if (!parts.surfaces?.length) return parts + const entry = registerSurfaces(host.scope, parts.surfaces) + /** Parentage never changes: asked once per session. */ + const parented = new Map() + const subagent = async (sessionID: string): Promise => { + const known = parented.get(sessionID) + if (known !== undefined) return known + const session = await host.session.get(sessionID).catch(() => undefined) + if (!session) return false + const answer = Boolean(session.parentID) + parented.set(sessionID, answer) + return answer + } + const { system, dispose } = parts + return { + ...parts, + system: async (sessionID) => { + const own = (await system?.(sessionID)) ?? [] + const line = entry.line() + if (!line || (sessionID && (await subagent(sessionID)))) return own + return [line, ...own] + }, + dispose: async () => { + entry.release() + await dispose?.() + }, + } +} + /** Starts a feature: what loaded and where first, so a feature that never answers still said it was loaded. */ async function begin( id: string, @@ -412,9 +714,28 @@ async function begin( options: unknown, ): Promise { host.log.info("start", { entry: id, opencode: host.version, cockpit: cockpitVersion() }) + /** Which install this agent side loaded, for a window to compare with its own (service.ts). */ + if (host.version === 2) recordAgent(host.log) try { - const parts = await start(host, options) - return { ...parts, tools: loggedTools(parts.tools, host.log) } + /** `cockpit_settings`, the `cockpit-setup` skill and `/cockpit-setup`: the first entry here adds them. */ + const parts = withSurfaces(host, composeParts([await start(host, options), setupServer(host, id)])) + const { toolAfter } = parts + return { + ...parts, + tools: loggedTools(parts.tools, host.log), + /** Listening to a call must never break it: a failure is logged and goes no further. */ + ...(toolAfter + ? { + toolAfter: async (call: ToolCall) => { + try { + await toolAfter(call) + } catch (error) { + host.log.warn("toolAfter failed", { tool: call.tool, error }) + } + }, + } + : {}), + } } catch (error) { host.log.error("start failed", { entry: id, error }) throw error @@ -444,6 +765,53 @@ export function dualServer(id: string, start: ServerStart) { for (const [name, def] of tools) editor.add(toolToV2(name, def, host.directory)) }) } + const skills = (parts.skills ?? []).flatMap((spec) => { + const skill = readSkill(spec) + if (!skill) host.log.warn("skill not found", { dir: spec.dir }) + return skill ? [skill] : [] + }) + if (skills.length > 0) { + if (ctx.skill) { + await ctx.skill.transform((editor) => { + for (const skill of skills) if (!editor.get(skill.id)) editor.add(skill) + }) + } else + host.log.warn("this OpenCode takes no skills from plugins", { skills: skills.map((s) => s.id) }) + } + const commands = parts.commands ?? [] + if (commands.length > 0) { + if (ctx.command && ctx.session.prompt) { + const prompt = ctx.session.prompt.bind(ctx.session) + await ctx.command.transform((editor) => { + for (const command of commands) + editor.add({ + name: command.name, + description: command.description, + /** + * Queued, always: v2 hands every command `delivery: "steer"`, which cuts a reply in + * progress off to start on this. Queued, an idle session starts at once and a busy + * one shows `1 queued` and waits (measured on 2.0.18). + */ + execute: async (invocation) => { + await prompt({ + sessionID: invocation.sessionID, + text: commandText(command, invocation), + delivery: "queue", + }) + }, + }) + }) + } else host.log.warn("this OpenCode takes no commands from plugins", { commands: commands.length }) + } + if (parts.toolAfter) { + const after = parts.toolAfter + if (ctx.tool.hook) { + await ctx.tool.hook("execute.after", async (event) => { + const call = v2ToolCall(event) + if (call) await after(call) + }) + } else host.log.warn("this OpenCode has no execute.after hook; tool output is not followed") + } if (parts.system) { const system = parts.system await ctx.session.hook("context", async (event) => { diff --git a/packages/client/src/service.ts b/packages/client/src/service.ts new file mode 100644 index 00000000..7270a830 --- /dev/null +++ b/packages/client/src/service.ts @@ -0,0 +1,263 @@ +/** + * OpenCode 2's background service against the Cockpit installed now: one toast per window when the + * service still runs an older one. + * + * OpenCode 2 runs the agent side in a long-lived server (`opencode serve --service`) that every window + * attaches to, and it loads plugins once. After an update a new window draws the new interface while + * the agent keeps the old tools and skills — measured on 2.0.18: a service started on Sep 27 had no + * `trail_add` and no `cockpit-setup` through every reinstall, until `opencode service restart`. + * Restarting it from here would cut every open window, so the window says so and the person decides. + * + * Both halves come from the same install, so each says which install it is: the agent side writes + * `agents/.json` in Cockpit's home when it starts, and a window reads the record of the pid in + * OpenCode's own `service.json`. Measured on 2.0.18 in an isolated config: the agent side runs in the + * service's own process (the `start` line's pid is the pid in `service.json`), each window in its own. + * + * Which install is this package's `package.json`: its version, and when it was written — a dev install + * carries the same version every time, and every install writes the file anew. Read once per process + * and kept, because a service that outlives an install must go on saying what it loaded, not what is + * on disk now. + * + * Never a false alarm: no service, a service that is not running, no record from it (one older than + * this check, or not loaded yet), or a record from this very install — and nothing is said. + */ + +import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs" +import { homedir } from "node:os" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { resolvePaths } from "@opencode-cockpit/protocol" +import { claimFeature } from "./feature.ts" +import type { Host } from "./host.ts" +import type { Log } from "./log.ts" + +export const RESTART_COMMAND = "opencode service restart" + +/** Which install a half was loaded from. */ +export interface Install { + version: string + /** When the package's `package.json` was written, in ms: a new install, a new number. */ + installedAt: number + /** The package's directory, so doctor can ask whether it is still what is installed. */ + dir: string +} + +/** What the agent side writes when it starts. */ +export interface AgentRecord extends Install { + pid: number + startedAt: number +} + +const PACKAGE_DIR = dirname(dirname(fileURLToPath(import.meta.url))) + +/** This package's install, read from disk. Undefined when it cannot be read. */ +export function readInstall(dir: string = PACKAGE_DIR): Install | undefined { + try { + const file = join(dir, "package.json") + const version = (JSON.parse(readFileSync(file, "utf8")) as { version?: unknown }).version + if (typeof version !== "string") return undefined + return { version, installedAt: Math.round(statSync(file).mtimeMs), dir } + } catch { + return undefined + } +} + +/** Read once and kept for the life of the process: what this process loaded, whatever is on disk later. */ +const LOADED = Symbol.for("opencode-cockpit.loaded-install") +export function loadedInstall(): Install | undefined { + const shared = globalThis as { [LOADED]?: { install: Install | undefined } } + shared[LOADED] ??= { install: readInstall() } + return shared[LOADED].install +} + +export const agentsDir = (env: Readonly> = process.env): string => + join(resolvePaths(env as Record).home, "agents") + +export const recordPath = (dir: string, pid: number): string => join(dir, `${pid}.json`) + +/** A record's text as a record; undefined for anything else. */ +export function parseRecord(text: string | undefined): AgentRecord | undefined { + if (!text) return undefined + try { + const value = JSON.parse(text) as Partial + const number = (n: unknown) => typeof n === "number" && Number.isFinite(n) + if ( + typeof value.version !== "string" || + typeof value.dir !== "string" || + !number(value.installedAt) || + !number(value.pid) || + !number(value.startedAt) + ) + return undefined + return value as AgentRecord + } catch { + return undefined + } +} + +export function alive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + /** Running, and someone else's: alive all the same. */ + return (error as { code?: string }).code === "EPERM" + } +} + +/** + * The agent side says which install it loaded: once per process, whichever entry starts first. Records + * of processes that have ended are removed on the way. OpenCode 2 only — on 1 both halves share a process. + */ +const RECORDED = Symbol.for("opencode-cockpit.agent-recorded") +export function recordAgent(log: Log, dir: string = agentsDir()): void { + const shared = globalThis as { [RECORDED]?: boolean } + if (shared[RECORDED]) return + shared[RECORDED] = true + const install = loadedInstall() + if (!install) return + try { + mkdirSync(dir, { recursive: true, mode: 0o700 }) + for (const name of readdirSync(dir)) { + const pid = Number.parseInt(name, 10) + if (Number.isInteger(pid) && pid !== process.pid && !alive(pid)) + rmSync(join(dir, name), { force: true }) + } + const record: AgentRecord = { + ...install, + pid: process.pid, + startedAt: Math.round(performance.timeOrigin), + } + writeFileSync(recordPath(dir, process.pid), JSON.stringify(record), { mode: 0o600 }) + } catch (error) { + log.warn("service: could not record the agent side", { error }) + } +} + +// ── The window ───────────────────────────────────────────────────────────────────────────────── + +/** OpenCode 2's own note of its service, `$XDG_STATE_HOME/opencode/service.json`. */ +export function serviceFile(env: Readonly>, home: string): string { + return join(env.XDG_STATE_HOME || join(home, ".local", "state"), "opencode", "service.json") +} + +/** The pid in `service.json`'s text. Nothing else in it is read: it holds a password too. */ +export function servicePid(text: string | undefined): number | undefined { + if (!text) return undefined + try { + const pid = (JSON.parse(text) as { pid?: unknown }).pid + return typeof pid === "number" && Number.isInteger(pid) && pid > 0 ? pid : undefined + } catch { + return undefined + } +} + +export type Verdict = + | { stale: false; why: "no install" | "no service" | "no record" | "same" | "agent newer" } + | { stale: true; message: string } + +/** What a window says, from what it found. Pure. */ +export function verdict(input: { + own: Install | undefined + /** The service's pid, when it is running; undefined when there is none or it has stopped. */ + service: number | undefined + record: AgentRecord | undefined +}): Verdict { + const { own, service, record } = input + if (!own) return { stale: false, why: "no install" } + if (service === undefined) return { stale: false, why: "no service" } + if (!record || record.pid !== service) return { stale: false, why: "no record" } + if (record.version === own.version && record.installedAt === own.installedAt) + return { stale: false, why: "same" } + /** This window is the old one: it started before an install the service has already loaded. */ + if (record.installedAt > own.installedAt && record.version === own.version) + return { stale: false, why: "agent newer" } + if (record.version !== own.version && newer(record.version, own.version)) + return { stale: false, why: "agent newer" } + const runs = record.version === own.version ? "the old one" : record.version + const updated = + record.version === own.version ? "Cockpit was updated" : `Cockpit was updated to ${own.version}` + return { + stale: true, + message: `${updated} — OpenCode's background service still runs ${runs}. Run: ${RESTART_COMMAND}`, + } +} + +/** Whether `a` is a later version than `b`: `0.10.0` after `0.9.1`, a release after its prerelease. */ +export function newer(a: string, b: string): boolean { + const parse = (v: string) => { + const [core = "", pre] = v.split("-", 2) + return { parts: core.split(".").map((n) => Number.parseInt(n, 10) || 0), pre } + } + const x = parse(a) + const y = parse(b) + for (let i = 0; i < Math.max(x.parts.length, y.parts.length); i++) { + const d = (x.parts[i] ?? 0) - (y.parts[i] ?? 0) + if (d !== 0) return d > 0 + } + if (x.pre === y.pre) return false + if (x.pre === undefined) return true + if (y.pre === undefined) return false + return x.pre > y.pre +} + +/** What a window reads. A seam for tests; the real one reads the disk. */ +export interface ServiceIo { + read(path: string): string | undefined + alive(pid: number): boolean + env: Readonly> + home: string +} + +const readText = (path: string): string | undefined => { + try { + return readFileSync(path, "utf8") + } catch { + return undefined + } +} + +export const diskIo = (): ServiceIo => ({ read: readText, alive, env: process.env, home: homedir() }) + +/** The verdict for this window, from the disk. */ +export function look(own: Install | undefined, io: ServiceIo = diskIo()): Verdict { + const pid = servicePid(io.read(serviceFile(io.env, io.home))) + const service = pid !== undefined && io.alive(pid) ? pid : undefined + const record = + service === undefined ? undefined : parseRecord(io.read(recordPath(agentsDir(io.env), service))) + return verdict({ own, service, record }) +} + +/** + * Looked at a few times over the first minute: the first window starts the service, which loads its + * plugins (and writes its record) a moment later. Stops at the first answer that is not "no record". + */ +export const LOOK_AT_MS = [3_000, 10_000, 30_000, 60_000] as const + +/** Once per window, OpenCode 2 only: the first Cockpit entry to start looks, the rest do not. */ +export function registerServiceCheck(host: Host, source: string, io: ServiceIo = diskIo()): void { + if (host.version !== 2) return + const claim = claimFeature(host.renderer, "service-check", source) + if (!claim.active) return + const own = loadedInstall() + const timers: ReturnType[] = [] + let done = false + host.lifecycle.onDispose(() => { + done = true + for (const timer of timers) clearTimeout(timer) + claim.release() + }) + for (const at of LOOK_AT_MS) { + timers.push( + setTimeout(() => { + if (done) return + const said = look(own, io) + if (!said.stale && said.why === "no record") return + done = true + host.log.info("service: looked", { ...said, at }) + if (said.stale) + host.ui.toast({ variant: "warning", title: "Cockpit", message: said.message, duration: 15_000 }) + }, at), + ) + } +} diff --git a/packages/client/src/settings.ts b/packages/client/src/settings.ts new file mode 100644 index 00000000..21d0dc18 --- /dev/null +++ b/packages/client/src/settings.ts @@ -0,0 +1,623 @@ +/** + * Cockpit's settings: one loader every bay reads through, so the files mean the same thing to all of + * them, to doctor and to `cockpit_settings`. + * + * ~/.config/opencode-cockpit/config.json (or $XDG_CONFIG_HOME/…) + * /.cockpit.json wins over the global file + * plugin-entry options win over both + * + * Each bay grew its own copy of that merge, and no two read the file the same way: Shell's keys sat at + * the file's root, Status read the root as its own when it had no section, Subagents and Review read + * no file at all, and every copy but the Updater's dropped a file with one comment in it without a + * word (docs/opencode/settings-and-commands.md). Here: + * + * - **JSONC everywhere.** Comments and trailing commas are fine. + * - **One section per bay** (`status`, `subagents`, `shell`, `trail`, `trust`, `review`, `updater`), + * merged key by key, nested objects included; a list replaces the one before it. A file without a + * bay's section says nothing about that bay — its root is never read as anyone's settings. + * - **The same shared keys in every bay**, flat and spelled once: `enabled`, `keybinds`, `sidebar` + * (draw the block, a boolean), `sidebarRows`, `hideWhenEmpty`. Time keys carry their unit + * (`hideFinishedAfterMinutes`, `hideNestedAfterSeconds`). + * - **One order**: the top-level `sidebar` list, and nothing else. + * - **Old names are not read.** They are recognised, so each one is a notice — the bay draws it as a + * `!` row, doctor prints it, `cockpit_settings` lists it for the `cockpit-setup` skill to fix — and + * its value is ignored. + * - **It never throws.** An unreadable file, a wrong type, an unknown name: a notice, and the + * defaults. A typo in a config should never cost you the interface. + * + * Read once, at start — never in a draw path. Node APIs only: doctor runs this under `npx`. + */ + +import { readFileSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" +import { parseJsonc } from "./jsonc.ts" +import { oldAtTop, oldInSection } from "./settings/old-names.ts" + +export { OLD_NAMES } from "./settings/old-names.ts" + +// ── Names ────────────────────────────────────────────────────────────────────────────────────── + +/** Bays that draw a sidebar block, in the default order. The `sidebar` list is made of these. */ +export const SIDEBAR_BAYS = ["status", "subagents", "shell", "trail", "trust"] as const +export type SidebarBay = (typeof SIDEBAR_BAYS)[number] + +/** Every bay with a section in the file. */ +export const BAYS = [...SIDEBAR_BAYS, "review", "updater"] as const +export type Bay = (typeof BAYS)[number] + +export const isBay = (name: unknown): name is Bay => (BAYS as readonly unknown[]).includes(name) +export const isSidebarBay = (name: unknown): name is SidebarBay => + (SIDEBAR_BAYS as readonly unknown[]).includes(name) + +/** The files, by where they are. */ +export const GLOBAL_FILE = "config.json" +export const PROJECT_FILE = ".cockpit.json" + +/** Where a notice from plugin-entry options says it came from. */ +export const OPTIONS_SOURCE = "plugin options" + +/** What every notice about an old name tells you to do. */ +export const SETUP_COMMAND = "/cockpit-setup" + +// ── The shape ────────────────────────────────────────────────────────────────────────────────── + +/** Spelled the same in every bay. */ +export interface SharedSettings { + /** The bay's off switch, in every file. `features.: false` still works too. */ + enabled: boolean + keybinds: Record + /** Draw the bay's sidebar block. A boolean here; the top-level `sidebar` is the order. */ + sidebar: boolean + /** Rows before the rest fold into `+ N more`. */ + sidebarRows: number + /** Draw nothing at all when there is nothing to list. Default false: the heading and `none yet`. */ + hideWhenEmpty: boolean +} + +/** + * What a bay starts from before any file is read. Every block is present by default except Trust's, + * which is opt-in (the palette toggles it per session); Trail is a core piece and shows. + */ +export const SHARED_DEFAULTS: Readonly> = { + status: { enabled: true, keybinds: {}, sidebar: true, sidebarRows: 8, hideWhenEmpty: false }, + subagents: { enabled: true, keybinds: {}, sidebar: true, sidebarRows: 6, hideWhenEmpty: false }, + shell: { enabled: true, keybinds: {}, sidebar: true, sidebarRows: 5, hideWhenEmpty: false }, + trail: { enabled: true, keybinds: {}, sidebar: true, sidebarRows: 5, hideWhenEmpty: false }, + trust: { enabled: true, keybinds: {}, sidebar: false, sidebarRows: 3, hideWhenEmpty: false }, + review: { enabled: true, keybinds: {}, sidebar: false, sidebarRows: 0, hideWhenEmpty: false }, + updater: { enabled: true, keybinds: {}, sidebar: false, sidebarRows: 0, hideWhenEmpty: false }, +} + +type Kind = "boolean" | "number" | "string" | "object" | "array" + +const SHARED_KIND: Readonly> = { + enabled: "boolean", + keybinds: "object", + sidebar: "boolean", + sidebarRows: "number", + hideWhenEmpty: "boolean", +} + +/** The whole file, as documented. Each bay types its own keys; these are the ones they share. */ +export interface CockpitSettings { + /** Top to bottom. Default: status, subagents, shell, trail, trust. */ + sidebar?: SidebarBay[] + /** The bundle's switches, read from the files too. */ + features?: Partial> + status?: Partial & Record + subagents?: Partial & Record + shell?: Partial & Record + trail?: Partial & Record + trust?: Partial & Record + review?: Partial & Record + updater?: { updateCheck?: boolean } +} + +// ── Notices ──────────────────────────────────────────────────────────────────────────────────── + +export type NoticeKind = + /** A name from before 0.9. Not read; `new` says what to write instead. */ + | "old" + /** Not read: an unknown name, or a key in the wrong place. */ + | "unread" + /** The right name with the wrong kind of value; the default is used. */ + | "invalid" + /** A file that is not JSON(C); the whole file is ignored. */ + | "unreadable" + +/** + * Something about the settings worth fixing. `old` is the name as written, `new` the one to write + * instead (when there is one). `text` says it in a sentence — `"statusline" is no longer read — run + * /cockpit-setup` — for a bay's `!` row (`noticeText`) and doctor's fix line (`${file}: ${text}`). + */ +export interface SettingsNotice { + /** The bay whose block should say it. `cockpit` belongs to no one bay: a file, the top level. */ + bay: Bay | "cockpit" + /** The file it was found in, or `plugin options`. */ + file: string + kind: NoticeKind + old?: string + new?: string + text: string +} + +/** The row a bay draws for a notice, after a `!` in the warning tone. */ +export const noticeText = (notice: SettingsNotice): string => `settings: ${notice.text}` + +// ── Old names ────────────────────────────────────────────────────────────────────────────────── +// +// Detection only, and removed in 0.10: `settings/old-names.ts`. + +// ── Small helpers ────────────────────────────────────────────────────────────────────────────── + +type Section = Record + +const isObject = (value: unknown): value is Section => + typeof value === "object" && value !== null && !Array.isArray(value) + +function kindOf(value: unknown): Kind | undefined { + if (Array.isArray(value)) return "array" + if (isObject(value)) return "object" + if (typeof value === "number") return Number.isFinite(value) ? "number" : undefined + if (typeof value === "boolean" || typeof value === "string") return typeof value as Kind + return undefined +} + +const article = (kind: Kind) => (kind === "array" ? "a list" : kind === "object" ? "an object" : `a ${kind}`) + +/** Key by key, nested objects included; anything else — a list too — replaces what was there. */ +export function mergeSections(base: Section, over: Section): Section { + const out: Section = { ...base } + for (const [key, value] of Object.entries(over)) { + if (value === undefined) continue + const before = out[key] + out[key] = isObject(value) && isObject(before) ? mergeSections(before, value) : value + } + return out +} + +function distance(a: string, b: string): number { + const row = Array.from({ length: b.length + 1 }, (_, i) => i) + for (let i = 1; i <= a.length; i++) { + let diagonal = row[0] as number + row[0] = i + for (let j = 1; j <= b.length; j++) { + const above = row[j] as number + row[j] = Math.min(above + 1, (row[j - 1] as number) + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1)) + diagonal = above + } + } + return row[b.length] as number +} + +/** The valid name someone most likely meant: `shells` → `shell`, `statusline` → `status`. */ +export function closestName(name: string, valid: readonly string[]): string | undefined { + const lower = name.toLowerCase() + const exact = valid.find((each) => each === lower) + if (exact) return exact + const prefix = valid.find((each) => lower.startsWith(each) || each.startsWith(lower)) + if (prefix && lower.length >= 3) return prefix + let best: { name: string; cost: number } | undefined + for (const each of valid) { + const cost = distance(lower, each) + if (!best || cost < best.cost) best = { name: each, cost } + } + return best && best.cost <= 2 ? best.name : undefined +} + +// ── Reading the files ────────────────────────────────────────────────────────────────────────── + +export interface SettingsWhere { + /** The project directory, for its `.cockpit.json`. */ + directory?: string + env?: Readonly> + home?: string + /** A file's text, or undefined when there is none. Doctor hands its own disk. */ + read?: (path: string) => string | undefined +} + +export interface SettingsFile { + path: string + scope: "global" | "project" + found: boolean + /** Why it could not be used. The whole file is then ignored. */ + error?: string +} + +/** One file that was read: its sections, old names already taken out. */ +export interface SettingsLayer { + path: string + scope: "global" | "project" + sections: Partial> + /** Its `sidebar` list, valid names only; undefined when it sets none. */ + sidebar?: SidebarBay[] + features: Partial> +} + +export interface Settings { + /** Every file looked for, global first. */ + files: SettingsFile[] + /** The files that were read, global first. */ + layers: SettingsLayer[] + /** Each bay's section, global then project, before plugin options and defaults. */ + sections: Record + /** The list as the user wrote it (project replaces global); undefined when neither sets one. */ + sidebarList?: SidebarBay[] + /** The order the blocks draw in: the user's list, then every bay it left out, in default order. */ + sidebar: SidebarBay[] + features: Partial> + /** Everything to fix, from every file. A bay's own are also in `baySettings(…).notices`. */ + notices: SettingsNotice[] +} + +export function settingsPaths(where: SettingsWhere = {}): { global: string; project?: string } { + const env = where.env ?? process.env + const base = env.XDG_CONFIG_HOME || join(where.home ?? homedir(), ".config") + return { + global: join(base, "opencode-cockpit", GLOBAL_FILE), + ...(where.directory ? { project: join(where.directory, PROJECT_FILE) } : {}), + } +} + +function readText(path: string): string | undefined { + try { + return readFileSync(path, "utf8") + } catch { + return undefined + } +} + +/** + * One bay's section with its old keys taken out, each one noted. `prefix` is how a key is named in + * a notice: `shell.` in a file, nothing in plugin options (where the keys are the bay's own). + */ +function withoutOld( + bay: Bay, + section: Section, + file: string, + prefix: string, + notes: SettingsNotice[], +): Section { + const out: Section = {} + for (const [key, value] of Object.entries(section)) { + if (oldInSection(bay, key, value, file, prefix, notes)) continue + if (key === "sidebar" && Array.isArray(value)) { + notes.push({ + bay, + file, + kind: "unread", + old: `${prefix}${key}`, + new: "sidebar", + text: `"${prefix}${key}" is a switch, true or false: the order is the top-level "sidebar" list`, + }) + } else out[key] = value + } + return out +} + +/** The shared keys of one section, type-checked: a wrong kind is dropped with a notice. */ +function checkShared( + bay: Bay, + section: Section, + file: string, + prefix: string, + notes: SettingsNotice[], +): Section { + const out: Section = {} + for (const [key, value] of Object.entries(section)) { + const want = SHARED_KIND[key as keyof SharedSettings] + if (want && kindOf(value) !== want) { + notes.push({ + bay, + file, + kind: "invalid", + old: `${prefix}${key}`, + text: `"${prefix}${key}" should be ${article(want)}; the default is used`, + }) + continue + } + out[key] = value + } + return out +} + +const TOP = ["sidebar", "features", "$schema", ...BAYS] as const + +function readSidebarList(value: unknown, file: string, notes: SettingsNotice[]): SidebarBay[] | undefined { + if (value === undefined) return undefined + const valid = SIDEBAR_BAYS.join(", ") + if (!Array.isArray(value)) { + notes.push({ + bay: "cockpit", + file, + kind: "invalid", + old: "sidebar", + text: `"sidebar" is the order of the blocks, a list: ["${SIDEBAR_BAYS.join('", "')}"]`, + }) + return undefined + } + const list: SidebarBay[] = [] + for (const name of value) { + if (isSidebarBay(name)) { + if (!list.includes(name)) list.push(name) + continue + } + const shown = typeof name === "string" ? name : JSON.stringify(name) + if (isBay(name)) { + notes.push({ + bay: name, + file, + kind: "unread", + old: `sidebar: "${shown}"`, + text: `"${shown}" in "sidebar" has no sidebar block (${valid})`, + }) + continue + } + const meant = typeof name === "string" ? closestName(name, SIDEBAR_BAYS) : undefined + notes.push({ + bay: (meant as SidebarBay | undefined) ?? "cockpit", + file, + kind: "unread", + old: `sidebar: "${shown}"`, + ...(meant ? { new: meant } : {}), + text: `"${shown}" in "sidebar" is not a bay${meant ? `: did you mean "${meant}"?` : ""} (${valid})`, + }) + } + return list +} + +function readFeatures(value: unknown, file: string, notes: SettingsNotice[]): Partial> { + const out: Partial> = {} + if (value === undefined) return out + if (!isObject(value)) { + notes.push({ + bay: "cockpit", + file, + kind: "invalid", + old: "features", + text: `"features" should be an object, like { "trust": false }`, + }) + return out + } + for (const [name, on] of Object.entries(value)) { + if (isBay(name) && typeof on === "boolean") { + out[name] = on + continue + } + const meant = isBay(name) ? undefined : closestName(name, BAYS) + notes.push({ + bay: isBay(name) ? name : "cockpit", + file, + kind: isBay(name) ? "invalid" : "unread", + old: `features.${name}`, + ...(meant ? { new: `features.${meant}` } : {}), + text: isBay(name) + ? `"features.${name}" should be true or false` + : `"features.${name}" is not a bay${meant ? `: did you mean "${meant}"?` : ""}`, + }) + } + return out +} + +/** One parsed file into its sections; every old name and stray key noted, and left out. */ +function readLayer(raw: Section, file: string, scope: SettingsLayer["scope"], notes: SettingsNotice[]) { + const note = (notice: Omit) => notes.push({ file, ...notice }) + const sections: Partial> = {} + + for (const [key, value] of Object.entries(raw)) { + if (key === "sidebar" || key === "features" || key === "$schema") continue + if (isBay(key)) { + if (isObject(value)) { + const section = withoutOld(key, value, file, `${key}.`, notes) + sections[key] = checkShared(key, section, file, `${key}.`, notes) + } else note({ bay: key, kind: "invalid", old: key, text: `"${key}" should be an object of settings` }) + } else if (!oldAtTop(key, value, note)) { + const meant = closestName(key, TOP) + note({ + bay: meant && isBay(meant) ? meant : "cockpit", + kind: "unread", + old: key, + ...(meant ? { new: meant } : {}), + text: `"${key}" is not a setting${meant ? `: did you mean "${meant}"?` : ""}`, + }) + } + } + + const sidebar = readSidebarList(raw.sidebar, file, notes) + return { + path: file, + scope, + sections, + ...(sidebar ? { sidebar } : {}), + features: readFeatures(raw.features, file, notes), + } satisfies SettingsLayer +} + +/** + * Both files, read and merged. Never throws: a file that cannot be read is a notice and is ignored + * whole — and doctor, reading through this same function, says the same. + */ +export function loadSettings(where: SettingsWhere = {}): Settings { + const read = where.read ?? readText + const paths = settingsPaths(where) + const files: SettingsFile[] = [] + const layers: SettingsLayer[] = [] + const notices: SettingsNotice[] = [] + const wanted: [string, SettingsFile["scope"]][] = [[paths.global, "global"]] + if (paths.project) wanted.push([paths.project, "project"]) + + for (const [path, scope] of wanted) { + const text = read(path) + if (text === undefined) { + files.push({ path, scope, found: false }) + continue + } + const parsed = parseJsonc(text) + const value = parsed.ok ? parsed.value : undefined + if (!isObject(value)) { + const error = parsed.ok ? "not a JSON object" : parsed.message + files.push({ path, scope, found: true, error }) + notices.push({ + bay: "cockpit", + file: path, + kind: "unreadable", + text: `${error} — the whole file is ignored`, + }) + continue + } + files.push({ path, scope, found: true }) + layers.push(readLayer(value, path, scope, notices)) + } + + const sections = Object.fromEntries( + BAYS.map((bay) => [ + bay, + layers.reduce((merged, layer) => mergeSections(merged, layer.sections[bay] ?? {}), {} as Section), + ]), + ) as Record + /** A project's list replaces the global one rather than merging: it is an order, not a set. */ + const sidebarList = layers.reduce( + (list, layer) => layer.sidebar ?? list, + undefined, + ) + const features: Partial> = Object.assign({}, ...layers.map((layer) => layer.features)) + return { + files, + layers, + sections, + ...(sidebarList ? { sidebarList } : {}), + sidebar: [...(sidebarList ?? []), ...SIDEBAR_BAYS.filter((bay) => !sidebarList?.includes(bay))], + features, + notices, + } +} + +/** Notices that belong to no one bay: a file that could not be read, an unknown top-level name. */ +export const cockpitNotices = (settings: Settings): SettingsNotice[] => + settings.notices.filter((notice) => notice.bay === "cockpit") + +// ── The order ────────────────────────────────────────────────────────────────────────────────── + +/** + * The numbers the list hands out: 110, 120, … 150. OpenCode 1 sorts its own blocks by the same + * numbers — Context 100, MCP 200, LSP 300, Todo 400, Modified files 500 — so Cockpit's blocks sit + * together, under Context and above the rest. OpenCode 2 ignores the number and draws blocks in the + * order they register, which `orderedSidebar` makes this same order. + */ +export const SIDEBAR_FIRST = 110 +export const SIDEBAR_STEP = 10 + +/** Where a bay's block sits: its place in the list, and nothing else. */ +export function orderOf(settings: Pick, bay: SidebarBay): number { + return SIDEBAR_FIRST + Math.max(0, settings.sidebar.indexOf(bay)) * SIDEBAR_STEP +} + +// ── One bay ──────────────────────────────────────────────────────────────────────────────────── + +export interface BaySettings { + /** Defaults, then the global file, the project's, and plugin options. */ + config: SharedSettings & T + /** What was written, merged, without defaults. For a bay that merges a key its own way. */ + written: Section + /** The block's `order` for `api.slots.register`. Meaningless for a bay without a block. */ + order: number + /** This bay's notices, from the files and from its plugin options: one `!` row each. */ + notices: SettingsNotice[] + settings: Settings +} + +export interface BayInput { + /** The plugin entry's options: the bay's own keys, or a whole config with a section for it. */ + options?: unknown + /** Already loaded — the bundle can load once for every bay. */ + settings?: Settings + where?: SettingsWhere +} + +type Widen = { + [K in keyof T]: T[K] extends boolean + ? boolean + : T[K] extends number + ? number + : T[K] extends string + ? string + : T[K] +} + +/** + * A bay's settings in one call: its defaults, every file, its plugin options, and every value whose + * kind differs from its default's dropped (with a notice). A key whose default is undefined passes as + * written; a key with no default at all passes too, so a bay can read what it does not type. + * + * const { config, order, notices } = baySettings("shell", { dockHeight: 14, colors: true }, { + * options, where: { directory: api.state.path.directory }, + * }) + */ +export function baySettings>( + bay: Bay, + defaults?: T, + input: BayInput = {}, +): BaySettings> { + const settings = input.settings ?? loadSettings(input.where) + const notices = settings.notices.filter((notice) => notice.bay === bay) + const base: Section = { ...SHARED_DEFAULTS[bay], ...(defaults as Section | undefined) } + + /** Each source checked on its own, so a notice names the file it is in. */ + const sources = settings.layers.map((layer) => ({ + file: layer.path, + prefix: `${bay}.`, + section: layer.sections[bay] ?? {}, + })) + const options = optionsSection(bay, input.options) + if (options) { + const section = withoutOld(bay, options, OPTIONS_SOURCE, "", notices) + sources.push({ + file: OPTIONS_SOURCE, + prefix: "", + section: checkShared(bay, section, OPTIONS_SOURCE, "", notices), + }) + } + + let written: Section = {} + for (const source of sources) { + const checked: Section = {} + for (const [key, value] of Object.entries(source.section)) { + const want = kindOf(base[key]) + if (want && !(key in SHARED_KIND) && kindOf(value) !== want) { + notices.push({ + bay, + file: source.file, + kind: "invalid", + old: `${source.prefix}${key}`, + text: `"${source.prefix}${key}" should be ${article(want)}; the default is used`, + }) + continue + } + checked[key] = value + } + written = mergeSections(written, checked) + } + + const config = mergeSections(base, written) as SharedSettings & Widen + config.sidebarRows = Math.max(0, Math.floor(config.sidebarRows)) + if (settings.features[bay] === false) config.enabled = false + return { + config, + written, + order: isSidebarBay(bay) ? orderOf(settings, bay) : 0, + notices, + settings, + } +} + +/** + * Plugin options as one bay's section: a whole cockpit config's section for it, else the options + * themselves (a standalone entry carries its own keys). Only for options — a *file* without a section + * says nothing about the bay; reading the whole file as its settings was Status's trap. + */ +export function optionsSection(bay: Bay, options: unknown): Section | undefined { + if (!isObject(options)) return undefined + const own = options[bay] + return isObject(own) ? own : options +} diff --git a/packages/client/src/settings/old-names.ts b/packages/client/src/settings/old-names.ts new file mode 100644 index 00000000..72664356 --- /dev/null +++ b/packages/client/src/settings/old-names.ts @@ -0,0 +1,120 @@ +/** + * Old names: detection only, and removed in 0.10 — by deleting this file and its two calls in + * `settings.ts` (`oldInSection`, `oldAtTop`). + * + * Before 0.9 each bay had its own spellings; they are no longer read, only recognised, so a config + * written for 0.8 says what changed instead of silently doing nothing. Values under these keys are + * ignored. + */ + +import { type Bay, SETUP_COMMAND, type SettingsNotice } from "../settings.ts" + +/** Shell's keys that sat at the file's root before it had a section. */ +const ROOT_SHELL = ["watch", "kinds", "defaults", "lifecycle", "notify", "guidance", "listRunningShells"] + +/** + * Root keys Status used to read as its own when the file had no section — so a root `enabled: false` + * meant for something else turned the statusline off. A file's root is no bay's settings now. + */ +const ROOT_STATUS = [ + "enabled", + "debug", + "preset", + "surface", + "segments", + "separator", + "stack", + "icons", + "maxRows", + "lines", + "commands", + "modules", + "paddingLeft", + "paddingRight", + "paddingTop", + "paddingBottom", +] + +/** Old keys inside a bay's section (and its plugin options), and what replaced them. */ +const OLD_IN_SECTION: Readonly>>>> = { + status: { maxRows: "sidebarRows" }, + shell: { historyMinutes: "hideFinishedAfterMinutes" }, + subagents: { hideFinishedAfter: "hideFinishedAfterMinutes", hideNestedAfter: "hideNestedAfterSeconds" }, +} + +/** Shell's old `ui` group: where each key went. Anything not listed went to `shell.`. */ +const OLD_UI: Readonly> = { + historyMinutes: "shell.hideFinishedAfterMinutes", + updateCheck: "updater.updateCheck", + sidebarOrder: "sidebar", +} + +/** Every old name the loader recognises, and what to write instead — for the `cockpit-setup` skill's reference. */ +export const OLD_NAMES: readonly { old: string; new: string }[] = [ + { old: "statusline", new: "status" }, + { old: "status.maxRows", new: "status.sidebarRows" }, + ...ROOT_SHELL.map((key) => ({ old: key, new: `shell.${key}` })), + { old: "ui.", new: "shell." }, + { old: "ui.historyMinutes", new: "shell.hideFinishedAfterMinutes" }, + { old: "ui.updateCheck", new: "updater.updateCheck" }, + { old: "ui.sidebarOrder", new: "sidebar" }, + { old: ".sidebarOrder", new: "sidebar" }, + { old: "subagents.hideFinishedAfter", new: "subagents.hideFinishedAfterMinutes" }, + { old: "subagents.hideNestedAfter", new: "subagents.hideNestedAfterSeconds" }, +] + +const oldText = (old: string) => `"${old}" is no longer read — run ${SETUP_COMMAND}` + +const isObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +/** + * Whether a key in one bay's section is an old name; when it is, it is noted. `prefix` is how a key is + * named in a notice: `shell.` in a file, nothing in plugin options (where the keys are the bay's own). + */ +export function oldInSection( + bay: Bay, + key: string, + value: unknown, + file: string, + prefix: string, + notes: SettingsNotice[], +): boolean { + const old = (name: string, now: string) => + notes.push({ bay, file, kind: "old", old: name, new: now, text: oldText(name) }) + const now = OLD_IN_SECTION[bay]?.[key] + if (key === "sidebarOrder") old(`${prefix}${key}`, "sidebar") + else if (now) old(`${prefix}${key}`, `${bay}.${now}`) + else if (bay === "shell" && key === "ui" && isObject(value)) { + for (const inner of Object.keys(value)) old(`${prefix}ui.${inner}`, OLD_UI[inner] ?? `shell.${inner}`) + } else return false + return true +} + +/** Whether a key at a file's top level is an old name; when it is, it is noted. */ +export function oldAtTop( + key: string, + value: unknown, + note: (notice: Omit) => void, +): boolean { + if (key === "statusline") { + note({ bay: "status", kind: "old", old: key, new: "status", text: oldText(key) }) + } else if (ROOT_SHELL.includes(key)) { + note({ bay: "shell", kind: "old", old: key, new: `shell.${key}`, text: oldText(key) }) + } else if (key === "ui" && isObject(value)) { + for (const inner of Object.keys(value)) { + const now = OLD_UI[inner] ?? `shell.${inner}` + const bay = now.startsWith("updater.") ? "updater" : "shell" + note({ bay, kind: "old", old: `ui.${inner}`, new: now, text: oldText(`ui.${inner}`) }) + } + } else if (ROOT_STATUS.includes(key)) { + note({ + bay: "status", + kind: "unread", + old: key, + new: `status.${key}`, + text: `"${key}" at the top level is not read: it belongs in "status"`, + }) + } else return false + return true +} diff --git a/packages/client/src/setup.ts b/packages/client/src/setup.ts new file mode 100644 index 00000000..9eeaf848 --- /dev/null +++ b/packages/client/src/setup.ts @@ -0,0 +1,51 @@ +/** + * Setting Cockpit up with the agent: the `cockpit_settings` tool, the `cockpit-setup` skill and the + * `/cockpit-setup` command. + * + * Choosing which bays show, where and in what order is an editing job in a file the interface never + * names, so the agent does it, with the person. What it needs splits in two, and each half lives where + * it stays true: + * + * - **What does not change between installs** — the flow, the starting points, every key and its + * default — is the skill (`packages/client/skills/cockpit-setup`), shipped in this package and + * loaded when the person asks. Its reference is written from `catalog.ts`. + * - **What does** — which bays are installed and on, what each file says, every value and where it + * came from, what is not read, OpenCode's own sidebar blocks — is this tool, read when it is called, + * through the same loader the bays read with, so it says what the interface draws. + * + * The command is one line naming the skill, shipped through the agent side so OpenCode itself opens a + * conversation from home and queues it behind a reply in progress. Every Cockpit server entry offers + * all three; the first in an OpenCode registers them (`setupServer`, called from `dualServer`). The + * palette lists no command an agent side ships, on either version, so every TUI entry offers a palette + * entry that sends the same line (`registerSetup`). + * + * This file is the `./setup` entry and only gathers the parts in `setup/`. The interface and the agent + * side import their own part directly (`host.ts` the palette entry, `server.ts` the tools), so neither + * loads the other's code. + */ + +export { briefAgent } from "./brief.ts" +export { baysOfEntry } from "./plugin-entries.ts" +export { HOST_BLOCKS, type HostFile, hostFilePaths, readHostFile } from "./setup/host-blocks.ts" +export { type Install, opencodeConfigPaths, readInstalls } from "./setup/installs.ts" +export { + CONVENTIONS_TOOL, + SETTINGS_TOOL, + SETUP_PROMPT, + SETUP_SKILL, + SETUP_SKILL_DIR, + SETUP_SLASH, +} from "./setup/names.ts" +export { registerSetup } from "./setup/palette.ts" +export { offerPreview, previewCommands } from "./setup/previews.ts" +export { + type BayState, + type ReportInput, + type ResolvedKey, + type SettingsReport, + type Source, + settingsReport, +} from "./setup/report.ts" +export { setupServer } from "./setup/server.ts" +export { blockState, noticeLine, settingsText } from "./setup/text.ts" +export { bayCommands, conventionsReply, type TuneFacts, tuneFacts, tuneText } from "./setup/tune.ts" diff --git a/packages/client/src/setup/host-blocks.ts b/packages/client/src/setup/host-blocks.ts new file mode 100644 index 00000000..7d719ac2 --- /dev/null +++ b/packages/client/src/setup/host-blocks.ts @@ -0,0 +1,83 @@ +/** + * OpenCode's own sidebar blocks. + * + * Status's table draws a Context section, so with OpenCode's own Context block on, "Context" shows + * twice. Turning that block off is OpenCode's setting, in OpenCode's file — so the tool says what it + * is set to now and how to change it, and the skill asks before touching a file that is not Cockpit's. + * Measured on 1.18.32 and 2.0.18 (docs/opencode/settings-and-commands.md, "/cockpit-setup spikes"). + */ + +import { homedir } from "node:os" +import { join } from "node:path" +import { parseJsonc } from "../jsonc.ts" +import { pluginEntries } from "../plugin-entries.ts" +import { isObject, opencodeDirs } from "./installs.ts" + +/** OpenCode's own sidebar blocks this talks about, and their plugin ids on each version. */ +type HostBlock = "context" | "mcp" | "lsp" | "todo" | "files" | "footer" + +/** + * 1.18.32 names its blocks `internal:sidebar-*` and turns one off in `tui.json` with + * `"plugin_enabled": { "": false }`. 2.0.18 names them `opencode.sidebar.*`, turns one off with + * `"-"` in `cli.json`'s `plugins` list, and has no LSP, Todo or Files block at all. Every id is + * the binary's own, and each switch was measured hiding its block (MCP and Footer on both versions in + * an isolated run, 2026-10-03) — except Files, whose block never drew in a test run to hide. + */ +export const HOST_BLOCKS: Readonly>>> = { + 1: { + context: "internal:sidebar-context", + mcp: "internal:sidebar-mcp", + lsp: "internal:sidebar-lsp", + todo: "internal:sidebar-todo", + files: "internal:sidebar-files", + footer: "internal:sidebar-footer", + }, + 2: { context: "opencode.sidebar.context", mcp: "opencode.sidebar.mcp", footer: "opencode.sidebar.footer" }, +} + +/** One of OpenCode's interface config files, and which of the blocks it switches. */ +export interface HostFile { + path: string + found: boolean + error?: string + /** Block id → on or off, as this file writes it. */ + blocks: Record +} + +/** The files OpenCode reads its interface settings from: the global one, then the project's. */ +export function hostFilePaths( + opencode: 1 | 2, + directory: string, + env: Readonly> = process.env, + home: string = homedir(), +): string[] { + const name = opencode === 1 ? "tui" : "cli" + return opencodeDirs(directory, env, home).flatMap((dir) => [ + join(dir, `${name}.json`), + join(dir, `${name}.jsonc`), + ]) +} + +/** What one file says about the blocks. Never throws: a file that will not parse says so. */ +export function readHostFile(opencode: 1 | 2, path: string, text: string | undefined): HostFile { + if (text === undefined) return { path, found: false, blocks: {} } + const parsed = parseJsonc(text) + const value = parsed.ok ? parsed.value : undefined + if (!isObject(value)) + return { path, found: true, error: parsed.ok ? "not a JSON object" : parsed.message, blocks: {} } + const ids = Object.values(HOST_BLOCKS[opencode]) + const blocks: Record = {} + if (opencode === 1) { + const enabled = value.plugin_enabled + if (isObject(enabled)) + for (const [id, on] of Object.entries(enabled)) + if (ids.includes(id) && typeof on === "boolean") blocks[id] = on + } else { + for (const { name } of pluginEntries(value)) { + const off = name.startsWith("-") + const id = off ? name.slice(1) : name + if (ids.includes(id)) blocks[id] = !off + } + } + return { path, found: true, blocks } +} diff --git a/packages/client/src/setup/installs.ts b/packages/client/src/setup/installs.ts new file mode 100644 index 00000000..0709feca --- /dev/null +++ b/packages/client/src/setup/installs.ts @@ -0,0 +1,70 @@ +/** + * Which bays are installed. + * + * The agent side cannot see the interface's plugins (OpenCode 2 runs them in another process, and + * OpenCode 1 in another thread), and three bays have no agent side. So "installed" is read where the + * person wrote it — OpenCode's own plugin lists — and "running here" from this side's claims. + */ + +import { homedir } from "node:os" +import { join } from "node:path" +import { parseJsonc } from "../jsonc.ts" +import { baysOfEntry, pluginEntries } from "../plugin-entries.ts" +import type { Bay } from "../settings.ts" + +export const isObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +/** OpenCode's config folders: the global one, the project, the project's `.opencode`. */ +export const opencodeDirs = ( + directory: string, + env: Readonly>, + home: string, +): string[] => [ + join(env.XDG_CONFIG_HOME || join(home, ".config"), "opencode"), + directory, + join(directory, ".opencode"), +] + +/** A Cockpit plugin entry found in one of OpenCode's files. */ +export interface Install { + /** As written: `opencode-cockpit@0.9.0`, a path… */ + entry: string + bundle: boolean + file: string + options?: unknown +} + +/** OpenCode's files that list plugins: the agent side's and the interface's, global then project. */ +export function opencodeConfigPaths( + opencode: 1 | 2, + directory: string, + env: Readonly> = process.env, + home: string = homedir(), +): string[] { + const names = ["opencode", opencode === 1 ? "tui" : "cli"] + return opencodeDirs(directory, env, home).flatMap((dir) => + names.flatMap((name) => [join(dir, `${name}.json`), join(dir, `${name}.jsonc`)]), + ) +} + +export function readInstalls(path: string, text: string | undefined): Install[] { + if (text === undefined) return [] + const parsed = parseJsonc(text) + if (!parsed.ok || !isObject(parsed.value)) return [] + return pluginEntries(parsed.value).flatMap(({ name, options }) => { + const bays = baysOfEntry(name) + if (bays.length === 0) return [] + return [ + { entry: name, bundle: bays.length > 1, file: path, ...(options !== undefined ? { options } : {}) }, + ] + }) +} + +/** One bay's options in an entry: the bundle's section for it, else a single bay's own options. */ +export function entryOptions(bay: Bay, install: Install): Record | undefined { + if (!isObject(install.options)) return undefined + if (!install.bundle) return install.options + const own = install.options[bay] + return isObject(own) ? own : undefined +} diff --git a/packages/client/src/setup/names.ts b/packages/client/src/setup/names.ts new file mode 100644 index 00000000..187ac270 --- /dev/null +++ b/packages/client/src/setup/names.ts @@ -0,0 +1,13 @@ +/** The names setup is reached by, shared by the agent side and the interface's palette entry. */ + +import { fileURLToPath } from "node:url" + +/** The skill, the command that loads it, and the line the command sends. */ +export const SETUP_SKILL = "cockpit-setup" +export const SETUP_SLASH = "cockpit-setup" +export const SETUP_PROMPT = "Use the cockpit-setup skill to help me set up Cockpit." +export const SETTINGS_TOOL = "cockpit_settings" +export const CONVENTIONS_TOOL = "cockpit_conventions" + +/** Where the skill sits in this package: `src/setup/` and `dist/setup/` are both two levels under its root. */ +export const SETUP_SKILL_DIR = fileURLToPath(new URL(`../../skills/${SETUP_SKILL}`, import.meta.url)) diff --git a/packages/client/src/setup/palette.ts b/packages/client/src/setup/palette.ts new file mode 100644 index 00000000..8ae99dcb --- /dev/null +++ b/packages/client/src/setup/palette.ts @@ -0,0 +1,33 @@ +/** The interface: a palette entry that asks the agent to set Cockpit up. */ + +import { briefAgent } from "../brief.ts" +import { claimFeature } from "../feature.ts" +import type { Host } from "../host.ts" +import { SETUP_PROMPT } from "./names.ts" + +/** + * The palette entry, once per window. Neither OpenCode lists a command an agent side ships in its + * palette (measured: `ctrl+p` → "cockpit" finds nothing), so the interface offers one that sends the + * command's own line. No slash name: `/cockpit-setup` is the shipped command's, and a second + * `/cockpit-setup` in the popup would be two rows doing one thing. + */ +export function registerSetup(host: Host, source: string): void { + const claim = claimFeature(host.renderer, "setup", source) + if (!claim.active) return + host.lifecycle.onDispose(() => claim.release()) + host.keymap.registerLayer({ + commands: [ + { + name: "cockpit.setup", + title: "Ask the agent to set up Cockpit", + desc: "which bays show, where, in what order", + category: "Cockpit", + namespace: "palette", + run: () => { + host.log.info("setup: asked from the palette") + briefAgent(host, SETUP_PROMPT, "Cockpit setup") + }, + }, + ], + } as never) +} diff --git a/packages/client/src/setup/previews.ts b/packages/client/src/setup/previews.ts new file mode 100644 index 00000000..f52fb44c --- /dev/null +++ b/packages/client/src/setup/previews.ts @@ -0,0 +1,21 @@ +/** + * Previews the loaded bays offer, by bay: the exact command for the copy installed here. Shared on + * `globalThis` because the bundle and a standalone package each carry their own copy of this module. + * + * `bunx @opencode-cockpit/status preview` fetches the newest release from npm instead — 0.8 drew a + * bottom line at terminal width for a 0.9 sidebar config — so the agent is handed the path. + */ + +const PREVIEWS = Symbol.for("opencode-cockpit.previews") +const previewRegistry = (): Map => { + const shared = globalThis as { [PREVIEWS]?: Map } + shared[PREVIEWS] ??= new Map() + return shared[PREVIEWS] +} + +/** A bay's preview command, e.g. `bun "/…/status/dist/cli/preview.js"`. */ +export function offerPreview(bay: string, command: string): void { + previewRegistry().set(bay, command) +} + +export const previewCommands = (): Record => Object.fromEntries(previewRegistry()) diff --git a/packages/client/src/setup/report.ts b/packages/client/src/setup/report.ts new file mode 100644 index 00000000..2d1ee73d --- /dev/null +++ b/packages/client/src/setup/report.ts @@ -0,0 +1,172 @@ +/** + * The report `cockpit_settings` answers from: every bay's state, read fresh through the same loader the + * bays read with, so it says what the interface draws. + */ + +import { readFileSync } from "node:fs" +import { homedir } from "node:os" +import { bayKeys, type KeyInfo } from "../catalog.ts" +import { bayNotices, uniqueNotices } from "../checks.ts" +import { baysOfEntry } from "../plugin-entries.ts" +import { + BAYS, + type Bay, + baySettings, + closestName, + loadSettings, + OPTIONS_SOURCE, + type Settings, + type SettingsNotice, + type SettingsWhere, +} from "../settings.ts" +import { type HostFile, hostFilePaths, readHostFile } from "./host-blocks.ts" +import { entryOptions, type Install, isObject, opencodeConfigPaths, readInstalls } from "./installs.ts" + +export type Source = "default" | "global" | "project" | typeof OPTIONS_SOURCE + +export interface ResolvedKey { + info: KeyInfo + value: unknown + source: Source +} + +export interface BayState { + bay: Bay + /** The entries that bring it, as written in OpenCode's files. */ + installs: Install[] + /** Running on this agent side now (it claimed itself here). */ + running: boolean + on: boolean + /** Why it is off, in words, when it is. */ + off?: string + keys: ResolvedKey[] +} + +export interface SettingsReport { + opencode: 1 | 2 + directory: string + settings: Settings + bays: BayState[] + /** + * Every notice: the files', each bay's own — what its block draws as a `!` row, from its own check + * (`checks.ts`) — and keys no bay reads. + */ + notices: SettingsNotice[] + /** OpenCode's interface files, global first: what they say about its sidebar blocks. */ + host: HostFile[] +} + +export interface ReportInput { + opencode: 1 | 2 + directory: string + /** Feature claims on this agent side: `{ shell: "opencode-cockpit", setup: … }`. Non-bays ignored. */ + claims?: ReadonlyMap + env?: Readonly> + home?: string + /** A file's text, or undefined when there is none — Cockpit's files and OpenCode's alike. */ + read?: (path: string) => string | undefined +} + +export function readText(path: string): string | undefined { + try { + return readFileSync(path, "utf8") + } catch { + return undefined + } +} + +const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b) + +export function settingsReport(input: ReportInput): SettingsReport { + const env = input.env ?? process.env + const home = input.home ?? homedir() + const read = input.read ?? readText + const where: SettingsWhere = { directory: input.directory, env, home, read } + const settings = loadSettings(where) + const installs = opencodeConfigPaths(input.opencode, input.directory, env, home).flatMap((path) => + readInstalls(path, read(path)), + ) + const notices = [...settings.notices] + + const bays = BAYS.map((bay): BayState => { + const mine = installs.filter((install) => baysOfEntry(install.entry).includes(bay)) + const running = input.claims?.has(bay) ?? false + /** Every entry's options for it, later files winning: the interface's entry and the agent side's. */ + const given = mine.flatMap((install) => entryOptions(bay, install) ?? []) + const options = given.length > 0 ? Object.assign({}, ...given) : undefined + const infos = bayKeys(bay) + const defaults = Object.fromEntries(infos.map((info) => [info.key, info.default])) + const loaded = baySettings(bay, defaults, { settings, ...(options ? { options } : {}) }) + /** What the bay itself draws: its keys' kinds, and what only it knows (Status's presets…). */ + notices.push(...bayNotices(bay, settings, options)) + + /** Keys a section carries that this bay never reads: a typo is silence otherwise. */ + const known = infos.map((info) => info.key) + const sources = [ + ...settings.layers.map((layer) => ({ + source: layer.scope as Source, + file: layer.path, + section: layer.sections[bay] ?? {}, + })), + ...(options ? [{ source: OPTIONS_SOURCE as Source, file: OPTIONS_SOURCE, section: options }] : []), + ] + for (const { file, section, source } of sources) { + for (const key of Object.keys(section)) { + if (known.includes(key)) continue + /** Compared without case: keys are camelCase, and `hideWhenEmty` should still find its key. */ + const lowered = closestName( + key, + known.map((each) => each.toLowerCase()), + ) + const meant = known.find((each) => each.toLowerCase() === lowered) + const name = source === OPTIONS_SOURCE ? key : `${bay}.${key}` + notices.push({ + bay, + file, + kind: "unread", + old: name, + ...(meant ? { new: source === OPTIONS_SOURCE ? meant : `${bay}.${meant}` } : {}), + text: `"${name}" is not a setting of ${bay}${meant ? `: did you mean "${meant}"?` : ""}`, + }) + } + } + + const config = loaded.config as unknown as Record + const keys = infos.map((info): ResolvedKey => { + /** The last source that wrote it, if what it wrote is what the bay uses (a wrong kind is dropped). */ + const from = [...sources].reverse().find(({ section }) => info.key in section) + const value = config[info.key] + const kept = from !== undefined && (same(from.section[info.key], value) || isObject(value)) + return { info, value, source: kept ? from.source : "default" } + }) + const enabled = config.enabled !== false + const off = + mine.length === 0 && !running + ? "not installed" + : settings.features[bay] === false + ? "`features` in a settings file" + : mine.some( + (install) => + install.bundle && + isObject(install.options) && + isObject(install.options.features) && + install.options.features[bay] === false, + ) + ? "`features` in the plugin entry" + : !enabled + ? "`enabled: false`" + : undefined + return { bay, installs: mine, running, on: off === undefined, ...(off ? { off } : {}), keys } + }) + + return { + opencode: input.opencode, + directory: input.directory, + settings, + bays, + notices: uniqueNotices(notices), + host: hostFilePaths(input.opencode, input.directory, env, home).map((path) => + readHostFile(input.opencode, path, read(path)), + ), + } +} diff --git a/packages/client/src/setup/server.ts b/packages/client/src/setup/server.ts new file mode 100644 index 00000000..f54e3d36 --- /dev/null +++ b/packages/client/src/setup/server.ts @@ -0,0 +1,99 @@ +/** The agent side: the `cockpit_settings` and `cockpit_conventions` tools, the skill and the command. */ + +import { mkdirSync, rmSync, writeFileSync } from "node:fs" +import { homedir } from "node:os" +import { dirname } from "node:path" +import { tool } from "@opencode-ai/plugin" +import { instructionPaths, writeSection } from "../conventions.ts" +import { claimedFeatures, claimFeature } from "../feature.ts" +import type { ServerHost, ServerParts } from "../server.ts" +import { CONVENTIONS_TOOL, SETTINGS_TOOL, SETUP_PROMPT, SETUP_SKILL_DIR, SETUP_SLASH } from "./names.ts" +import { previewCommands } from "./previews.ts" +import { type ReportInput, readText, settingsReport } from "./report.ts" +import { settingsText } from "./text.ts" +import { conventionsReply, tuneFacts, tuneText } from "./tune.ts" + +const TOOL_DESCRIPTION = [ + "Cockpit's settings as they are now, read fresh: which bays are installed and on, where each draws,", + "every value with where it came from (default, global file, project file, plugin options), the sidebar", + "order, every setting that is not read and how to fix it, the settings files' paths, and OpenCode's own", + "sidebar blocks with the exact syntax to switch them. Read-only. Call it before changing Cockpit's", + "settings and again after writing, to check the change: it should then report no notices.", + "With tune: true it adds the second phase of /cockpit-setup: a tour of each bay's keys and commands,", + "the project's long-running commands, ticket keys and remotes, and what each AGENTS.md's Cockpit section says.", +].join(" ") + +const CONVENTIONS_DESCRIPTION = [ + "Writes the person's Cockpit conventions into an AGENTS.md as one marked `## Cockpit conventions` section:", + "replaced in place when it is there, added at the end when not, the rest of the file kept byte for byte.", + "Only after the person agreed to the text and the file. `conventions` is the section's markdown without", + "its heading; an empty string removes the section. Returns the section as it now reads.", +].join(" ") + +/** + * The tool, the skill and the command, once per OpenCode: the first Cockpit server entry to ask gets + * them, the rest get nothing, so a bundle beside a single bay registers one of each. + */ +export function setupServer(host: ServerHost, source: string): ServerParts { + const claim = claimFeature(host.scope, "setup", source) + if (!claim.active) return {} + const z = tool.schema + const settings = tool({ + description: TOOL_DESCRIPTION, + args: { + tune: z + .boolean() + .optional() + .describe("true for the second phase: the tour, the project's facts and the AGENTS.md sections"), + }, + execute: async (args, ctx) => { + const input: ReportInput = { + opencode: host.version, + // The session's own directory where the host gives one: the project the person is in. + directory: ctx?.directory || host.directory, + claims: claimedFeatures(host.scope), + } + const report = settingsReport(input) + host.log.info("setup: settings read", { notices: report.notices.length, tune: args.tune === true }) + const text = settingsText(report, previewCommands()) + return args.tune ? `${text}\n\n${tuneText(report, tuneFacts(input))}` : text + }, + }) + const conventions = tool({ + description: CONVENTIONS_DESCRIPTION, + args: { + file: z + .enum(["project", "global"]) + .describe("project: this repository's AGENTS.md; global: OpenCode's, read in every project"), + conventions: z.string().describe("The section's markdown, without its heading. Empty removes it."), + }, + execute: async (args, ctx) => { + const path = instructionPaths(ctx?.directory || host.directory, process.env, homedir())[args.file] + const written = writeSection(readText(path), args.conventions) + if (!written.ok) throw new Error(written.error) + if (written.action !== "unchanged" && written.action !== "absent") { + /** The file is the person's: OpenCode's own edit permission decides, as for any edit. */ + await ctx.ask({ permission: "edit", patterns: [path], always: [path], metadata: { filepath: path } }) + if (written.text === undefined) rmSync(path, { force: true }) + else { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, written.text) + } + } + host.log.info("setup: conventions written", { file: args.file, action: written.action }) + return conventionsReply(path, written) + }, + }) + return { + tools: { [SETTINGS_TOOL]: settings, [CONVENTIONS_TOOL]: conventions }, + skills: [{ dir: SETUP_SKILL_DIR }], + commands: [ + { + name: SETUP_SLASH, + description: "set up Cockpit with the agent: which bays show, where, in what order", + prompt: SETUP_PROMPT, + }, + ], + dispose: () => claim.release(), + } +} diff --git a/packages/client/src/setup/text.ts b/packages/client/src/setup/text.ts new file mode 100644 index 00000000..0344c2e9 --- /dev/null +++ b/packages/client/src/setup/text.ts @@ -0,0 +1,212 @@ +/** The tool's answer: the report as text the agent acts on. */ + +import { BAY_ABOUT, shownDefault } from "../catalog.ts" +import { + isSidebarBay, + OPTIONS_SOURCE, + type Settings, + type SettingsNotice, + type SidebarBay, +} from "../settings.ts" +import { HOST_BLOCKS } from "./host-blocks.ts" +import type { BayState, ResolvedKey, SettingsReport } from "./report.ts" + +const json = (value: unknown) => JSON.stringify(value) + +function valueText(key: ResolvedKey): string { + if (key.source === "default") return shownDefault(key.info).replaceAll("`", "") + return json(key.value) +} + +/** Where a bay's block is, in a few words. */ +export function blockState(state: BayState): string { + if (!state.on) return "—" + const value = (key: string) => state.keys.find((each) => each.info.key === key)?.value + if (state.bay === "status") { + const surface = value("surface") + return value("sidebar") === false || surface === "bottom" ? "a line under the prompt" : "in the sidebar" + } + if (!isSidebarBay(state.bay)) return "no sidebar block" + if (value("sidebar") === false) return "hidden (sidebar: false)" + return value("hideWhenEmpty") === true ? "shown, hidden while empty" : "shown, `none yet` while empty" +} + +/** + * One notice as a line to act on. An old name says what to write instead; the order's old numbers + * have no new name to copy to, because the order is the list now. + */ +export function noticeLine(notice: SettingsNotice): string { + const where = notice.file === OPTIONS_SOURCE ? "plugin options" : notice.file + if (notice.kind === "old" && notice.old && notice.new) { + if (notice.new === "sidebar") + return `- ${where}: "${notice.old}" is no longer read. Remove it; the order is the top-level "sidebar" list.` + return `- ${where}: "${notice.old}" is no longer read. Move its value to "${notice.new}" and remove "${notice.old}".` + } + const text = notice.text.replace(/ — run \/cockpit-setup$/, "") + /** A bay's own words may end in a question (`did you mean "git"?`): no full stop after it. */ + return `- ${where}: ${text}${/[.?!]$/.test(text) ? "" : "."}` +} + +/** Whether Status draws its table in the sidebar. */ +function statusInSidebar(report: SettingsReport): boolean { + const status = report.bays.find((state) => state.bay === "status") + return status?.on === true && blockState(status) === "in the sidebar" +} + +/** OpenCode's own blocks: what each is set to now, where, and what to suggest. */ +function hostSection(report: SettingsReport): string[] { + const ids = HOST_BLOCKS[report.opencode] + const found = report.host.filter((file) => file.found) + /** Global, then project: the last file that sets a block is the one in force. */ + const state = (id: string) => { + const file = [...found].reverse().find((each) => id in each.blocks) + return { on: file ? file.blocks[id] !== false : true, file: file?.path } + } + const said = (id: string) => { + const { on, file } = state(id) + return `${on ? "on" : "off"} (${file ? `set in ${file}` : "default"})` + } + const global = report.host.find((file) => file.found && !file.error)?.path ?? report.host[0]?.path ?? "" + const exists = report.host.some((file) => file.path === global && file.found) + const target = `${global}${exists ? "" : " (create it)"}` + const how = + report.opencode === 1 + ? (id: string, on: boolean) => `\`"plugin_enabled": { "${id}": ${on} }\` in ${target}` + : (id: string, on: boolean) => + on + ? `remove \`"-${id}"\` from the \`"plugins"\` list in the cli.json that has it` + : `add \`"-${id}"\` to the \`"plugins"\` list in ${target}, keeping every entry already there` + const lines = [ + `## OpenCode's own sidebar blocks (OpenCode ${report.opencode}: ${report.opencode === 1 ? '`tui.json` → `"plugin_enabled"`' : '`cli.json` → `"plugins"`'})`, + "", + ] + if (ids.context) { + lines.push(`- Context \`${ids.context}\`: ${said(ids.context)}.`) + if (state(ids.context).on && statusInSidebar(report)) + lines.push( + ` Status draws its table in the sidebar, so "Context" shows twice. Suggest turning OpenCode's off: ${how(ids.context, false)}.`, + ) + } + /** A block that is the person's call: its state, why one might hide it, and the switch either way. */ + const optional = (name: string, id: string | undefined, why: string) => { + if (!id) return + const { on } = state(id) + lines.push( + `- ${name} \`${id}\`: ${said(id)}. Optional; offer it without recommending either way. ${why} ${on ? `Off: ${how(id, false)}` : `Back on: ${how(id, true)}`}.`, + ) + } + optional( + "MCP", + ids.mcp, + `Lists every MCP server and its state. ${statusInSidebar(report) ? "Status's table already warns when one fails, so hiding" : "Hiding"} the list makes the sidebar quieter; \`opencode mcp list\` still shows every server.`, + ) + optional("LSP", ids.lsp, "Lists the language servers running.") + optional("Files", ids.files, "Lists the files this conversation changed.") + optional("Footer", ids.footer, "The project's path and git branch at the bottom of the sidebar.") + if (ids.todo) + lines.push( + state(ids.todo).on + ? `- Todo \`${ids.todo}\`: on. Never suggest turning it off: nothing in Cockpit replaces it.` + : `- Todo \`${ids.todo}\`: ${said(ids.todo)}. Say it is off and offer to turn it back on (nothing in Cockpit replaces it): ${how(ids.todo, true)}.`, + ) + if (report.opencode === 2) lines.push("- OpenCode 2 has no LSP, Todo or Files block in the sidebar.") + const broken = found.filter((file) => file.error) + for (const file of broken) + lines.push(`- ${file.path} does not parse (${file.error}): fix it before editing.`) + lines.push( + "- These are OpenCode's files, not Cockpit's: ask before changing one, and keep everything else in it.", + ) + return [...lines, ""] +} + +/** What `cockpit_settings` answers. Leads with what to fix, then the state, then what to do next. */ +export function settingsText(report: SettingsReport, previews: Record = {}): string { + const { settings } = report + const installed = report.bays.filter((state) => state.installs.length > 0 || state.running) + const order = settings.sidebar.filter((bay) => + installed.some((state) => state.bay === bay && state.on), + ) as SidebarBay[] + const written = Object.fromEntries([ + ...(settings.sidebarList ? [["sidebar", settings.sidebarList] as const] : []), + ...(Object.keys(settings.features).length > 0 ? [["features", settings.features] as const] : []), + ...Object.entries(settings.sections).filter(([, section]) => Object.keys(section).length > 0), + ]) + const fileLine = (file: Settings["files"][number]) => + `- ${file.scope}: ${file.path} — ${ + file.error + ? `does not parse (${file.error}); the whole file is ignored until it does` + : file.found + ? "exists" + : file.scope === "global" + ? "not created yet (create it, folder included)" + : "not created yet (for settings only this project uses)" + }` + + const bayLines = report.bays.flatMap((state) => { + if (state.installs.length === 0 && !state.running) return [] + const via = [...new Set(state.installs.map((install) => install.entry))].join(", ") || "this agent side" + const head = `### ${state.bay} — ${state.on ? "on" : `off (${state.off})`} · block: ${blockState(state)} · from ${via}` + if (!state.on && state.off === "not installed") return [head, ""] + const set = state.keys.filter((key) => key.source !== "default") + const rest = state.keys.filter((key) => key.source === "default") + return [ + head, + `- is: ${BAY_ABOUT[state.bay]}`, + ...(set.length > 0 + ? [`- set: ${set.map((key) => `${key.info.key} ${valueText(key)} (${key.source})`).join(" · ")}`] + : []), + `- defaults: ${rest.map((key) => `${key.info.key} ${valueText(key)}`).join(" · ")}`, + "", + ] + }) + const missing = report.bays.filter((state) => state.installs.length === 0 && !state.running) + + return [ + `# Cockpit settings — OpenCode ${report.opencode}, project ${report.directory}`, + "", + report.notices.length === 0 + ? "Notices: none. Every setting written is read." + : [ + `## Fix these first (${report.notices.length})`, + "", + "Each is ignored, so the value under it does nothing. Fix them in the same file before anything else, and say what changed:", + "", + ...report.notices.map(noticeLine), + ].join("\n"), + "", + "## Files (JSONC; the project's wins over the global key by key; read when OpenCode starts)", + "", + ...settings.files.map(fileLine), + "", + Object.keys(written).length === 0 + ? "Written: nothing. Every bay is on its defaults." + : `Written, both files merged: ${json(written)}`, + "", + `## Bays (sidebar order, top to bottom: ${order.join(", ") || "no blocks"}${settings.sidebarList ? ", from the `sidebar` list" : ", the default"})`, + "", + ...bayLines, + ...(missing.length > 0 + ? [ + `Not installed: ${missing.map((state) => state.bay).join(", ")}. Their settings do nothing; do not ask about them.`, + "", + ] + : []), + ...hostSection(report), + ...(Object.keys(previews).length > 0 + ? [ + "## Previews (this install's own — use exactly these; `bunx`/`npx` fetch another release)", + "", + ...Object.entries(previews).map(([bay, command]) => `- ${bay}: \`${command}\``), + "", + ] + : []), + "## Next", + "", + ...(report.notices.length > 0 + ? [`- Fix the ${report.notices.length} notice${report.notices.length === 1 ? "" : "s"} above first.`] + : []), + "- Write only keys that differ from the defaults. Global file unless the person wants this project only.", + "- After writing, call cockpit_settings again: it should say `Notices: none`.", + "- Changes apply after OpenCode restarts.", + ].join("\n") +} diff --git a/packages/client/src/setup/tune.ts b/packages/client/src/setup/tune.ts new file mode 100644 index 00000000..f27837fd --- /dev/null +++ b/packages/client/src/setup/tune.ts @@ -0,0 +1,153 @@ +/** + * The second phase: making it fit how the person works. + * + * Asked for with `cockpit_settings({ tune: true })`, after the blocks are set: a tour of what each + * installed bay does for the person, with the keys as they are set now; what the project runs that + * never ends; the ticket keys its history uses; and what each AGENTS.md says now. The conventions + * themselves are written by `cockpit_conventions` (conventions.ts). + */ + +import { homedir } from "node:os" +import { BAY_ABOUT, BAY_COMMANDS, DEFAULT_KEYS } from "../catalog.ts" +import { + findSections, + type GitRun, + type InstructionFile, + type ProjectFacts, + projectFacts, + readInstructions, + sectionText, + type WriteAction, + type Written, +} from "../conventions.ts" +import { isObject } from "./installs.ts" +import { CONVENTIONS_TOOL } from "./names.ts" +import { type BayState, type ReportInput, readText, type SettingsReport } from "./report.ts" + +export interface TuneFacts { + project: ProjectFacts + instructions: InstructionFile[] +} + +export function tuneFacts(input: ReportInput, git?: GitRun): TuneFacts { + const env = input.env ?? process.env + const home = input.home ?? homedir() + const read = input.read ?? readText + return { + project: projectFacts(input.directory, read, git), + instructions: readInstructions(input.opencode, input.directory, env, home, read), + } +} + +/** A bay's commands with the keys they have now: its defaults, then what `keybinds` wrote over them. */ +export function bayCommands(state: BayState): { key?: string; slash?: string; does: string }[] { + const written = state.keys.find((key) => key.info.key === "keybinds")?.value + const keys: Record = { ...DEFAULT_KEYS[state.bay], ...(isObject(written) ? written : {}) } + return BAY_COMMANDS[state.bay].map((command) => { + const key = command.command ? keys[command.command] : undefined + return { + ...(typeof key === "string" && key !== "none" ? { key } : {}), + ...(command.slash ? { slash: command.slash } : {}), + does: command.does, + } + }) +} + +function tourLines(report: SettingsReport): string[] { + return report.bays + .filter((state) => state.on) + .map((state) => { + const commands = bayCommands(state) + .map((each) => + [each.key ? `\`${each.key}\`` : "", each.slash ? `\`/${each.slash}\`` : "", each.does] + .filter(Boolean) + .join(" "), + ) + .join(" · ") + return `- ${state.bay} — ${BAY_ABOUT[state.bay]}${commands ? `. ${commands}` : ""}` + }) +} + +function instructionLines(file: InstructionFile): string[] { + const head = `- ${file.scope}: ${file.path} — ` + if (file.unclosed) + return [ + `${head}its Cockpit section (line ${file.unclosed}) has no end marker; ${CONVENTIONS_TOOL} says how to fix it`, + ] + if (!file.exists) + return [ + `${head}not created yet (${CONVENTIONS_TOOL} creates it)`, + ...(file.shadows + ? [ + ` ${file.shadows} exists, and OpenCode 1 reads it only while there is no ${file.path}: creating this file stops OpenCode 1 reading that one. Say so before choosing it.`, + ] + : []), + ] + if (file.sections === 0) return [`${head}exists, no Cockpit section yet (one would be added at the end)`] + return [ + `${head}has the Cockpit section${file.sections > 1 ? ` ${file.sections} times (a write merges them into one)` : ""}. Now:`, + "", + ...(file.body ? file.body.split("\n").map((line) => ` ${line}`) : [" (empty)"]), + "", + ] +} + +/** The second phase's facts, after the settings. */ +export function tuneText(report: SettingsReport, facts: TuneFacts): string { + const { project } = facts + const long = project.longRunning.map((each) => `- \`${each.command}\` — ${each.from}`) + const tickets = project.tickets.map((each) => `${each.prefix} (${each.count}, e.g. ${each.example})`) + return [ + "## Tune it to how they work", + "", + "Conventions only: every request already tells the agent how to use each bay, so never write how to use Cockpit.", + "", + "### The tour: what each bay does for them (keys as set now; `` is OpenCode's leader key, ctrl+x unless they changed it)", + "", + ...tourLines(report), + "", + `### This project (${report.directory})`, + "", + ...(long.length > 0 + ? ["Long-running commands found — offer these as background shells:", ...long] + : ["No long-running command found in package.json, a Makefile, a compose file or a Procfile: ask."]), + ...(project.otherScripts.length > 0 + ? [ + `Other package.json scripts (they end on their own): ${project.otherScripts.slice(0, 20).join(", ")}`, + ] + : []), + ...(project.packageManager ? [`Package manager: ${project.packageManager}`] : []), + `Ticket keys in branch names and the last 200 commits: ${tickets.length > 0 ? tickets.join(", ") : "none seen — ask"}`, + `Git remotes: ${project.remotes.length > 0 ? project.remotes.map((each) => `${each.repo} (${each.name})`).join(", ") : "none"}`, + "", + `### AGENTS.md — where the conventions go, as one marked section written with ${CONVENTIONS_TOOL}`, + "", + ...facts.instructions.flatMap(instructionLines), + "- The project file is for this repository's conventions (the team's too, if committed); the global one for every project.", + `- Ask before writing. ${CONVENTIONS_TOOL} replaces the section in place and keeps the rest of the file byte for byte.`, + ].join("\n") +} + +/** What `cockpit_conventions` answers: what it did, where, and the section as it now reads. */ +export function conventionsReply(path: string, written: Extract): string { + const did: Record = { + created: `Created ${path} with the Cockpit section.`, + added: `Added the Cockpit section at the end of ${path}; everything before it is unchanged.`, + updated: `Updated the Cockpit section in ${path}; everything outside it is unchanged.`, + unchanged: `${path} already had exactly this section: nothing written.`, + removed: + written.text === undefined + ? `Removed the Cockpit section; ${path} held nothing else, so it was deleted.` + : `Removed the Cockpit section from ${path}; everything else is unchanged.`, + absent: `${path} has no Cockpit section: nothing to remove.`, + } + const found = written.text ? findSections(written.text) : undefined + const body = found?.ok ? found.sections[0]?.body : undefined + return [ + did[written.action], + ...(written.merged > 0 ? [`It had ${written.merged + 1} Cockpit sections; they are one now.`] : []), + ...(body ? ["", "The section now:", "", sectionText(body)] : []), + "", + "OpenCode reads AGENTS.md when a conversation starts: it applies to new conversations.", + ].join("\n") +} diff --git a/packages/client/src/sidebar.ts b/packages/client/src/sidebar.ts index 661347ac..2138511e 100644 --- a/packages/client/src/sidebar.ts +++ b/packages/client/src/sidebar.ts @@ -1,65 +1,15 @@ -import { readFileSync } from "node:fs" -import { homedir } from "node:os" -import { join } from "node:path" import type { Host } from "./host.ts" /** - * Where a bay's block sits in OpenCode's sidebar. + * Where the bays' blocks sit in OpenCode's sidebar: one list in Cockpit's settings orders them all, * - * Every bay used to take its place from its own setting (Shell's `ui.sidebarOrder`, Status's - * `statusline.sidebarOrder`…), so putting the statusline first meant knowing three numbers. Now one - * list in Cockpit's config orders them all: + * { "sidebar": ["status", "subagents", "shell", "trail", "trust"] } * - * { "sidebar": ["status", "subagents", "shell"] } - * - * in `~/.config/opencode-cockpit/config.json`, or a project's `.cockpit.json` (which wins). A bay's - * own explicit number still beats the list, and a bay the list leaves out keeps its default. - * - * Read once, at start — never in a draw path. + * and each bay takes its place from `baySettings(bay, …).order` (`orderOf` in settings.ts, which also + * says where the numbers sit among OpenCode's own blocks). What is left here is making both OpenCodes + * draw that order. */ -/** Positions the list hands out: before every default (Status 140, Subagents 150, Shell 170). */ -const FIRST = 100 -const STEP = 10 - -function readList(file: string): string[] | undefined { - try { - const parsed = JSON.parse(readFileSync(file, "utf8")) as { sidebar?: unknown } - return Array.isArray(parsed.sidebar) - ? parsed.sidebar.filter((name): name is string => typeof name === "string") - : undefined - } catch { - return undefined - } -} - -export interface SidebarWhere { - /** The project directory, for its `.cockpit.json`. */ - directory?: string - env?: Record - home?: string -} - -/** The configured order, project over global; undefined when neither sets one. */ -export function sidebarList(where: SidebarWhere = {}): string[] | undefined { - const env = where.env ?? process.env - const home = where.home ?? homedir() - const global = join(env.XDG_CONFIG_HOME ?? join(home, ".config"), "opencode-cockpit", "config.json") - const project = where.directory ? readList(join(where.directory, ".cockpit.json")) : undefined - return project ?? readList(global) -} - -export function sidebarOrder( - bay: string, - fallback: number, - explicit: number | undefined, - where: SidebarWhere = {}, -): number { - if (typeof explicit === "number") return explicit - const at = sidebarList(where)?.indexOf(bay) ?? -1 - return at >= 0 ? FIRST + at * STEP : fallback -} - type Register = Host["slots"]["register"] type Registration = Parameters[0] diff --git a/packages/client/src/surfaces.ts b/packages/client/src/surfaces.ts new file mode 100644 index 00000000..53ada9aa --- /dev/null +++ b/packages/client/src/surfaces.ts @@ -0,0 +1,116 @@ +/** + * The one Cockpit-wide line in the system prompt: where the user sees what the agent made. + * + * Each bay knows only itself — "your background shells, in the sidebar" — and hands that fragment + * over as `ServerParts.surfaces`. The line is written once for the whole window from every bay that + * is loaded and on, so a model told "the user sees this in the sidebar" points there instead of + * pasting a list the user already has on screen. Once, not per bay: the bundle composes its bays' + * fragments into one entry, and separate installs share a registry on the instance's scope, where + * the first entry still loaded says the line for all of them. + */ + +import { DEFAULT_KEYS } from "./catalog.ts" +import type { Bay } from "./settings.ts" + +/** What one bay shows the user, and where. */ +export interface Surface { + /** As the line names it: `your background shells`, `review threads`. */ + what: string + /** Where it is drawn; the sidebar by default. Those in the sidebar are named together. */ + where?: string + /** How to open it, as the user types it: `ctrl+x o`, `/trail`. */ + open?: string +} + +const IN_SIDEBAR = "the sidebar" + +/** `a`, `a and b`, `a, b and c`. */ +function listed(items: readonly string[]): string { + return items.length <= 1 ? (items[0] ?? "") : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}` +} + +const named = (surface: Surface) => (surface.open ? `${surface.what} (${surface.open})` : surface.what) + +/** + * The line, or nothing when no bay shows anything: + * + * The user sees your background shells (ctrl+x o) and subagents (ctrl+x d) in the sidebar, and + * review threads in Review (ctrl+x v): point them there instead of pasting those lists. + */ +export function surfacesLine(surfaces: readonly Surface[]): string | undefined { + if (surfaces.length === 0) return undefined + const places = new Map() + for (const surface of surfaces) { + const where = surface.where ?? IN_SIDEBAR + places.set(where, [...(places.get(where) ?? []), surface]) + } + /** A place of its own is what the key opens: `review threads in Review (ctrl+x v)`. */ + const parts = [...places].map(([where, here]) => + here.length === 1 && where !== IN_SIDEBAR && here[0]?.open + ? `${here[0].what} in ${where} (${here[0].open})` + : `${listed(here.map(named))} in ${where}`, + ) + /** Each part may hold an "and" of its own, so the parts are joined with a comma before theirs. */ + const all = parts.length === 1 ? parts[0] : `${parts.slice(0, -1).join(", ")}, and ${parts.at(-1)}` + return `The user sees ${all}: point them there instead of pasting those lists.` +} + +/** + * A key as the user presses it. `` is OpenCode's prefix, `ctrl+x` unless they changed it + * in OpenCode's own config, which the agent side does not read. The first of several keys; `none` + * (a key turned off) is no key. + */ +export function keyText(key: string | undefined): string | undefined { + const first = key?.split(",")[0]?.trim() + if (!first || first === "none") return undefined + return first.replace(/^\s*/, "ctrl+x ") +} + +/** + * How the user opens a bay's view: its key as the settings have it (the bay's default unless + * `keybinds` changes it), or its slash command when the key is turned off. + */ +export function openText( + bay: Bay, + command: string, + keybinds: Readonly> | undefined, + slash: string, +): string { + return keyText(keybinds?.[command] ?? DEFAULT_KEYS[bay]?.[command]) ?? `/${slash}` +} + +// ── once per window ─────────────────────────────────────────────────────────────────────────── + +const REGISTRY = Symbol.for("opencode-cockpit.surfaces") + +type Entry = { surfaces: readonly Surface[] } +type Registry = WeakMap + +function registry(): Registry { + const host = globalThis as { [REGISTRY]?: Registry } + host[REGISTRY] ??= new WeakMap() + return host[REGISTRY] +} + +export interface SurfaceEntry { + /** The line for every entry in the scope, when this entry is the one that says it; else nothing. */ + line(): string | undefined + /** Leave the scope: a reloaded plugin registers again, and the next entry says the line. */ + release(): void +} + +/** One plugin entry's fragments, registered with every other Cockpit entry of the same OpenCode. */ +export function registerSurfaces(scope: object, surfaces: readonly Surface[]): SurfaceEntry { + const reg = registry() + const entries = reg.get(scope) ?? [] + reg.set(scope, entries) + const entry: Entry = { surfaces } + entries.push(entry) + return { + line: () => (entries[0] === entry ? surfacesLine(entries.flatMap((each) => each.surfaces)) : undefined), + release: () => { + const at = entries.indexOf(entry) + if (at >= 0) entries.splice(at, 1) + }, + } +} diff --git a/packages/client/test/catalog.test.ts b/packages/client/test/catalog.test.ts new file mode 100644 index 00000000..10298379 --- /dev/null +++ b/packages/client/test/catalog.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { join } from "node:path" +import { BAY_ABOUT, bayKeys, settingsReference } from "../src/catalog.ts" +import { readSkill } from "../src/server.ts" +import { BAYS, SHARED_DEFAULTS } from "../src/settings.ts" +import { + CONVENTIONS_TOOL, + HOST_BLOCKS, + SETTINGS_TOOL, + SETUP_SKILL, + SETUP_SKILL_DIR, + settingsReport, +} from "../src/setup.ts" + +/** + * The `cockpit-setup` skill is static text shipped beside code that changes. These are what keep it + * honest: its reference is exactly what the catalog writes, every starting point it offers is a file + * the loader reads without a notice, and the names it uses are the code's. + */ + +const skill = readFileSync(join(SETUP_SKILL_DIR, "SKILL.md"), "utf8") +const reference = readFileSync(join(SETUP_SKILL_DIR, "references", "settings.md"), "utf8") + +describe("the reference", () => { + test("is what the catalog writes — run `bun packages/client/src/cli/reference.ts` when it is not", () => { + expect(reference).toBe(settingsReference()) + }) + + test("every bay has a section, and every shared default is the loader's", () => { + for (const bay of BAYS) { + expect(reference).toContain(`## \`${bay}\` — ${BAY_ABOUT[bay]}`) + for (const info of bayKeys(bay)) { + if (info.key in SHARED_DEFAULTS[bay] && info.key !== "keybinds" && !info.defaultText) + expect(info.default).toEqual( + SHARED_DEFAULTS[bay][info.key as keyof (typeof SHARED_DEFAULTS)[typeof bay]], + ) + } + } + }) +}) + +describe("the skill", () => { + test("is named for the command, and its description says when to use it", () => { + const read = readSkill({ dir: SETUP_SKILL_DIR }) + expect(read?.name).toBe(SETUP_SKILL) + const description = read?.description ?? "" + expect(description.length).toBeLessThan(1024) + for (const trigger of [ + "/cockpit-setup", + "sidebar", + "quieter", + "hide the shells block", + "configure cockpit", + ]) + expect(description).toContain(trigger) + }) + + test("calls the tool by its name, and points at a reference that exists", () => { + expect(skill).toContain(`\`${SETTINGS_TOOL}\``) + expect(skill).toContain("(references/settings.md)") + }) + + test("names OpenCode's own blocks as each version spells them", () => { + expect(skill).toContain(`"plugin_enabled": { "${HOST_BLOCKS[1].context}": false }`) + expect(skill).toContain(`"-${HOST_BLOCKS[2].context}"`) + expect(skill).toContain("never suggest turning it off") + for (const id of [...Object.values(HOST_BLOCKS[1]), ...Object.values(HOST_BLOCKS[2])]) + expect(skill).toContain(`\`${id}\``) + }) + + test("the second phase: offered after the close, through its tool, with its reference", () => { + expect(skill.indexOf("tune it to how you work")).toBeGreaterThan(skill.indexOf("## 8. Close")) + expect(skill).toContain("`tune: true`") + expect(skill).toContain(`\`${CONVENTIONS_TOOL}\``) + expect(skill).toContain("(references/conventions.md)") + }) + + /** The JSON blocks under "Offer a starting point", as the skill writes them. */ + const presets = (() => { + const section = skill.slice(skill.indexOf("## 3."), skill.indexOf("## 4.")) + /** Each starting point is a paragraph opening with its name in bold, then its JSON. */ + return section + .split(/^(?=\*\*[^*]+\*\* — )/m) + .slice(1) + .map((part) => ({ + name: /^\*\*([^*]+)\*\*/.exec(part)?.[1] as string, + json: JSON.parse(/```json\n([\s\S]*?)```/.exec(part)?.[1] ?? "null") as Record, + })) + })() + + test("offers the starting points it describes", () => { + expect(presets.map((preset) => preset.name)).toEqual([ + "Everything visible", + "Quiet", + "Minimal", + "Classic", + ]) + }) + + test("every starting point is a file Cockpit reads without a single notice", () => { + for (const preset of presets) { + const report = settingsReport({ + opencode: 2, + directory: "/work/app", + env: {}, + home: "/home/me", + read: (path) => { + if (path.endsWith("/opencode/opencode.json")) + return JSON.stringify({ plugins: ["opencode-cockpit"] }) + if (path.endsWith("/opencode-cockpit/config.json")) return JSON.stringify(preset.json) + return undefined + }, + }) + expect({ preset: preset.name, notices: report.notices }).toEqual({ preset: preset.name, notices: [] }) + } + }) + + test("the starting points do what they say", () => { + const blocks = (json: Record) => { + const report = settingsReport({ + opencode: 2, + directory: "/work/app", + env: {}, + home: "/home/me", + read: (path) => { + if (path.endsWith("/opencode/opencode.json")) + return JSON.stringify({ plugins: ["opencode-cockpit"] }) + if (path.endsWith("/opencode-cockpit/config.json")) return JSON.stringify(json) + return undefined + }, + }) + const sidebar = (bay: string) => + report.bays.find((state) => state.bay === bay)?.keys.find((key) => key.info.key === "sidebar")?.value + return { subagents: sidebar("subagents"), shell: sidebar("shell"), status: sidebar("status") } + } + const [, , minimal, classic] = presets + expect(blocks(minimal?.json ?? {})).toEqual({ subagents: false, shell: false, status: true }) + expect(blocks(classic?.json ?? {}).status).toBe(false) + }) +}) diff --git a/packages/client/test/checks.test.ts b/packages/client/test/checks.test.ts new file mode 100644 index 00000000..8dd7fb28 --- /dev/null +++ b/packages/client/test/checks.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { + bayNotices, + everyNotice, + offerSettingsCheck, + type SettingsCheck, + uniqueNotices, +} from "../src/checks.ts" +import { loadSettings, type SettingsNotice } from "../src/settings.ts" +import { settingsReport, settingsText } from "../src/setup.ts" + +/** + * "Notices: none" from `cockpit_settings` has to mean no `!` row in any block. The audit's case: the + * agent fixed what the loader saw and said "none", and after a restart Status still warned about an + * override matching no segment and Trust about a threshold written as a string — notices only the + * bays drew. + */ + +const GLOBAL = "/home/me/.config/opencode-cockpit/config.json" +const OPENCODE = "/home/me/.config/opencode/opencode.json" +const CHECKS = Symbol.for("opencode-cockpit.settings-checks") + +afterEach(() => { + ;(globalThis as { [CHECKS]?: Map })[CHECKS]?.clear() +}) + +const files = (cockpit: unknown) => { + const all: Record = { + [OPENCODE]: { plugins: ["opencode-cockpit@0.9.0"] }, + [GLOBAL]: cockpit, + } + return (path: string) => (all[path] === undefined ? undefined : JSON.stringify(all[path])) +} +const where = (cockpit: unknown) => ({ + directory: "/work/app", + env: {}, + home: "/home/me", + read: files(cockpit), +}) +const report = (cockpit: unknown) => + settingsText( + settingsReport({ opencode: 2, directory: "/work/app", env: {}, home: "/home/me", read: files(cockpit) }), + ) + +/** Stands in for Status's own check (`statusNotices`), which this package cannot import. */ +const statusCheck: SettingsCheck = ({ settings }) => { + const override = settings.sections.status.override as Record | undefined + return Object.keys(override ?? {}) + .filter((name) => name === "gti") + .map((name) => ({ + bay: "status", + file: GLOBAL, + kind: "invalid", + text: `override "${name}" matches no segment in the sidebar preset — did you mean "git"?`, + })) +} + +describe("cockpit_settings lists what the bays draw", () => { + const broken = { status: { override: { gti: false } }, trust: { threshold: "3" } } + + test("a bay's own word (Status's override) and a key of the wrong kind (Trust's threshold)", () => { + offerSettingsCheck("status", statusCheck) + const text = report(broken) + expect(text).toContain("## Fix these first (2)") + expect(text).toContain(`- ${GLOBAL}: override "gti" matches no segment in the sidebar preset`) + expect(text).toContain(`- ${GLOBAL}: "trust.threshold" should be a number; the default is used.`) + expect(text.split("\n")[2]).toBe("## Fix these first (2)") + }) + + test("fixed, it says none", () => { + offerSettingsCheck("status", statusCheck) + expect(report({ status: { override: { git: false } }, trust: { threshold: 3 } }).split("\n")[2]).toBe( + "Notices: none. Every setting written is read.", + ) + }) + + test("a bay that offered no check still has its keys' kinds checked", () => { + expect(report(broken)).toContain('"trust.threshold" should be a number') + }) +}) + +describe("bayNotices", () => { + test("each notice once: the loader's are in the bay's list too", () => { + const settings = loadSettings(where({ status: { maxRows: 3 } })) + const notices = bayNotices("status", settings, undefined, ({ settings }) => settings.notices) + expect(notices.filter((notice) => notice.text.includes("status.maxRows"))).toHaveLength(1) + }) + + test("plugin options are checked as the bay reads them", () => { + const settings = loadSettings(where({})) + expect(bayNotices("trust", settings, { threshold: "3" })).toEqual([ + { + bay: "trust", + file: "plugin options", + kind: "invalid", + old: "threshold", + text: '"threshold" should be a number; the default is used', + }, + ]) + }) + + test("a check that throws costs only its own notices", () => { + const settings = loadSettings(where({ trust: { threshold: "3" } })) + const notices = bayNotices("trust", settings, undefined, () => { + throw new Error("boom") + }) + expect(notices.map((notice) => notice.text)).toEqual([ + '"trust.threshold" should be a number; the default is used', + ]) + }) +}) + +test("everyNotice: the ones that belong to no bay, then every bay's, each once", () => { + offerSettingsCheck("status", statusCheck) + const settings = loadSettings(where({ statusbar: {}, ...{ status: { override: { gti: false } } } })) + const texts = everyNotice(settings).map((notice) => notice.text) + expect(texts[0]).toContain('"statusbar" is not a setting') + expect(texts.some((text) => text.startsWith('override "gti"'))).toBe(true) + expect(new Set(texts).size).toBe(texts.length) +}) + +test("uniqueNotices keeps the first of each", () => { + const one: SettingsNotice = { bay: "trust", file: GLOBAL, kind: "invalid", text: "x" } + expect(uniqueNotices([one, { ...one }, { ...one, file: "plugin options" }])).toHaveLength(2) +}) diff --git a/packages/client/test/conventions.test.ts b/packages/client/test/conventions.test.ts new file mode 100644 index 00000000..9780782e --- /dev/null +++ b/packages/client/test/conventions.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, test } from "bun:test" +import { + findSections, + projectFacts, + readInstructions, + repoOf, + SECTION_END, + SECTION_HEADING, + SECTION_START, + sectionText, + ticketPrefixes, + writeSection, +} from "../src/conventions.ts" + +/** + * The second phase of /cockpit-setup writes into someone's own instructions file. What is tested is + * the promise the tool makes: one section, replaced where it is, and every byte around it kept. + */ + +const USER = "# My project\n\nUse tabs.\r\nNever push to main.\n" + +const write = (text: string | undefined, body: string) => { + const result = writeSection(text, body) + if (!result.ok) throw new Error(result.error) + return result +} + +describe("the section", () => { + test("no file: created with the section alone", () => { + const result = write(undefined, "- Start `bun run dev` as a background shell named `dev`.") + expect(result.action).toBe("created") + expect(result.text).toBe( + `${SECTION_START}\n${SECTION_HEADING}\n\n- Start \`bun run dev\` as a background shell named \`dev\`.\n${SECTION_END}\n`, + ) + }) + + test("added at the end, the person's text kept byte for byte", () => { + const result = write(USER, "- a") + expect(result.action).toBe("added") + expect(result.text?.startsWith(USER)).toBe(true) + expect(result.text?.slice(USER.length)).toBe(`\n${sectionText("- a")}\n`) + }) + + test("a file without a final newline gets a blank line before the section, nothing else changes", () => { + const result = write("no newline", "- a") + expect(result.text).toBe(`no newline\n\n${sectionText("- a")}\n`) + }) + + test("a rerun replaces it in place, and writing the same again is a no-op", () => { + const first = write(USER, "- a").text as string + const after = `${first}\n## Theirs, below ours\n` + const second = write(after, "- b\n- c") + expect(second.action).toBe("updated") + expect(second.text).toBe(after.replace(sectionText("- a"), sectionText("- b\n- c"))) + expect(findSections(second.text as string)).toEqual({ + ok: true, + sections: [expect.objectContaining({ body: "- b\n- c" })], + }) + const third = write(second.text, "- b\n- c") + expect(third.action).toBe("unchanged") + expect(third.text).toBe(second.text) + }) + + test("a heading the agent included is not doubled", () => { + const result = write(undefined, `${SECTION_HEADING}\n\n- a`) + expect(result.text?.split(SECTION_HEADING).length).toBe(2) + }) + + test("two sections become one, where the first was", () => { + const doubled = `top\n\n${sectionText("- a")}\n\nmiddle\n\n${sectionText("- old")}\n` + const result = write(doubled, "- new") + expect(result.merged).toBe(1) + expect(result.text).toBe(`top\n\n${sectionText("- new")}\n\nmiddle\n`) + }) + + test("an empty body removes it, undoing the add exactly", () => { + const added = write(USER, "- a").text + const removed = write(added, "") + expect(removed.action).toBe("removed") + expect(removed.text).toBe(USER) + }) + + test("removing the only content deletes the file", () => { + expect(write(write(undefined, "- a").text, " ").text).toBeUndefined() + }) + + test("CRLF files stay CRLF", () => { + const result = write("a\r\nb\r\n", "- x\n- y") + expect(result.text).toBe(`a\r\nb\r\n\r\n${sectionText("- x\n- y", "\r\n")}\r\n`) + }) + + test("a start marker with no end is refused, naming its line", () => { + const result = writeSection(`one\ntwo\n${SECTION_START}\n- a\n`, "- b") + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error).toContain("line 3") + }) +}) + +describe("the AGENTS.md files", () => { + const files: Record = {} + const read = (path: string) => files[path] + + test("each scope's path, whether it exists and what its section says", () => { + files["/work/app/AGENTS.md"] = `# App\n\n${sectionText("- tickets are COM-…")}\n` + const [project, global] = readInstructions(2, "/work/app", {}, "/home/me", read) + expect(project).toEqual({ + scope: "project", + path: "/work/app/AGENTS.md", + exists: true, + sections: 1, + body: "- tickets are COM-…", + }) + expect(global).toEqual({ + scope: "global", + path: "/home/me/.config/opencode/AGENTS.md", + exists: false, + sections: 0, + }) + }) + + test("on OpenCode 1, a new AGENTS.md would stop it reading CLAUDE.md: said", () => { + const only = { "/work/app/CLAUDE.md": "x", "/home/me/.claude/CLAUDE.md": "y" } as Record + const [project, global] = readInstructions(1, "/work/app", {}, "/home/me", (path) => only[path]) + expect(project?.shadows).toBe("/work/app/CLAUDE.md") + expect(global?.shadows).toBe("/home/me/.claude/CLAUDE.md") + const [v2] = readInstructions(2, "/work/app", {}, "/home/me", (path) => only[path]) + expect(v2?.shadows).toBeUndefined() + }) +}) + +describe("the project", () => { + const tree = (files: Record) => (path: string) => files[path.replace("/work/app/", "")] + const noGit = () => undefined + + test("package.json: servers and watchers, run with the lockfile's manager; the rest listed apart", () => { + const facts = projectFacts( + "/work/app", + tree({ + "package.json": JSON.stringify({ + scripts: { dev: "vite", "test:watch": "vitest --watch", test: "vitest run", build: "vite build" }, + }), + "bun.lock": "", + }), + noGit, + ) + expect(facts.packageManager).toBe("bun") + expect(facts.longRunning).toEqual([ + { command: "bun run dev", from: 'package.json "dev": "vite"', name: "dev" }, + { + command: "bun run test:watch", + from: 'package.json "test:watch": "vitest --watch"', + name: "test:watch", + }, + ]) + expect(facts.otherScripts).toEqual(["test", "build"]) + }) + + test("a Makefile's dev target, a compose file with its services, a Procfile", () => { + const facts = projectFacts( + "/work/app", + tree({ + Makefile: "build:\n\tgo build\ndev:\n\tair\n", + "compose.yaml": + "services:\n db:\n image: postgres\n cache:\n image: redis\nvolumes:\n data:\n", + Procfile: "web: bin/rails server\n", + }), + noGit, + ) + expect(facts.longRunning.map((each) => each.command)).toEqual([ + "make dev", + "docker compose up", + "bin/rails server", + ]) + expect(facts.longRunning[1]?.from).toBe("compose.yaml (services: db, cache)") + expect(facts.packageManager).toBeUndefined() + }) + + test("ticket keys from history, most used first; standards are not tickets", () => { + expect( + ticketPrefixes([ + "COM-12 fix", + "feat/COM-40-login", + "ENG-3 a", + "ENG-4 b", + "UTF-8 everywhere", + "UTF-8", + "COM-1", + ]), + ).toEqual([ + { prefix: "COM", count: 3, example: "COM-12" }, + { prefix: "ENG", count: 2, example: "ENG-3" }, + ]) + expect(ticketPrefixes(["ABC-1 once"])).toEqual([]) + }) + + test("remotes, with credentials left out", () => { + expect(repoOf("git@github.com:acme/app.git")).toBe("github.com/acme/app") + expect(repoOf("https://bot:ghp_secret@github.com/acme/app.git")).toBe("github.com/acme/app") + const facts = projectFacts("/work/app", tree({}), (args) => + args[0] === "remote" + ? "origin\thttps://x:tok@github.com/acme/app.git (fetch)\norigin\thttps://x:tok@github.com/acme/app.git (push)\n" + : undefined, + ) + expect(facts.remotes).toEqual([{ name: "origin", repo: "github.com/acme/app" }]) + }) +}) diff --git a/packages/client/test/design.test.ts b/packages/client/test/design.test.ts index 259e7abe..640c8947 100644 --- a/packages/client/test/design.test.ts +++ b/packages/client/test/design.test.ts @@ -1,18 +1,23 @@ import { describe, expect, test } from "bun:test" import { + blockShown, checkbox, closeHint, duration, + EMPTY_TEXT, + emptyBlock, fitHints, gaugeTone, type Hint, type HintRun, + headingRows, hintRuns, keyName, labelCase, STATE_TONE, stateMark, summaryRuns, + warnRows, } from "../src/design.ts" /** The shared grammar: one tone per state, and a key row that keeps its way out and says what it cut. */ @@ -172,3 +177,52 @@ describe("a block's heading", () => { expect(said(counts, 5)).toBe("1 needs you") }) }) + +/** + * Presence over silence: an empty block draws its heading and `none yet` in the slot its first item + * takes, so the first item replaces the line and the blocks below stay where they are. + */ +describe("an empty sidebar block", () => { + const lines = (rows: readonly (readonly HintRun[])[]) => rows.map((each) => text(each)) + + test("is the heading and one muted row, every row exactly the column's width", () => { + const rows = emptyBlock("Shells", 30) + expect(lines(rows)).toEqual(["Shells".padEnd(30), " ".repeat(30), EMPTY_TEXT.padEnd(30)]) + expect(rows[0]?.[0]).toMatchObject({ text: "Shells", bold: true }) + expect(rows.at(-1)?.[0]).toMatchObject({ text: "none yet", tone: "muted" }) + }) + + test("a warning wraps at spaces and keeps its fix", () => { + const rows = warnRows('settings: "statusline" is no longer read — run /cockpit-setup', 30) + expect(lines(rows)).toEqual([ + '! settings: "statusline" is no', + " longer read — run".padEnd(30), + " /cockpit-setup".padEnd(30), + ]) + expect(rows[0]?.[0]).toMatchObject({ text: "! ", tone: "warning" }) + expect(lines(warnRows("one two three four five six", 9, 2))).toEqual(["! one two", " three…".padEnd(9)]) + }) + + test("is as tall as the same block with one single-row item", () => { + const withOne = [ + ...headingRows("Shells", [{ text: "1 running", tone: "accent" }], 30), + [{ text: "â ¹ dev" }], + ] + expect(emptyBlock("Shells", 30)).toHaveLength(withOne.length) + }) + + test("draws nothing when asked to hide, and only when empty", () => { + expect(emptyBlock("Shells", 30, true)).toEqual([]) + expect(blockShown(0, true)).toBe(false) + expect(blockShown(0)).toBe(true) + expect(blockShown(1, true)).toBe(true) + }) + + test("a heading keeps its summary only while there is room, and never runs past the column", () => { + const summary = [{ text: "2 running", tone: "accent" as const }] + expect(lines(headingRows("Subagents", summary, 24))[0]).toBe("Subagents 2 running") + expect(lines(headingRows("Subagents", summary, 18))[0]).toBe("Subagents ") + expect(lines(headingRows("Subagents", [], 6))[0]).toBe("Subag…") + expect(lines(emptyBlock("Trail", 4)).every((each) => each.length === 4)).toBe(true) + }) +}) diff --git a/packages/client/test/jsonc.test.ts b/packages/client/test/jsonc.test.ts new file mode 100644 index 00000000..6fbe6f8b --- /dev/null +++ b/packages/client/test/jsonc.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test" +import { parseJsonc } from "../src/jsonc.ts" + +describe("parseJsonc", () => { + test("drops comments and trailing commas, and leaves strings alone", () => { + const text = `{ + // the plugins + "$schema": "https://opencode.ai/config.json", /* a URL is not a comment */ + "plugin": ["a@1.0.0", "b,}",], + }` + expect(parseJsonc(text)).toEqual({ + ok: true, + value: { $schema: "https://opencode.ai/config.json", plugin: ["a@1.0.0", "b,}"] }, + }) + }) + + test("an escaped quote does not end a string", () => { + expect(parseJsonc(`{"a": "say \\"hi\\" // not a comment"}`)).toEqual({ + ok: true, + value: { a: 'say "hi" // not a comment' }, + }) + }) + + test("broken JSON is a failure with a message, not an empty config", () => { + const result = parseJsonc(`{"plugin": [}`) + expect(result.ok).toBe(false) + }) +}) diff --git a/packages/client/test/opener.test.ts b/packages/client/test/opener.test.ts new file mode 100644 index 00000000..3fd9eddb --- /dev/null +++ b/packages/client/test/opener.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test" +import { openerFor, openerName, systemOpenerWhere } from "../src/opener.ts" + +/** The argv each platform gets, decided without running anything: `have` is what this fake machine has. */ +const where = (platform: string, have: string[] = []) => ({ + platform, + exists: (path: string) => have.includes(path), + which: (name: string) => (have.includes(name) ? `/somewhere/${name}` : undefined), +}) + +describe("the opener for each platform (Trail's links, Review's images)", () => { + const url = "https://github.com/a/b/pull/1" + + test("macOS: /usr/bin/open first, then PATH, else none", () => { + expect(openerFor(url, where("darwin", ["/usr/bin/open", "open"]))).toEqual({ + command: "/usr/bin/open", + args: [url], + }) + expect(openerFor(url, where("darwin", ["open"]))).toEqual({ command: "/somewhere/open", args: [url] }) + expect(openerFor(url, where("darwin"))).toBeUndefined() + }) + + test("Linux: xdg-open from PATH, else none", () => { + expect(openerFor("/repo/shot.png", where("linux", ["xdg-open"]))).toEqual({ + command: "/somewhere/xdg-open", + args: ["/repo/shot.png"], + }) + expect(openerFor(url, where("linux"))).toBeUndefined() + }) + + test("Windows: start through cmd, an empty title first, & kept from cmd where Node leaves it unquoted", () => { + expect(openerFor("https://a.dev/?a=1&b=2", where("win32", ["cmd"]))).toEqual({ + command: "/somewhere/cmd", + args: ["/c", "start", "", "https://a.dev/?a=1^&b=2"], + }) + /** Quoted by Node for its space, where `^` would be read as itself. */ + expect(openerFor("C:\\my shots\\a&b.png", where("win32"))).toEqual({ + command: "cmd", + args: ["/c", "start", "", "C:\\my shots\\a&b.png"], + }) + }) + + test("COCKPIT_OPENER wins on every platform", () => { + for (const platform of ["darwin", "linux", "win32"]) + expect(openerFor(url, { ...where(platform, ["/usr/bin/open"]), override: "/tmp/stub" })).toEqual({ + command: "/tmp/stub", + args: [url], + }) + }) + + test("the program's name, for saying it is missing", () => { + expect(["darwin", "linux", "win32"].map(openerName)).toEqual(["open", "xdg-open", "cmd"]) + }) + + test("this machine: programs from the PATH it is given, the override from the env", () => { + const empty = systemOpenerWhere({ PATH: "" }, "linux") + expect(empty.which("sh")).toBeUndefined() + expect(empty.override).toBeUndefined() + expect(systemOpenerWhere({ PATH: "/usr/bin:/bin" }, "linux").which("sh")).toEndWith("/sh") + expect(systemOpenerWhere({ PATH: "", COCKPIT_OPENER: "/tmp/stub" }).override).toBe("/tmp/stub") + }) +}) diff --git a/packages/client/test/plugin-entries.test.ts b/packages/client/test/plugin-entries.test.ts new file mode 100644 index 00000000..513ca94d --- /dev/null +++ b/packages/client/test/plugin-entries.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test" +import { bayOf, baysOfEntry, pluginEntries } from "../src/plugin-entries.ts" +import { BAYS } from "../src/settings.ts" + +describe("Cockpit's plugin entries (one reader for /cockpit-setup and doctor)", () => { + test("every spelling of an entry, v1's and v2's", () => { + expect( + pluginEntries({ + plugin: ["a@1", ["b", { x: 1 }], [2]], + plugins: [{ package: "c", options: { y: 2 } }, { name: "d" }], + }), + ).toEqual([{ name: "a@1" }, { name: "b", options: { x: 1 } }, { name: "c", options: { y: 2 } }]) + }) + + test("a file that is not an object lists nothing", () => { + for (const value of [undefined, null, "x", [], 3]) expect(pluginEntries(value)).toEqual([]) + }) + + test("a package name is a bay, the bundle, or not ours", () => { + expect(bayOf("opencode-cockpit")).toBe("bundle") + expect(bayOf("@opencode-cockpit/shell")).toBe("shell") + expect(bayOf("@opencode-cockpit/client")).toBeUndefined() + expect(bayOf("@opencode-cockpit/shell@0.9.0")).toBeUndefined() + expect(bayOf(undefined)).toBeUndefined() + }) + + test("an entry as written brings its bays: versions and paths too", () => { + expect(baysOfEntry("opencode-cockpit@0.9.0")).toEqual([...BAYS]) + expect(baysOfEntry("/home/me/src/opencode-cockpit/")).toEqual([...BAYS]) + expect(baysOfEntry("@opencode-cockpit/trail@latest")).toEqual(["trail"]) + expect(baysOfEntry("C:\\x\\node_modules\\@opencode-cockpit\\review")).toEqual(["review"]) + expect(baysOfEntry("@opencode-cockpit/client")).toEqual([]) + expect(baysOfEntry("opencode-foo")).toEqual([]) + }) +}) diff --git a/packages/client/test/server.test.ts b/packages/client/test/server.test.ts index dae141b3..d7a650e6 100644 --- a/packages/client/test/server.test.ts +++ b/packages/client/test/server.test.ts @@ -1,15 +1,24 @@ import { describe, expect, test } from "bun:test" +import { mkdtempSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" import { tool } from "@opencode-ai/plugin" import { silentLog } from "../src/log.ts" import { + addToV1Config, + commandText, composeParts, dualServer, follow, partsToV1Hooks, + readSkill, type ServerParts, serverFromV1, serverFromV2, + type ToolCall, toolToV2, + v1ToolText, + v2ToolCall, } from "../src/server.ts" /** @@ -56,6 +65,65 @@ describe("several features as one", () => { expect(composeParts([])).toEqual({}) expect(partsToV1Hooks(composeParts([{}]))).toEqual({}) }) + + test("skills are listed once, and a command name registered twice throws", () => { + const command = { name: "same", description: "", prompt: "p" } + expect(composeParts([{ skills: [{ dir: "/s" }] }, { skills: [{ dir: "/s" }] }]).skills).toEqual([ + { dir: "/s" }, + ]) + expect(() => composeParts([{ commands: [command] }, { commands: [command] }])).toThrow( + 'command "/same" is registered by more than one cockpit feature', + ) + }) +}) + +describe("skills and commands", () => { + const command = { name: "cockpit-setup", description: "set up", prompt: "Use the cockpit-setup skill." } + + test("OpenCode 1: into its config, beneath what the user wrote, each folder once", async () => { + const hooks = partsToV1Hooks({ skills: [{ dir: "/pkg/skills/a" }], commands: [command] }) + const config = { + command: { other: { template: "x" } }, + skills: { paths: ["/mine", "/pkg/skills/a"] }, + } as Record + await (hooks as { config?: (config: unknown) => Promise }).config?.(config) + expect(config).toEqual({ + command: { + other: { template: "x" }, + "cockpit-setup": { template: "Use the cockpit-setup skill.", description: "set up" }, + }, + skills: { paths: ["/mine", "/pkg/skills/a"] }, + }) + const mine = { command: { "cockpit-setup": { template: "my own" } } } as Record + addToV1Config(mine as never, { commands: [command], skills: [{ dir: "/b" }] }) + expect(mine).toEqual({ + command: { "cockpit-setup": { template: "my own", description: "set up" } }, + skills: { paths: ["/b"] }, + }) + }) + + test("a skill's folder read for OpenCode 2: frontmatter names it, the body is the content", () => { + const dir = mkdtempSync(join(tmpdir(), "ck-skill-")) + writeFileSync( + join(dir, "SKILL.md"), + '---\nname: probe\ndescription: "Does a thing: well"\n---\n\n# Probe\nbody\n', + ) + expect(readSkill({ dir })).toEqual({ + id: "probe", + name: "probe", + description: "Does a thing: well", + path: join(dir, "SKILL.md"), + content: "\n# Probe\nbody\n", + }) + expect(readSkill({ dir: join(dir, "missing") })).toBeUndefined() + }) + + test("a command's line, then whatever was typed after its name", () => { + expect(commandText(command, { sessionID: "s", prompt: { text: "" } })).toBe(command.prompt) + expect(commandText(command, { sessionID: "s", prompt: { text: " hide shells " } })).toBe( + `${command.prompt}\n\nhide shells`, + ) + }) }) describe("on OpenCode 1", () => { @@ -105,21 +173,38 @@ describe("a v1 tool, as v2 registers it", () => { }) /** Enough of a v2 server context to watch what gets registered. */ -function fakeV2(events: { type: string; data?: { sessionID?: string } }[] = []) { +function fakeV2(events: { type: string; data?: { sessionID?: string } }[] = [], directory = "/work/project") { const added: string[] = [] const hooks: string[] = [] + const skills: { id: string; path: string }[] = [] + const commands: { name: string; execute(input: unknown): Promise }[] = [] + const prompts: unknown[] = [] let context: ((event: { sessionID: string; system: unknown[] }) => unknown) | undefined const ctx = { options: {}, - location: { directory: "/work/project" }, + location: { directory }, tool: { transform: async (edit: (editor: { add: (tool: { name: string }) => void }) => void) => { edit({ add: (tool) => added.push(tool.name) }) }, }, + skill: { + transform: async (edit: (editor: unknown) => void) => { + edit({ + get: (id: string) => skills.find((skill) => skill.id === id), + add: (skill: { id: string; path: string }) => skills.push(skill), + }) + }, + }, + command: { + transform: async (edit: (editor: unknown) => void) => { + edit({ add: (command: (typeof commands)[number]) => commands.push(command) }) + }, + }, session: { get: async () => undefined, synthetic: async () => undefined, + prompt: async (input: unknown) => void prompts.push(input), hook: async (name: string, run: typeof context) => { hooks.push(name) context = run @@ -131,7 +216,7 @@ function fakeV2(events: { type: string; data?: { sessionID?: string } }[] = []) }, }, } - return { ctx, added, hooks, system: () => context } + return { ctx, added, hooks, skills, commands, prompts, system: () => context } } describe("one entry for both", () => { @@ -163,7 +248,7 @@ describe("one entry for both", () => { } }) const cleanup = await entry.setup(fake.ctx as never) - expect(fake.added).toEqual(["echo"]) + expect(fake.added).toContain("echo") expect(fake.hooks).toEqual(["context"]) const event = { sessionID: "ses_1", system: [] as unknown[] } await fake.system()?.(event) @@ -174,6 +259,29 @@ describe("one entry for both", () => { expect(disposed).toBe(true) }) + test("v2's setup adds the setup tool, skill and command once, and a command prompts queued", async () => { + const one = fakeV2([], "/work/setup-v2") + const two = fakeV2([], "/work/setup-v2") + const entry = dualServer("cockpit.test", async () => ({})) + const cleanup = await entry.setup(one.ctx as never) + await entry.setup(two.ctx as never) + expect(one.added).toEqual(["cockpit_settings", "cockpit_conventions"]) + expect(one.skills.map((skill) => skill.id)).toEqual(["cockpit-setup"]) + expect(one.skills[0]?.path).toEndWith("skills/cockpit-setup/SKILL.md") + expect(one.commands.map((command) => command.name)).toEqual(["cockpit-setup"]) + expect(two.added).toEqual([]) + /** v2 hands a command `steer` even when idle; it is sent queued, which starts at once when idle. */ + await one.commands[0]?.execute({ sessionID: "ses_1", prompt: { text: "" }, delivery: "steer" }) + expect(one.prompts).toEqual([ + { + sessionID: "ses_1", + text: "Use the cockpit-setup skill to help me set up Cockpit.", + delivery: "queue", + }, + ]) + await cleanup?.() + }) + test("two copies in one OpenCode 2 share a claim scope, so a feature loads once", async () => { const scopes: object[] = [] const entry = dualServer("cockpit.test", async (host) => { @@ -261,3 +369,128 @@ describe("messaging a session that may be busy", () => { ]) }) }) + +/** + * A finished tool call, heard the same on both versions (docs/opencode/trail-server.md): v1's MCP + * output sits in another field, and v2 fires twice for a Code Mode call — only the inner one counts. + */ +describe("toolAfter", () => { + test("v1: a built-in's output, and an MCP tool's content, both arrive as text", async () => { + const calls: ToolCall[] = [] + const hooks = partsToV1Hooks({ toolAfter: (call) => void calls.push(call) }) + const after = hooks["tool.execute.after"] + const input = { tool: "bash", sessionID: "ses_1", callID: "call_1", args: { command: "gh pr create" } } + await after?.(input, { title: "", output: "https://github.com/a/b/pull/33\n", metadata: {} }) + await after?.({ ...input, tool: "spike_open_pr", callID: "call_2", args: {} }, { + content: [{ type: "text", text: "Created pull request: x/77" }, { type: "image" }], + } as never) + expect(calls).toEqual([ + { + sessionID: "ses_1", + tool: "bash", + callID: "call_1", + args: input.args, + output: "https://github.com/a/b/pull/33\n", + }, + { + sessionID: "ses_1", + tool: "spike_open_pr", + callID: "call_2", + args: {}, + output: "Created pull request: x/77", + }, + ]) + }) + + test("v1 text, whichever field it is in", () => { + expect(v1ToolText({ output: "a" })).toBe("a") + expect( + v1ToolText({ + content: [ + { type: "text", text: "a" }, + { type: "text", text: "b" }, + ], + }), + ).toBe("a\nb") + expect(v1ToolText(undefined)).toBe("") + }) + + test("v2: the inner call is delivered, Code Mode's outer `execute` and failures are not", () => { + const inner = { + tool: "spike_open_pr", + sessionID: "ses_1", + agent: "general", + id: "call_9", + input: { title: "x" }, + status: "completed", + result: { + output: "Created pull request: x/77", + content: [{ type: "text", text: "Created pull request: x/77" }], + }, + } + expect(v2ToolCall(inner)).toEqual({ + sessionID: "ses_1", + tool: "spike_open_pr", + callID: "call_9", + args: { title: "x" }, + output: "Created pull request: x/77", + agent: "general", + }) + expect(v2ToolCall({ ...inner, tool: "execute" })).toBeUndefined() + expect(v2ToolCall({ ...inner, status: "error", error: "boom" })).toBeUndefined() + /** `shell`'s `output` is an object; `content` carries its text. */ + const shell = { + ...inner, + tool: "shell", + result: { output: { exit: 0 }, content: [{ type: "text", text: "ok" }] }, + } + expect(v2ToolCall(shell)?.output).toBe("ok") + expect(v2ToolCall({ ...inner, result: { output: "only output" } })?.output).toBe("only output") + }) + + test("v2's setup registers execute.after, and a feature's failure never reaches the call", async () => { + const seen: string[] = [] + let run: ((event: unknown) => unknown) | undefined + const fake = fakeV2() + const ctx = { + ...fake.ctx, + tool: { + ...fake.ctx.tool, + hook: async (name: string, handler: (event: unknown) => unknown) => { + seen.push(name) + run = handler + }, + }, + } + const calls: string[] = [] + const entry = dualServer("cockpit.test", async () => ({ + toolAfter: (call) => { + calls.push(call.tool) + if (call.tool === "bad") throw new Error("listener broke") + }, + })) + await entry.setup(ctx as never) + expect(seen).toEqual(["execute.after"]) + const event = (tool: string) => ({ tool, sessionID: "s", id: "c", result: { content: [] } }) + await run?.(event("spike_open_pr")) + await run?.(event("execute")) + await run?.(event("bad")) + expect(calls).toEqual(["spike_open_pr", "bad"]) + }) + + test("composed: every feature hears the call, even after one fails", async () => { + const heard: string[] = [] + const parts = composeParts([ + { + toolAfter: () => { + heard.push("a") + throw new Error("a broke") + }, + }, + { toolAfter: () => void heard.push("b") }, + ]) + const call = { sessionID: "s", tool: "t", callID: "c", args: {}, output: "" } + await expect(parts.toolAfter?.(call) ?? Promise.resolve()).rejects.toThrow("a broke") + expect(heard).toEqual(["a", "b"]) + }) +}) diff --git a/packages/client/test/service.test.ts b/packages/client/test/service.test.ts new file mode 100644 index 00000000..54ac90b5 --- /dev/null +++ b/packages/client/test/service.test.ts @@ -0,0 +1,241 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { claimFeature } from "../src/feature.ts" +import type { Host } from "../src/host.ts" +import { silentLog } from "../src/log.ts" +import { + type AgentRecord, + agentsDir, + type Install, + LOOK_AT_MS, + look, + newer, + parseRecord, + RESTART_COMMAND, + readInstall, + recordAgent, + recordPath, + registerServiceCheck, + type ServiceIo, + serviceFile, + servicePid, + verdict, +} from "../src/service.ts" + +/** + * A window compares the install it loaded with the one OpenCode 2's background service loaded, and + * says so once when the service's is older. What matters is that it never cries wolf: every case + * where it cannot know says nothing. + */ + +const own: Install = { version: "0.9.0", installedAt: 2_000, dir: "/inst/client" } +const record = (over: Partial = {}): AgentRecord => ({ + ...own, + pid: 42, + startedAt: 1_000, + ...over, +}) + +describe("verdict", () => { + test("the service loaded this very install: nothing to say", () => { + expect(verdict({ own, service: 42, record: record() })).toEqual({ stale: false, why: "same" }) + }) + + test("a dev install, same version, written after the service loaded its own: the toast", () => { + const said = verdict({ own, service: 42, record: record({ installedAt: 1_000 }) }) + expect(said).toEqual({ + stale: true, + message: `Cockpit was updated — OpenCode's background service still runs the old one. Run: ${RESTART_COMMAND}`, + }) + }) + + test("an update to a new version names both", () => { + const said = verdict({ own, service: 42, record: record({ version: "0.8.0", installedAt: 500 }) }) + expect(said).toEqual({ + stale: true, + message: `Cockpit was updated to 0.9.0 — OpenCode's background service still runs 0.8.0. Run: ${RESTART_COMMAND}`, + }) + }) + + test("an npm install keeps its files' dates: the version alone tells them apart", () => { + const said = verdict({ + own, + service: 42, + record: record({ version: "0.8.2", installedAt: own.installedAt }), + }) + expect(said.stale).toBe(true) + }) + + test("this window is the older one: not the service's to restart", () => { + expect(verdict({ own, service: 42, record: record({ installedAt: 9_000 }) })).toEqual({ + stale: false, + why: "agent newer", + }) + expect(verdict({ own, service: 42, record: record({ version: "0.9.1" }) })).toEqual({ + stale: false, + why: "agent newer", + }) + }) + + test("never a guess: no install, no service, no record, a record from another process", () => { + expect(verdict({ own: undefined, service: 42, record: record() }).stale).toBe(false) + expect(verdict({ own, service: undefined, record: record() })).toEqual({ + stale: false, + why: "no service", + }) + expect(verdict({ own, service: 42, record: undefined })).toEqual({ stale: false, why: "no record" }) + /** A record left by a service that has since restarted under another pid. */ + expect(verdict({ own, service: 43, record: record({ installedAt: 1 }) })).toEqual({ + stale: false, + why: "no record", + }) + }) +}) + +test("newer compares versions, not strings", () => { + expect(newer("0.10.0", "0.9.1")).toBe(true) + expect(newer("0.9.1", "0.10.0")).toBe(false) + expect(newer("0.9.0", "0.9.0-rc.1")).toBe(true) + expect(newer("0.9.0-rc.2", "0.9.0-rc.1")).toBe(true) + expect(newer("0.9.0", "0.9.0")).toBe(false) +}) + +test("records and service.json are read defensively", () => { + expect(parseRecord(JSON.stringify(record()))).toEqual(record()) + expect(parseRecord("{")).toBeUndefined() + expect(parseRecord(JSON.stringify({ ...record(), pid: "42" }))).toBeUndefined() + expect(servicePid('{"id":"x","url":"http://127.0.0.1:1","pid":51190,"password":"p"}')).toBe(51190) + expect(servicePid('{"pid":-1}')).toBeUndefined() + expect(servicePid(undefined)).toBeUndefined() + expect(serviceFile({ XDG_STATE_HOME: "/s" }, "/h")).toBe("/s/opencode/service.json") + expect(serviceFile({}, "/h")).toBe("/h/.local/state/opencode/service.json") +}) + +test("readInstall reads this package: its version, and when its package.json was written", () => { + const install = readInstall() + const manifest = JSON.parse(readFileSync(join(import.meta.dir, "..", "package.json"), "utf8")) + expect(install?.version).toBe(manifest.version) + expect(install?.installedAt).toBeGreaterThan(0) + expect(readInstall("/nowhere")).toBeUndefined() +}) + +// ── On disk ──────────────────────────────────────────────────────────────────────────────────── + +let dirs: string[] = [] +afterEach(() => { + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }) + dirs = [] +}) +const temp = () => { + const dir = mkdtempSync("/tmp/ck-svc-") + dirs.push(dir) + return dir +} + +/** A machine with a service at `pid` whose record says `rec`. */ +function machine(pid: number | undefined, rec?: AgentRecord): ServiceIo & { root: string } { + const root = temp() + const env = { XDG_STATE_HOME: join(root, "state"), COCKPIT_HOME: join(root, "cockpit") } + if (pid !== undefined) { + mkdirSync(join(root, "state", "opencode"), { recursive: true }) + writeFileSync(serviceFile(env, root), JSON.stringify({ id: "x", pid, password: "secret" })) + } + if (rec) { + mkdirSync(agentsDir(env), { recursive: true }) + writeFileSync(recordPath(agentsDir(env), rec.pid), JSON.stringify(rec)) + } + const read = (path: string) => (existsSync(path) ? readFileSync(path, "utf8") : undefined) + return { root, env, home: root, read, alive: (each) => each === pid } +} + +describe("look", () => { + test("the service's own record, through service.json's pid", () => { + expect(look(own, machine(42, record({ installedAt: 1 }))).stale).toBe(true) + expect(look(own, machine(42, record()))).toEqual({ stale: false, why: "same" }) + }) + + test("a service.json whose pid has ended is no service", () => { + const io = machine(42, record({ installedAt: 1 })) + expect(look(own, { ...io, alive: () => false })).toEqual({ stale: false, why: "no service" }) + }) + + test("no service.json, or no record yet", () => { + expect(look(own, machine(undefined))).toEqual({ stale: false, why: "no service" }) + expect(look(own, machine(42))).toEqual({ stale: false, why: "no record" }) + }) +}) + +test("recordAgent writes this process's install once, and clears records of ended processes", () => { + const dir = temp() + writeFileSync(join(dir, "999999.json"), "{}") + const flag = Symbol.for("opencode-cockpit.agent-recorded") + const shared = globalThis as { [flag]?: boolean } + shared[flag] = false + recordAgent(silentLog, dir) + const written = parseRecord(readFileSync(recordPath(dir, process.pid), "utf8")) + expect(written).toMatchObject({ pid: process.pid, version: readInstall()?.version }) + expect(existsSync(join(dir, "999999.json"))).toBe(false) + /** Once per process: a second entry starting does not write again. */ + rmSync(recordPath(dir, process.pid)) + recordAgent(silentLog, dir) + expect(existsSync(recordPath(dir, process.pid))).toBe(false) +}) + +// ── The window ───────────────────────────────────────────────────────────────────────────────── + +function fakeHost(version: 1 | 2 = 2) { + const toasts: { message: string; variant?: string }[] = [] + const disposers: (() => void)[] = [] + const host = { + version, + renderer: {}, + ui: { toast: (options: { message: string; variant?: string }) => toasts.push(options) }, + lifecycle: { onDispose: (fn: () => void) => disposers.push(fn) }, + log: silentLog, + } as unknown as Host + return { + host, + toasts, + dispose: () => { + for (const fn of disposers) fn() + }, + } +} + +describe("registerServiceCheck", () => { + test("one toast per window, however many Cockpit entries it loads", async () => { + const fake = fakeHost() + const install = readInstall() as Install + const io = machine(42, record({ ...install, installedAt: install.installedAt - 60_000 })) + const timers: (() => void)[] = [] + const real = globalThis.setTimeout + globalThis.setTimeout = ((fn: () => void) => timers.push(fn)) as never + try { + registerServiceCheck(fake.host, "opencode-cockpit", io) + registerServiceCheck({ ...fake.host } as Host, "@opencode-cockpit/status", io) + } finally { + globalThis.setTimeout = real + } + expect(timers).toHaveLength(LOOK_AT_MS.length) + for (const fire of timers) fire() + expect(fake.toasts).toHaveLength(1) + expect(fake.toasts[0]?.message).toContain(RESTART_COMMAND) + fake.dispose() + expect(claimFeature(fake.host.renderer, "service-check", "again").active).toBe(true) + }) + + test("OpenCode 1 has no service: nothing is looked at", () => { + const fake = fakeHost(1) + let read = false + registerServiceCheck(fake.host, "opencode-cockpit", { + ...machine(42), + read: () => { + read = true + return undefined + }, + }) + expect(read).toBe(false) + expect(claimFeature(fake.host.renderer, "service-check", "other").active).toBe(true) + }) +}) diff --git a/packages/client/test/settings.test.ts b/packages/client/test/settings.test.ts new file mode 100644 index 00000000..82eae1be --- /dev/null +++ b/packages/client/test/settings.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, test } from "bun:test" +import { + baySettings, + closestName, + loadSettings, + OLD_NAMES, + orderOf, + type SettingsWhere, +} from "../src/settings.ts" + +/** + * One loader for every bay: the files mean the same thing to all of them, a comment never drops a + * file, an old name says it is not read, and nothing a user writes can throw. + */ + +const GLOBAL = "/home/me/.config/opencode-cockpit/config.json" +const PROJECT = "/work/project/.cockpit.json" + +function where(files: Record): SettingsWhere { + return { + directory: "/work/project", + env: {}, + home: "/home/me", + read: (path) => { + const file = files[path] + return file === undefined ? undefined : typeof file === "string" ? file : JSON.stringify(file) + }, + } +} + +describe("reading the files", () => { + test("comments and trailing commas are fine", () => { + const settings = loadSettings( + where({ + [GLOBAL]: `{ + // mine + "trust": { "threshold": 5, /* five */ }, + "sidebar": ["shell", "status",], + }`, + }), + ) + expect(settings.notices).toEqual([]) + expect(settings.sections.trust).toEqual({ threshold: 5 }) + expect(settings.sidebarList).toEqual(["shell", "status"]) + }) + + test("global, then project, then plugin options; nested objects merge key by key", () => { + const loaded = baySettings( + "shell", + { dockHeight: 14, lifecycle: { onExit: "stopMine", orphanAfterMinutes: 60 } }, + { + where: where({ + [GLOBAL]: { shell: { dockHeight: 16, sidebarRows: 9, lifecycle: { onExit: "keep" } } }, + [PROJECT]: { shell: { dockHeight: 20, keybinds: { "cockpit.shells.dock": "d" } } }, + }), + options: { sidebarRows: 2 }, + }, + ) + expect(loaded.config).toMatchObject({ + dockHeight: 20, + sidebarRows: 2, + enabled: true, + sidebar: true, + hideWhenEmpty: false, + keybinds: { "cockpit.shells.dock": "d" }, + lifecycle: { onExit: "keep", orphanAfterMinutes: 60 }, + }) + }) + + test("plugin options may be a whole config with a section for the bay", () => { + const loaded = baySettings("trust", {}, { where: where({}), options: { trust: { sidebarRows: 7 } } }) + expect(loaded.config.sidebarRows).toBe(7) + }) + + test("a file that cannot be read is a notice, and costs nothing else", () => { + const settings = loadSettings(where({ [GLOBAL]: "{ nope", [PROJECT]: { trust: { threshold: 4 } } })) + expect(settings.files.find((file) => file.path === GLOBAL)?.error).toBeDefined() + expect(settings.notices).toEqual([ + expect.objectContaining({ bay: "cockpit", file: GLOBAL, kind: "unreadable" }), + ]) + expect(settings.sections.trust).toEqual({ threshold: 4 }) + expect(() => loadSettings(where({ [GLOBAL]: "[1, 2]", [PROJECT]: "null" }))).not.toThrow() + }) + + test("a value of the wrong kind falls back to the default, and says where it was", () => { + const loaded = baySettings( + "shell", + { dockHeight: 14 }, + { where: where({ [GLOBAL]: { shell: { dockHeight: "16", sidebarRows: "8", hideWhenEmpty: 1 } } }) }, + ) + expect(loaded.config.dockHeight).toBe(14) + expect(loaded.config.sidebarRows).toBe(5) + expect(loaded.config.hideWhenEmpty).toBe(false) + expect(loaded.notices.map((notice) => notice.text)).toEqual([ + '"shell.sidebarRows" should be a number; the default is used', + '"shell.hideWhenEmpty" should be a boolean; the default is used', + '"shell.dockHeight" should be a number; the default is used', + ]) + expect(loaded.notices.every((notice) => notice.file === GLOBAL)).toBe(true) + }) + + test("the shared defaults: every block present, Trust's opt-in, nothing hidden when empty", () => { + const none = where({}) + expect(baySettings("subagents", {}, { where: none }).config).toEqual({ + enabled: true, + keybinds: {}, + sidebar: true, + sidebarRows: 6, + hideWhenEmpty: false, + }) + expect(baySettings("trail", {}, { where: none }).config.sidebar).toBe(true) + expect(baySettings("trust", {}, { where: none }).config.sidebar).toBe(false) + }) + + test("features.: false in a file turns the bay off", () => { + const loaded = baySettings("review", {}, { where: where({ [GLOBAL]: { features: { review: false } } }) }) + expect(loaded.config.enabled).toBe(false) + }) + + test("a file's root is no bay's settings: Status reads nothing without its section", () => { + const loaded = baySettings( + "status", + { debug: false }, + { where: where({ [GLOBAL]: { enabled: false, debug: true } }) }, + ) + expect(loaded.config.enabled).toBe(true) + expect(loaded.config.debug).toBe(false) + expect(loaded.notices.map((notice) => notice.text)).toEqual([ + '"enabled" at the top level is not read: it belongs in "status"', + '"debug" at the top level is not read: it belongs in "status"', + ]) + }) +}) + +describe("old names", () => { + /** Detection only: the value under an old name is ignored, the default applies, and it says so. */ + test("each one is a notice, its value ignored and the default used", () => { + const loaded = (bay: Parameters[0], file: object, defaults = {}) => + baySettings(bay, defaults, { where: where({ [GLOBAL]: file }) }) + + const status = loaded("status", { statusline: { maxRows: 3, debug: true } }, { debug: false }) + expect(status.config.debug).toBe(false) + expect(status.notices).toEqual([ + { + bay: "status", + file: GLOBAL, + kind: "old", + old: "statusline", + new: "status", + text: '"statusline" is no longer read — run /cockpit-setup', + }, + ]) + + const shell = loaded( + "shell", + { watch: { auto: true }, ui: { dockHeight: 30, sidebarOrder: 1 } }, + { dockHeight: 14 }, + ) + expect(shell.config.dockHeight).toBe(14) + expect(shell.config).not.toHaveProperty("watch") + expect(shell.notices.map((notice) => [notice.old, notice.new])).toEqual([ + ["watch", "shell.watch"], + ["ui.dockHeight", "shell.dockHeight"], + ["ui.sidebarOrder", "sidebar"], + ]) + + const subagents = loaded("subagents", { + subagents: { hideFinishedAfter: 5, hideNestedAfter: 10, sidebarOrder: 3 }, + }) + expect(subagents.config).not.toHaveProperty("hideFinishedAfterMinutes") + expect(subagents.notices.map((notice) => notice.new)).toEqual([ + "subagents.hideFinishedAfterMinutes", + "subagents.hideNestedAfterSeconds", + "sidebar", + ]) + + const maxRows = loaded("status", { status: { maxRows: 3 } }) + expect(maxRows.config.sidebarRows).toBe(8) + expect(maxRows.notices[0]?.new).toBe("status.sidebarRows") + + const updater = loaded("updater", { ui: { updateCheck: false } }) + expect(updater.notices[0]).toMatchObject({ old: "ui.updateCheck", new: "updater.updateCheck" }) + }) + + test("old names in plugin options too", () => { + const loaded = baySettings("subagents", {}, { where: where({}), options: { hideFinishedAfter: 5 } }) + expect(loaded.notices).toEqual([ + expect.objectContaining({ file: "plugin options", old: "hideFinishedAfter", kind: "old" }), + ]) + expect(loaded.written).toEqual({}) + }) + + test("the list is there for /cockpit-setup's brief", () => { + expect(OLD_NAMES).toContainEqual({ old: "statusline", new: "status" }) + expect(OLD_NAMES).toContainEqual({ old: ".sidebarOrder", new: "sidebar" }) + }) +}) + +describe("the sidebar order", () => { + test("default: status, subagents, shell, trail, trust — between Context (100) and MCP (200)", () => { + const settings = loadSettings(where({})) + expect(settings.sidebar).toEqual(["status", "subagents", "shell", "trail", "trust"]) + const orders = settings.sidebar.map((bay) => orderOf(settings, bay)) + expect(orders).toEqual([110, 120, 130, 140, 150]) + }) + + test("the list is the only order; a bay it leaves out follows, in default order", () => { + const settings = loadSettings(where({ [GLOBAL]: { sidebar: ["trust", "shell"] } })) + expect(settings.sidebar).toEqual(["trust", "shell", "status", "subagents", "trail"]) + }) + + test("a bay's old sidebarOrder no longer moves it", () => { + const loaded = baySettings("trust", {}, { where: where({ [GLOBAL]: { trust: { sidebarOrder: 1 } } }) }) + expect(loaded.order).toBe(150) + expect(loaded.notices[0]).toMatchObject({ old: "trust.sidebarOrder", new: "sidebar", kind: "old" }) + }) + + test("a project's list replaces the global one", () => { + const settings = loadSettings( + where({ [GLOBAL]: { sidebar: ["shell", "status"] }, [PROJECT]: { sidebar: ["status"] } }), + ) + expect(settings.sidebarList).toEqual(["status"]) + }) + + test("an unknown entry names the closest bay, on that bay's block", () => { + const settings = loadSettings(where({ [GLOBAL]: { sidebar: ["status", "shells", "review", "xyz"] } })) + expect(settings.sidebarList).toEqual(["status"]) + expect(settings.notices.map((notice) => [notice.bay, notice.text])).toEqual([ + [ + "shell", + '"shells" in "sidebar" is not a bay: did you mean "shell"? (status, subagents, shell, trail, trust)', + ], + ["review", '"review" in "sidebar" has no sidebar block (status, subagents, shell, trail, trust)'], + ["cockpit", '"xyz" in "sidebar" is not a bay (status, subagents, shell, trail, trust)'], + ]) + }) + + test("closest names", () => { + const bays = ["status", "subagents", "shell", "trail", "trust"] + expect(closestName("shells", bays)).toBe("shell") + expect(closestName("statusline", bays)).toBe("status") + expect(closestName("Subagent", bays)).toBe("subagents") + expect(closestName("trails", bays)).toBe("trail") + expect(closestName("qqqqqq", bays)).toBeUndefined() + }) +}) diff --git a/packages/client/test/setup.test.ts b/packages/client/test/setup.test.ts new file mode 100644 index 00000000..0db9b7bb --- /dev/null +++ b/packages/client/test/setup.test.ts @@ -0,0 +1,501 @@ +import { describe, expect, test } from "bun:test" +import { existsSync } from "node:fs" +import { join } from "node:path" +import { sectionText, writeSection } from "../src/conventions.ts" +import { claimFeature } from "../src/feature.ts" +import type { Host } from "../src/host.ts" +import { silentLog } from "../src/log.ts" +import type { ServerHost } from "../src/server.ts" +import { + baysOfEntry, + briefAgent, + CONVENTIONS_TOOL, + conventionsReply, + HOST_BLOCKS, + offerPreview, + previewCommands, + type ReportInput, + readHostFile, + readInstalls, + registerSetup, + SETTINGS_TOOL, + SETUP_PROMPT, + SETUP_SKILL_DIR, + settingsReport, + settingsText, + setupServer, + tuneFacts, + tuneText, +} from "../src/setup.ts" + +/** + * `cockpit_settings` is what the agent reads before and after it edits. What is tested is the facts + * in it — which bays, which files, which values from where, what to fix — and that it says them in + * the order an agent acts on them, not its prose. + */ + +const GLOBAL = "/home/me/.config/opencode-cockpit/config.json" +const PROJECT = "/work/app/.cockpit.json" +const OPENCODE = "/home/me/.config/opencode/opencode.json" +const V1_TUI = "/home/me/.config/opencode/tui.json" +const V2_CLI = "/home/me/.config/opencode/cli.json" + +/** The bundle installed, plus whatever files a test adds. */ +function report(files: Record, over: Partial = {}) { + const all: Record = { [OPENCODE]: { plugins: ["opencode-cockpit@0.9.0"] }, ...files } + const result = settingsReport({ + opencode: 2, + directory: "/work/app", + env: {}, + home: "/home/me", + read: (path) => { + const file = all[path] + return file === undefined ? undefined : typeof file === "string" ? file : JSON.stringify(file) + }, + ...over, + }) + return { report: result, text: settingsText(result) } +} + +const headings = (text: string) => text.split("\n").filter((line) => line.startsWith("## ")) +const bay = (text: string, name: string) => { + const lines = text.split("\n") + const at = lines.findIndex((line) => line.startsWith(`### ${name} `)) + return at < 0 ? "" : lines.slice(at, lines.indexOf("", at)).join("\n") +} + +describe("its shape", () => { + test("nothing to fix: says so first, then files, bays, OpenCode's blocks, next", () => { + const { text } = report({}) + expect(text.split("\n")[2]).toBe("Notices: none. Every setting written is read.") + expect(headings(text).map((line) => line.split(" (")[0])).toEqual([ + "## Files", + "## Bays", + "## OpenCode's own sidebar blocks", + "## Next", + ]) + }) + + test("with something to fix, that comes first", () => { + const { text } = report({ [PROJECT]: { statusline: { preset: "minimal" } } }) + expect(headings(text)[0]).toBe("## Fix these first (1)") + expect(text).toContain("- Fix the 1 notice above first.") + }) +}) + +describe("previews", () => { + /** `bunx` fetches the newest release from npm; the agent is handed this install's own copy. */ + test("a bay's preview is listed by its exact command, and none means no section", () => { + const { report: read, text: plain } = report({}) + expect(plain).not.toContain("## Previews") + const text = settingsText(read, { status: 'bun "/x/status/dist/cli/preview.js"' }) + expect(text).toContain("## Previews") + expect(text).toContain('- status: `bun "/x/status/dist/cli/preview.js"`') + const order = headings(text).map((line) => line.split(" (")[0]) + expect(order.indexOf("## Previews")).toBe(order.indexOf("## Next") - 1) + }) + + test("offered previews are shared across copies of this module, by bay", () => { + offerPreview("status", "bun /a/preview.js") + offerPreview("status", "bun /b/preview.js") + expect(previewCommands().status).toBe("bun /b/preview.js") + }) +}) + +describe("what to fix", () => { + test("an old name says where its value goes", () => { + const { text } = report({ [GLOBAL]: { statusline: {}, ui: { sidebarRows: 3 } } }) + expect(text).toContain( + `- ${GLOBAL}: "statusline" is no longer read. Move its value to "status" and remove "statusline".`, + ) + expect(text).toContain(`"ui.sidebarRows" is no longer read. Move its value to "shell.sidebarRows"`) + }) + + test("an old place number points at the list, not at a name to copy it to", () => { + const { text } = report({ [PROJECT]: { review: { sidebarOrder: 3 } } }) + expect(text).toContain( + `"review.sidebarOrder" is no longer read. Remove it; the order is the top-level "sidebar" list.`, + ) + }) + + test("a key no bay reads is a notice too, with the one it most likely meant", () => { + const { report: r, text } = report({ [PROJECT]: { shell: { hideWhenEmty: true } } }) + expect(r.notices).toHaveLength(1) + expect(text).toContain(`"shell.hideWhenEmty" is not a setting of shell: did you mean "hideWhenEmpty"?`) + }) + + test("a starting point's keys raise none", () => { + const { report: r } = report({ + [GLOBAL]: { subagents: { hideWhenEmpty: true, sidebarRows: 4 }, status: { sidebar: false } }, + }) + expect(r.notices).toEqual([]) + }) +}) + +describe("files", () => { + test("both, and whether each exists or parses", () => { + const { text } = report({ [GLOBAL]: "{ nope" }) + expect(text).toContain(`- global: ${GLOBAL} — does not parse`) + expect(text).toContain(`- project: ${PROJECT} — not created yet (for settings only this project uses)`) + }) + + test("what is written, merged — or that nothing is", () => { + expect(report({}).text).toContain("Written: nothing. Every bay is on its defaults.") + expect(report({ [PROJECT]: { shell: { dockHeight: 16 } } }).text).toContain( + 'Written, both files merged: {"shell":{"dockHeight":16}}', + ) + }) +}) + +describe("bays", () => { + test("installed from OpenCode's plugin lists, in either version's spelling", () => { + expect(baysOfEntry("opencode-cockpit@0.9.0")).toHaveLength(7) + expect(baysOfEntry("/x/node_modules/opencode-cockpit")).toHaveLength(7) + expect(baysOfEntry("@opencode-cockpit/shell@0.9.0")).toEqual(["shell"]) + expect(baysOfEntry("/x/node_modules/@opencode-cockpit/trail/")).toEqual(["trail"]) + expect(baysOfEntry("@opencode-cockpit/client")).toEqual([]) + expect( + readInstalls(V1_TUI, JSON.stringify({ plugin: [["opencode-cockpit", { features: {} }]] })), + ).toEqual([{ entry: "opencode-cockpit", bundle: true, file: V1_TUI, options: { features: {} } }]) + expect( + readInstalls(V2_CLI, JSON.stringify({ plugins: [{ package: "@opencode-cockpit/status" }] })), + ).toHaveLength(1) + }) + + test("a bay nobody installed is named once, and its settings are not offered", () => { + const { text } = report({ [OPENCODE]: { plugins: ["@opencode-cockpit/shell"] } }) + expect(text).toContain("### shell — on") + expect(text).not.toContain("### status") + expect(text).toContain("Not installed: status, subagents, trail, trust, review, updater.") + }) + + test("each value says where it came from; defaults are listed apart", () => { + const { text } = report({ + [GLOBAL]: { shell: { dockHeight: 16, hideWhenEmpty: true } }, + [PROJECT]: { shell: { dockHeight: 20 } }, + }) + const shell = bay(text, "shell") + expect(shell).toContain("- set: hideWhenEmpty true (global) · dockHeight 20 (project)") + expect(shell).toContain("- defaults: enabled true · sidebar true · sidebarRows 5 ·") + expect(shell).toContain("block: shown, hidden while empty") + }) + + test("plugin-entry options are a source of their own", () => { + const { text } = report({ + [OPENCODE]: { plugins: [{ package: "opencode-cockpit", options: { trust: { threshold: 5 } } }] }, + }) + expect(bay(text, "trust")).toContain("threshold 5 (plugin options)") + }) + + test("off, and why: a file's switch, the entry's, or enabled", () => { + expect(report({ [GLOBAL]: { features: { shell: false } } }).text).toContain( + "### shell — off (`features` in a settings file)", + ) + expect( + report({ + [OPENCODE]: { plugins: [{ package: "opencode-cockpit", options: { features: { trail: false } } }] }, + }).text, + ).toContain("### trail — off (`features` in the plugin entry)") + expect(report({ [PROJECT]: { subagents: { enabled: false } } }).text).toContain( + "### subagents — off (`enabled: false`)", + ) + }) + + test("where each block is: Status's surface, a hidden block, Trust's default", () => { + const { text } = report({ [GLOBAL]: { status: { sidebar: false }, shell: { sidebar: false } } }) + expect(bay(text, "status")).toContain("block: a line under the prompt") + expect(bay(text, "shell")).toContain("block: hidden (sidebar: false)") + expect(bay(text, "trust")).toContain("block: hidden (sidebar: false)") + expect(bay(text, "review")).toContain("block: no sidebar block") + }) + + test("the order, from the list or the default, of the blocks that are on", () => { + expect(report({}).text).toContain( + "## Bays (sidebar order, top to bottom: status, subagents, shell, trail, trust, the default)", + ) + expect(report({ [GLOBAL]: { sidebar: ["trail", "status"] } }).text).toContain( + "top to bottom: trail, status, subagents, shell, trust, from the `sidebar` list", + ) + }) +}) + +describe("OpenCode's own blocks", () => { + test("reads the switches each version writes", () => { + expect( + readHostFile( + 1, + V1_TUI, + JSON.stringify({ plugin_enabled: { "internal:sidebar-todo": false, other: false } }), + ).blocks, + ).toEqual({ "internal:sidebar-todo": false }) + expect(readHostFile(2, V2_CLI, '{ "plugins": ["x", "-opencode.sidebar.context"] }').blocks).toEqual({ + "opencode.sidebar.context": false, + }) + expect(readHostFile(2, V2_CLI, "{ nope").error).toBeTruthy() + }) + + test("suggests turning Context off when Status draws in the sidebar, in each version's syntax", () => { + expect(report({}, { opencode: 1 }).text).toContain( + `\`"plugin_enabled": { "${HOST_BLOCKS[1].context}": false }\``, + ) + expect(report({}).text).toContain(`add \`"-${HOST_BLOCKS[2].context}"\` to the \`"plugins"\` list`) + }) + + test("no suggestion once it is off, or when Status is at the bottom", () => { + const off = report({ [V2_CLI]: { plugins: ["-opencode.sidebar.context"] } }).text + expect(off).toContain(`Context \`opencode.sidebar.context\`: off (set in ${V2_CLI}).`) + expect(off).not.toContain("Suggest turning") + expect(report({ [PROJECT]: { status: { sidebar: false } } }).text).not.toContain("Suggest turning") + }) + + test("Todo is never offered off; when it is off, turning it back on is offered", () => { + expect(report({}, { opencode: 1 }).text).toContain("Never suggest turning it off") + const off = report( + { [V1_TUI]: { plugin_enabled: { "internal:sidebar-todo": false } } }, + { opencode: 1 }, + ).text + expect(off).toContain("offer to turn it back on") + expect(off).toContain('`"plugin_enabled": { "internal:sidebar-todo": true }`') + }) + + test("OpenCode 2 has no LSP, Todo or Files block to talk about", () => { + const { text } = report({}) + expect(text).not.toContain("internal:sidebar-lsp") + expect(text).toContain("OpenCode 2 has no LSP, Todo or Files block in the sidebar.") + }) + + test("every block each version has is listed by its id; the optional ones neutrally, either way", () => { + const ids = (text: string) => [...text.matchAll(/^- \w+ `([\w.:-]+)`/gm)].map((match) => match[1]) + expect(ids(report({}).text)).toEqual([ + "opencode.sidebar.context", + "opencode.sidebar.mcp", + "opencode.sidebar.footer", + ]) + expect(ids(report({}, { opencode: 1 }).text)).toEqual([ + "internal:sidebar-context", + "internal:sidebar-mcp", + "internal:sidebar-lsp", + "internal:sidebar-files", + "internal:sidebar-footer", + "internal:sidebar-todo", + ]) + const mcp = report({ [V2_CLI]: { plugins: ["-opencode.sidebar.mcp"] } }).text + expect(mcp).toContain("- MCP `opencode.sidebar.mcp`: off (set in") + expect(mcp).toContain("Status's table already warns when one fails") + expect(mcp).toContain('Back on: remove `"-opencode.sidebar.mcp"`') + expect(report({ [PROJECT]: { status: { sidebar: false } } }).text).not.toContain("Status's table already") + }) +}) + +/* ─── the second phase ──────────────────────────────────────────────────────────────────────── */ + +describe("tune", () => { + const facts = (files: Record, over: Partial = {}) => { + const input: ReportInput = { + opencode: 2, + directory: "/work/app", + env: {}, + home: "/home/me", + read: (path) => files[path], + ...over, + } + return tuneFacts(input, (args) => (args[0] === "log" ? "COM-1 a\nCOM-2 b\n" : undefined)) + } + + test("the tour gives each bay that is on its keys as set now, and its commands", () => { + const { report: r } = report({ + [GLOBAL]: { shell: { keybinds: { "cockpit.shells.dock": "d" } } }, + }) + const text = tuneText(r, facts({})) + expect(text).toContain("- shell — background shells") + expect(text).toContain("`d` `/shells-dock` show or hide the shells panel") + expect(text).toContain("`j` `/shell` open the console") + expect(text).toContain("`f` `/trail`") + const off = report({ [GLOBAL]: { features: { trail: false } } }).report + expect(tuneText(off, facts({}))).not.toContain("- trail —") + }) + + test("the project's long-running commands, ticket keys and AGENTS.md sections", () => { + const files = { + "/work/app/package.json": JSON.stringify({ scripts: { dev: "next dev", lint: "biome check" } }), + "/work/app/AGENTS.md": `# Ours\n\n${sectionText("- Tickets are COM-…")}\n`, + } + const text = tuneText(report({}).report, facts(files)) + expect(text).toContain('- `npm run dev` — package.json "dev": "next dev"') + expect(text).toContain("Other package.json scripts (they end on their own): lint") + expect(text).toContain("COM (2, e.g. COM-1)") + expect(text).toContain("- project: /work/app/AGENTS.md — has the Cockpit section. Now:") + expect(text).toContain(" - Tickets are COM-…") + expect(text).toContain("- global: /home/me/.config/opencode/AGENTS.md — not created yet") + expect(text).toContain("Conventions only") + }) + + test("on OpenCode 1, creating AGENTS.md beside a CLAUDE.md is flagged", () => { + const text = tuneText( + report({}, { opencode: 1 }).report, + facts({ "/work/app/CLAUDE.md": "x" }, { opencode: 1 }), + ) + expect(text).toContain("/work/app/CLAUDE.md exists, and OpenCode 1 reads it only while there is no") + }) + + test("cockpit_conventions answers with what it did and the section as it now reads", () => { + const added = writeSection("# Ours\n", "- a") + if (!added.ok) throw new Error(added.error) + const reply = conventionsReply("/work/app/AGENTS.md", added) + expect(reply.split("\n")[0]).toBe( + "Added the Cockpit section at the end of /work/app/AGENTS.md; everything before it is unchanged.", + ) + expect(reply).toContain(sectionText("- a")) + }) +}) + +/* ─── the agent side ────────────────────────────────────────────────────────────────────────── */ + +function serverHost(scope: object = {}): ServerHost { + return { + version: 1, + directory: "/work/app", + scope, + session: { get: async () => undefined, notify: async () => {} }, + readFile: async () => undefined, + log: silentLog, + } +} + +describe("setupServer", () => { + test("the tool, the skill and the command, once per OpenCode", () => { + const scope = {} + const first = setupServer(serverHost(scope), "opencode-cockpit") + expect(Object.keys(first.tools ?? {})).toEqual([SETTINGS_TOOL, CONVENTIONS_TOOL]) + expect(first.skills).toEqual([{ dir: SETUP_SKILL_DIR }]) + expect(first.commands).toEqual([expect.objectContaining({ name: "cockpit-setup", prompt: SETUP_PROMPT })]) + expect(setupServer(serverHost(scope), "@opencode-cockpit/shell")).toEqual({}) + }) + + test("the skill it points at is in the package", () => { + expect(existsSync(join(SETUP_SKILL_DIR, "SKILL.md"))).toBe(true) + expect(existsSync(join(SETUP_SKILL_DIR, "references", "settings.md"))).toBe(true) + }) + + test("the command's line names the skill", () => { + expect(SETUP_PROMPT).toContain("cockpit-setup skill") + }) +}) + +/* ─── the interface's palette entry ─────────────────────────────────────────────────────────── */ + +interface Fake { + host: Host + layers: unknown[] + toasts: string[] + calls: string[] +} + +function fakeHost(over: { version?: 1 | 2; route?: Host["route"]["current"]; status?: string } = {}): Fake { + const layers: unknown[] = [] + const toasts: string[] = [] + const calls: string[] = [] + const host = { + version: over.version ?? 2, + renderer: {}, + state: { path: { directory: "/work/app", worktree: "/work/app" } }, + route: { current: over.route ?? { name: "home" } }, + ui: { toast: (toast: { message: string }) => toasts.push(toast.message) }, + keymap: { + registerLayer: (layer: unknown) => { + layers.push(layer) + return () => {} + }, + }, + lifecycle: { onDispose: () => {} }, + log: { info: () => {}, warn: () => {} }, + ...(over.version === 1 + ? { + v1: { + client: { + tui: { + appendPrompt: async ({ text }: { text: string }) => void calls.push(`append ${text}`), + submitPrompt: async () => void calls.push("submit"), + }, + }, + }, + } + : { + v2: { + ui: { + router: { + navigate: (route: { sessionID: string }) => calls.push(`navigate ${route.sessionID}`), + }, + }, + data: { + session: { + status: () => over.status ?? "idle", + create: () => { + calls.push("create") + return { id: "ses_new", request: Promise.resolve() } + }, + prompt: async (input: { sessionID: string; delivery?: string }) => + void calls.push(`prompt ${input.sessionID} ${input.delivery ?? "default"}`), + }, + }, + }, + }), + } as unknown as Host + return { host, layers, toasts, calls } +} + +const settle = () => new Promise((done) => setTimeout(done, 5)) + +describe("registerSetup", () => { + test("the first entry in a window registers a palette entry with no slash name of its own", () => { + const a = fakeHost() + registerSetup(a.host, "opencode-cockpit") + registerSetup({ ...a.host } as Host, "@opencode-cockpit/shell") + expect(a.layers).toHaveLength(1) + const command = (a.layers[0] as { commands: Record[] }).commands[0] + expect(command).toMatchObject({ + title: "Ask the agent to set up Cockpit", + category: "Cockpit", + namespace: "palette", + }) + expect(command).not.toHaveProperty("slashName") + }) + + test("running it sends the command's own line", async () => { + const fake = fakeHost({ version: 1 }) + registerSetup(fake.host, "opencode-cockpit") + ;(fake.layers[0] as { commands: { run(): void }[] }).commands[0]?.run() + await settle() + expect(fake.calls).toEqual([`append ${SETUP_PROMPT}`, "submit"]) + }) + + test("a claim is per window", () => { + const { host } = fakeHost() + expect(claimFeature(host.renderer, "setup", "x").active).toBe(true) + }) +}) + +describe("briefAgent, from every state", () => { + test("OpenCode 2 at home: a conversation is made, opened, and asked", async () => { + const fake = fakeHost() + briefAgent(fake.host, "line", "Cockpit setup") + await settle() + expect(fake.calls).toEqual(["create", "navigate ses_new", "prompt ses_new default"]) + expect(fake.toasts).toEqual(["Asked the agent."]) + }) + + test("OpenCode 2, agent busy: queued behind the running turn, not steered into it", async () => { + const fake = fakeHost({ route: { name: "session", params: { sessionID: "ses_1" } }, status: "running" }) + briefAgent(fake.host, "line", "Cockpit setup") + await settle() + expect(fake.calls).toEqual(["prompt ses_1 queue"]) + }) + + test("OpenCode 1: into the prompt and submitted, which starts or queues a turn itself", async () => { + const fake = fakeHost({ version: 1 }) + briefAgent(fake.host, "line", "Cockpit setup") + await settle() + expect(fake.calls).toEqual(["append line", "submit"]) + expect(fake.toasts).toEqual(["Asked the agent."]) + }) +}) diff --git a/packages/client/test/sidebar.test.ts b/packages/client/test/sidebar.test.ts index 285e0382..50a349d2 100644 --- a/packages/client/test/sidebar.test.ts +++ b/packages/client/test/sidebar.test.ts @@ -1,52 +1,6 @@ -import { afterEach, describe, expect, test } from "bun:test" -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" -import { join } from "node:path" +import { describe, expect, test } from "bun:test" import type { Host } from "../src/host.ts" -import { orderedSidebar, sidebarOrder } from "../src/sidebar.ts" - -/** One list orders every bay's sidebar block; a bay's own number still wins; a project beats global. */ - -const dirs: string[] = [] -afterEach(() => { - for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) -}) -function setup(global?: unknown, project?: unknown) { - const root = mkdtempSync("/tmp/ck-sidebar-") - dirs.push(root) - const config = join(root, "config") - const directory = join(root, "project") - mkdirSync(join(config, "opencode-cockpit"), { recursive: true }) - mkdirSync(directory, { recursive: true }) - if (global) writeFileSync(join(config, "opencode-cockpit", "config.json"), JSON.stringify(global)) - if (project) writeFileSync(join(directory, ".cockpit.json"), JSON.stringify(project)) - return { directory, env: { XDG_CONFIG_HOME: config } } -} - -describe("the sidebar order", () => { - test("the list puts the statusline first, ahead of every default", () => { - const where = setup({ sidebar: ["status", "subagents", "shell"] }) - const status = sidebarOrder("status", 200, undefined, where) - const subagents = sidebarOrder("subagents", 160, undefined, where) - const shell = sidebarOrder("shell", 150, undefined, where) - expect(status).toBeLessThan(subagents) - expect(subagents).toBeLessThan(shell) - expect(shell).toBeLessThan(150) - }) - - test("a bay's own number beats the list; a bay the list leaves out keeps its default", () => { - const where = setup({ sidebar: ["status"] }) - expect(sidebarOrder("status", 200, 999, where)).toBe(999) - expect(sidebarOrder("shell", 150, undefined, where)).toBe(150) - }) - - test("a project's list beats the global one; no list, no change", () => { - const where = setup({ sidebar: ["shell", "status"] }, { sidebar: ["status", "shell"] }) - expect(sidebarOrder("status", 200, undefined, where)).toBeLessThan( - sidebarOrder("shell", 150, undefined, where), - ) - expect(sidebarOrder("status", 200, undefined, setup())).toBe(200) - }) -}) +import { orderedSidebar } from "../src/sidebar.ts" describe("orderedSidebar", () => { test("sidebar blocks register in their order, everything else at once", () => { diff --git a/packages/client/test/surfaces.test.ts b/packages/client/test/surfaces.test.ts new file mode 100644 index 00000000..5eb6e17e --- /dev/null +++ b/packages/client/test/surfaces.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from "bun:test" +import { DEFAULT_KEYS } from "../src/catalog.ts" +import { composeParts, dualServer, type ServerParts } from "../src/server.ts" +import { keyText, openText, registerSurfaces, surfacesLine } from "../src/surfaces.ts" + +/** + * The one Cockpit-wide line: where the user sees what the agent made, written from the bays that are + * loaded, said once per window and only to the main agent. + */ + +const shells = { what: "your background shells", open: "ctrl+x o" } +const subagents = { what: "subagents", open: "ctrl+x d" } +const trail = { what: "this conversation's trail", open: "ctrl+x f" } +const review = { what: "review threads", where: "Review", open: "ctrl+x v" } + +describe("the line", () => { + test("names the sidebar's bays together, then each other place", () => { + expect(surfacesLine([shells, review, subagents, trail])).toBe( + "The user sees your background shells (ctrl+x o), subagents (ctrl+x d) and this conversation's trail (ctrl+x f) in the sidebar, and review threads in Review (ctrl+x v): point them there instead of pasting those lists.", + ) + }) + + test("one bay alone, and none at all", () => { + expect(surfacesLine([review])).toBe( + "The user sees review threads in Review (ctrl+x v): point them there instead of pasting those lists.", + ) + expect(surfacesLine([])).toBeUndefined() + }) +}) + +describe("keys as the user presses them", () => { + test(" is OpenCode's ctrl+x; the first of several; none is no key", () => { + expect(keyText("v")).toBe("ctrl+x v") + expect(keyText("ctrl+o,o")).toBe("ctrl+o") + expect(keyText("none")).toBeUndefined() + expect(keyText(undefined)).toBeUndefined() + }) + + test("the bay's default unless the settings change it; its slash command when the key is off", () => { + expect(DEFAULT_KEYS.review?.["cockpit.review.open"]).toBe("v") + expect(openText("review", "cockpit.review.open", {}, "changes")).toBe("ctrl+x v") + expect(openText("review", "cockpit.review.open", { "cockpit.review.open": "g" }, "changes")).toBe( + "ctrl+x g", + ) + expect(openText("review", "cockpit.review.open", { "cockpit.review.open": "none" }, "changes")).toBe( + "/changes", + ) + }) +}) + +describe("once per window", () => { + test("the first entry still registered says every entry's fragments; the others say nothing", () => { + const scope = {} + const a = registerSurfaces(scope, [shells]) + const b = registerSurfaces(scope, [review]) + expect(a.line()).toBe(surfacesLine([shells, review])) + expect(b.line()).toBeUndefined() + a.release() + expect(b.line()).toBe(surfacesLine([review])) + expect(registerSurfaces({}, [trail]).line()).toBe(surfacesLine([trail])) + }) + + test("the bundle composes its bays' fragments into one entry", () => { + expect(composeParts([{ surfaces: [shells] }, {}, { surfaces: [review] }]).surfaces).toEqual([ + shells, + review, + ]) + }) +}) + +/** A v2 context the way OpenCode 2 hands one to `setup`: enough for tools, the context hook and sessions. */ +function fakeV2(parents: Record = {}) { + let context: ((event: { sessionID: string; system: unknown[] }) => unknown) | undefined + const ctx = { + options: {}, + location: { directory: `/work/surfaces-${crypto.randomUUID()}` }, + tool: { transform: async () => {} }, + session: { + get: async ({ sessionID }: { sessionID: string }) => ({ id: sessionID, parentID: parents[sessionID] }), + hook: async (_name: string, run: typeof context) => { + context = run + }, + }, + event: { subscribe: async function* () {} }, + } + const say = async (sessionID: string) => { + const event = { sessionID, system: [] as { text: string }[] } + await context?.(event) + return event.system.map((part) => part.text) + } + return { ctx, say } +} + +describe("through the entries OpenCode loads", () => { + const bay = (parts: ServerParts) => async () => parts + + test("two bays installed apart: the line comes once, ahead of the first one's guidance", async () => { + const one = fakeV2({ ses_child: "ses_main" }) + const shell = dualServer("cockpit.shell", bay({ surfaces: [shells], system: async () => ["## Shells"] })) + const rev = dualServer("cockpit.review", bay({ surfaces: [review], system: async () => ["## Review"] })) + const stop = await shell.setup(one.ctx as never) + /** OpenCode 2 hands every plugin of a location its own context; the scope is shared by directory. */ + const other = { ...one.ctx, session: { ...one.ctx.session, hook: async () => {} } } + await rev.setup(other as never) + expect(await one.say("ses_main")).toEqual([surfacesLine([shells, review]), "## Shells"]) + /** A subagent answers its caller, not the user: no line for it. */ + expect(await one.say("ses_child")).toEqual(["## Shells"]) + await stop?.() + }) + + test("a bay that shows nothing adds no line", async () => { + const one = fakeV2() + const entry = dualServer("cockpit.plain", bay({ system: async () => ["## Plain"] })) + const stop = await entry.setup(one.ctx as never) + expect(await one.say("ses_main")).toEqual(["## Plain"]) + await stop?.() + }) +}) diff --git a/packages/opencode/README.md b/packages/opencode/README.md index 7804c7c9..14d97fc9 100644 --- a/packages/opencode/README.md +++ b/packages/opencode/README.md @@ -26,9 +26,10 @@ ships.* |---|---|---| | **Shell** | Background terminals it starts, waits on, reads and types into — dev servers, watchers, test suites, REPLs | [`@opencode-cockpit/shell`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/shell) | | **Review** | A pull request in the terminal: your comments on the lines, which it reads, answers and resolves | [`@opencode-cockpit/review`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/review) | -| **Statusline** | — (for you: what the session is costing, under the prompt or in the sidebar) | [`@opencode-cockpit/status`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/status) | +| **Statusline** | The `status-setup` skill, to change the line with you (for you: what the session is costing, in the sidebar or under the prompt) | [`@opencode-cockpit/status`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/status) | | **Updater** | — (for you: every plugin, what it really runs, and the update) | [`@opencode-cockpit/updater`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/updater) | | **Subagents** | Every subagent's run in the sidebar and a pane; follow-ups continue the subagent that did the work; message, stop or background one | [`@opencode-cockpit/subagents`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/subagents) | +| **Trail** | `trail_add` / `trail_list`: it records the PRs, tickets and pages it creates or changes, and can say which conversation made one (for you: the sidebar, one click from the page, and `/trail`) | [`@opencode-cockpit/trail`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/trail) | | **Trust** | — (for you: approve the exact same command three times and it is answered for you; `/trust` shows what it learned and answered) | [`@opencode-cockpit/trust`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/trust) | ## Shell, by example @@ -67,7 +68,7 @@ changes, never about a thousand identical recompiles: shell_start command="tsc --watch --noEmit" description="type checker" watch=true → tsc: ok → fail · src/auth.ts(42,3): error TS2339: Property 'id' does not exist ``` -Presets cover about 35 tools (tsc, vitest, jest, eslint, cargo, go, gradle, pytest, vite, next, +Presets cover 34 tools (tsc, vitest, jest, eslint, cargo, go, gradle, pytest, vite, next, docker compose…), and anything else takes three regexes of its own. A watched process that dies counts as a failure, so a crashed dev server is reported too. @@ -93,11 +94,21 @@ keeping line numbers and highlighting matches — and output keeps the colours t | Key / command | Does | |---|---| -| `ctrl+x o` · `/shells` | Toggle the shells panel under the chat | -| `ctrl+x i` · `/shell` | Open the shell console | +| `/shells` | Every shell in view, plus "New shell": pick one to open its console | +| `ctrl+x o` · `/shells-dock` | Toggle the shells panel under the chat | +| `ctrl+x j` · `/shell` | Open the shell console | | `/shell-new` | Start a shell yourself | | `/shells-clear` | Remove finished shells | +| `ctrl+x d` · `/subagents` | Open the subagent working now, in a pane beside the chat | +| `ctrl+x v` · `/changes` | Open or close the review of what changed; `ctrl+x k` moves it between the right pane and full screen | +| `ctrl+x f` · `/trail` | What this conversation made; `/link`, then paste the link, adds one yourself | +| `ctrl+x p` · `/trust` | What Trust answered for you, and what it has learned | | `/plugins-update` | Every plugin you have installed: what runs, what is published, and an update checked against disk | +| `/cockpit-setup` | The agent sets Cockpit up with you, with the `cockpit-setup` skill: which bays show, where, in what order — then, if you want, tunes it to how your project works | +| `/status-setup` | The agent designs the Status line with you, with the `status-setup` skill (`/statusline` until 0.9) | + +Every key is the same on OpenCode 1 and 2, none of them is one of OpenCode's own, and each bay's +`keybinds` changes it. Status reads the same everywhere — `RUN` (with a spinner), `FAIL`, `STOP`, `DONE` — running shells and recent failures stay in view, the rest folds behind `â–¸ N more`. In the console: `i` types @@ -147,32 +158,38 @@ An existing v1 `opencode.json` with `plugin` is read by OpenCode 2 as well. To u the version in that entry — `/plugins-update` and `npx opencode-cockpit update` edit OpenCode 1's files only. -**Turn features off** (in both `opencode.json` and `tui.json`): +**After installing or updating on OpenCode 2, restart its background service:** -```json -{ - "plugin": [["opencode-cockpit", { "features": { "shell": true } }]] -} +```sh +opencode service restart ``` -**Configure them** in one file, read by both halves of the plugin and by every project: +OpenCode 2 runs the agent side in a background service that loads plugins once, when it starts. +Until it restarts, the windows draw the new Cockpit while the agent keeps the old one's tools and +skills; a window says so in a toast, and `npx opencode-cockpit@latest doctor` does too. + +**Configure them** in one file, read by both halves of every bay and by every project — or type +`/cockpit-setup` and the agent writes it with you: ``` -~/.config/opencode-cockpit/config.json → /.cockpit.json → plugin-entry options +~/.config/opencode-cockpit/config.json → /.cockpit.json ``` -```json +```jsonc { - "kinds": { "e2e": "playwright|cypress" }, - "defaults": { "logFile": true, "timeoutSeconds": 900 }, - "ui": { "dockHeight": 16, "historyMinutes": 60 } + "sidebar": ["status", "subagents", "shell", "trail", "trust"], // the blocks' order + "features": { "trust": false }, // switch a bay off + "shell": { "dockHeight": 16, "hideFinishedAfterMinutes": 60 }, + "trail": { "sidebarRows": 5 } } ``` -Later sources win key by key, and an invalid file is ignored rather than fatal. You can categorize -your own commands, define watch rules, cap how long shells live, choose what may interrupt the -agent, and trade context tokens for accuracy. Each feature's README documents its own settings: -[Shell](https://github.com/Codestz/opencode-cockpit/tree/main/packages/shell#configuration). +One section per bay — `status`, `subagents`, `shell`, `trail`, `trust`, `review`, `updater` — with +the same shared keys in each (`enabled`, `sidebar`, `sidebarRows`, `hideWhenEmpty`, `keybinds`). A +project's file wins key by key; comments and trailing commas are fine. Names from before 0.9 +(`statusline`, Shell's keys at the root, `ui.*`, `sidebarOrder`) are no longer read: each is a `!` +row in its bay's block, and `/cockpit-setup` fixes it. Every key, its default and the old names: +[Configuration](https://github.com/Codestz/opencode-cockpit#configuration). ## Troubleshooting @@ -219,6 +236,7 @@ Each shell's output feeds three views at once: a normalized **log** for the agen | [`@opencode-cockpit/review`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/review) | Review feature | | [`@opencode-cockpit/updater`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/updater) | Updater feature | | [`@opencode-cockpit/subagents`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/subagents) | Subagents feature | +| [`@opencode-cockpit/trail`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/trail) | Trail feature | | [`@opencode-cockpit/trust`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/trust) | Trust feature | | [`@opencode-cockpit/daemon`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/daemon) | `cockpitd`, the shared process host | | [`@opencode-cockpit/client`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/client) | Typed, auto-spawning client and plugin helpers | diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 9766507b..0f8508d9 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,7 +1,7 @@ { "name": "opencode-cockpit", "version": "0.8.0", - "description": "OpenCode superpowers, all in one: installs every opencode-cockpit feature (Shell, Statusline, Review, Updater, Subagents, Trust), each can be switched off", + "description": "OpenCode superpowers, all in one: installs every opencode-cockpit feature (Shell, Statusline, Review, Updater, Subagents, Trail, Trust), each can be switched off", "type": "module", "license": "MIT", "author": "Codestz", @@ -52,12 +52,13 @@ "access": "public" }, "dependencies": { - "@opencode-ai/plugin": "1.18.31", + "@opencode-ai/plugin": "1.18.33", "@opencode-cockpit/client": "workspace:*", "@opencode-cockpit/review": "workspace:*", "@opencode-cockpit/shell": "workspace:*", "@opencode-cockpit/status": "workspace:*", "@opencode-cockpit/subagents": "workspace:*", + "@opencode-cockpit/trail": "workspace:*", "@opencode-cockpit/trust": "workspace:*", "@opencode-cockpit/updater": "workspace:*" }, diff --git a/packages/opencode/src/bin.ts b/packages/opencode/src/bin.ts index 11fad2f5..ba61f697 100644 --- a/packages/opencode/src/bin.ts +++ b/packages/opencode/src/bin.ts @@ -3,8 +3,9 @@ * `npx opencode-cockpit@latest update` — the rescue for anyone whose installed copy is too old to * update itself, under the name they already installed. * - * Node, not Bun, and nothing from the bays is loaded: `npx` may run where there is no `bun`, and a - * bin that cannot start is the one failure this exists to get people out of. + * Node, not Bun, and nothing from the bays is loaded but Status's settings words, guarded: `npx` may + * run where there is no `bun`, and a bin that cannot start is the one failure this exists to get + * people out of. */ const HELP = `Usage: npx opencode-cockpit@latest @@ -18,6 +19,22 @@ const HELP = `Usage: npx opencode-cockpit@latest const [command, ...rest] = process.argv.slice(2) if (command === "update" || command === "doctor") { + /** + * The one exception: doctor lists what Status's line warns about (an unknown preset, an override + * matching no segment), and only Status knows those words. Its config module is plain Node; one + * that will not load costs those lines, never doctor. + */ + if (command === "doctor") { + try { + const [{ offerSettingsCheck }, { statusNotices }] = await Promise.all([ + import("@opencode-cockpit/client/checks"), + import("@opencode-cockpit/status/config"), + ]) + offerSettingsCheck("status", statusNotices) + } catch { + // doctor runs without them + } + } const { main } = await import("@opencode-cockpit/updater/cli") process.exitCode = await main(command === "doctor" ? [command, ...rest] : rest) } else { diff --git a/packages/opencode/src/features.ts b/packages/opencode/src/features.ts index d9df69f4..ea138a06 100644 --- a/packages/opencode/src/features.ts +++ b/packages/opencode/src/features.ts @@ -1,6 +1,11 @@ -/** Every feature the bundle can load. Adding one: list it here and wire it in server.ts / tui.ts. */ -export const FEATURES = ["shell", "status", "review", "updater", "subagents", "trust"] as const -export type Feature = (typeof FEATURES)[number] +import { BAYS, type Bay } from "@opencode-cockpit/client/settings" + +/** + * Every feature the bundle can load: every bay. Adding one: list it in `BAYS` + * (`@opencode-cockpit/client/settings`) and wire it in server.ts / tui.ts. + */ +export const FEATURES = BAYS +export type Feature = Bay export interface CockpitOptions { /** Switch features off, e.g. `{ "shell": false }`. Everything is on by default. */ @@ -11,6 +16,7 @@ export interface CockpitOptions { review?: Record updater?: Record subagents?: Record + trail?: Record trust?: Record [key: string]: unknown } diff --git a/packages/opencode/src/server.ts b/packages/opencode/src/server.ts index 6010479f..1a10eee0 100644 --- a/packages/opencode/src/server.ts +++ b/packages/opencode/src/server.ts @@ -1,18 +1,25 @@ import { composeParts, dualServer } from "@opencode-cockpit/client/server" import { createReviewServer } from "@opencode-cockpit/review/server" import { createShellServer } from "@opencode-cockpit/shell/server" +import { createStatusServer } from "@opencode-cockpit/status/server" import { createSubagentsServer } from "@opencode-cockpit/subagents/server" +import { createTrailServer } from "@opencode-cockpit/trail/server" import { BUNDLE, type CockpitOptions, featureOptions, isEnabled } from "./features.ts" const shell = createShellServer({ source: BUNDLE }) +const status = createStatusServer({ source: BUNDLE }) const review = createReviewServer({ source: BUNDLE }) const subagents = createSubagentsServer({ source: BUNDLE }) +const trail = createTrailServer({ source: BUNDLE }) export default dualServer(BUNDLE, async (host, rawOptions) => { const options = rawOptions as CockpitOptions | undefined const parts = [] if (isEnabled(options, "shell")) parts.push(await shell(host, featureOptions(options, "shell"))) + /** Status's agent side is its `status-setup` skill and commands; the line itself is all interface. */ + if (isEnabled(options, "status")) parts.push(await status(host, featureOptions(options, "status"))) if (isEnabled(options, "review")) parts.push(await review(host, featureOptions(options, "review"))) if (isEnabled(options, "subagents")) parts.push(await subagents(host, featureOptions(options, "subagents"))) + if (isEnabled(options, "trail")) parts.push(await trail(host, featureOptions(options, "trail"))) return composeParts(parts) }) diff --git a/packages/opencode/src/tui.ts b/packages/opencode/src/tui.ts index 8ac40675..438dd881 100644 --- a/packages/opencode/src/tui.ts +++ b/packages/opencode/src/tui.ts @@ -4,6 +4,7 @@ import { createReviewTui } from "@opencode-cockpit/review/tui" import { createShellTui } from "@opencode-cockpit/shell/tui" import { createStatusTui } from "@opencode-cockpit/status/tui" import { createSubagentsTui } from "@opencode-cockpit/subagents/tui" +import { createTrailTui } from "@opencode-cockpit/trail/tui" import { createTrustTui } from "@opencode-cockpit/trust/tui" import { createUpdaterTui } from "@opencode-cockpit/updater/tui" import { BUNDLE, type CockpitOptions, featureOptions, isEnabled } from "./features.ts" @@ -13,6 +14,7 @@ const status = createStatusTui({ source: BUNDLE }) const review = createReviewTui({ source: BUNDLE }) const updater = createUpdaterTui({ source: BUNDLE }) const subagents = createSubagentsTui({ source: BUNDLE }) +const trail = createTrailTui({ source: BUNDLE }) const trust = createTrustTui({ source: BUNDLE }) /** One entry for both OpenCodes (docs/opencode/v2.md): each bay starts on the same Host. */ @@ -25,6 +27,7 @@ export default dualTui(BUNDLE, async (outer, rawOptions) => { if (isEnabled(options, "review")) await review(host, featureOptions(options, "review")) if (isEnabled(options, "updater")) await updater(host, featureOptions(options, "updater")) if (isEnabled(options, "subagents")) await subagents(host, featureOptions(options, "subagents")) + if (isEnabled(options, "trail")) await trail(host, featureOptions(options, "trail")) if (isEnabled(options, "trust")) await trust(host, featureOptions(options, "trust")) flush() }) diff --git a/packages/opencode/test/bundle.test.ts b/packages/opencode/test/bundle.test.ts index ee4a0e03..7151e9ad 100644 --- a/packages/opencode/test/bundle.test.ts +++ b/packages/opencode/test/bundle.test.ts @@ -14,6 +14,12 @@ describe("feature options", () => { expect(featureOptions({ trust: { threshold: 5 } }, "trust")).toEqual({ threshold: 5 }) }) + test("Trail is in the bundle, on by default, and switched off like the rest", () => { + expect(isEnabled(undefined, "trail")).toBe(true) + expect(isEnabled({ features: { trail: false } }, "trail")).toBe(false) + expect(featureOptions({ trail: { sidebarRows: 3 } }, "trail")).toEqual({ sidebarRows: 3 }) + }) + test("shell options come from their own key, with 0.1.x top-level options as fallback", () => { expect(featureOptions({ dockHeight: 10, shell: { dockHeight: 20, dockOpen: true } }, "shell")).toEqual({ dockHeight: 20, diff --git a/packages/opencode/test/catalog.test.ts b/packages/opencode/test/catalog.test.ts new file mode 100644 index 00000000..25ec5af0 --- /dev/null +++ b/packages/opencode/test/catalog.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { join } from "node:path" +import { bayKeys, DEFAULT_KEYS, OWN_KEYS } from "@opencode-cockpit/client/catalog" +import { DEFAULTS as REVIEW } from "../../review/src/core/config.ts" +import { DEFAULTS as SHELL } from "../../shell/src/core/config.ts" +import { KINDS as STATUS } from "../../status/src/core/config.ts" +import { DEFAULTS as SUBAGENTS } from "../../subagents/src/core/config.ts" +import { DEFAULTS as TRUST } from "../../trust/src/core/config.ts" + +/** + * The catalog in `@opencode-cockpit/client` lists every bay's own keys and defaults — for + * `cockpit_settings` and the `cockpit-setup` skill's reference — but client cannot import the bays. + * This package depends on all of them, so it is where a bay's defaults and the catalog are held to + * each other: a key added, removed or re-defaulted in a bay fails here until the catalog says so too. + */ + +/** Every key in `defaults` is in the catalog with that default; every catalog key the bay does not default is unset. */ +function agrees(bay: Parameters[0], defaults: Record) { + const catalog = bayKeys(bay) + for (const [key, value] of Object.entries(defaults)) { + const info = catalog.find((each) => each.key === key) + expect({ bay, key, listed: info !== undefined }).toEqual({ bay, key, listed: true }) + expect({ bay, key, default: info?.default }).toEqual({ bay, key, default: value }) + } + for (const info of OWN_KEYS[bay]) + if (!(info.key in defaults)) + expect({ bay, key: info.key, default: info.default }).toEqual({ + bay, + key: info.key, + default: undefined, + }) +} + +describe("the catalog agrees with every bay's defaults", () => { + test("shell", () => agrees("shell", SHELL)) + test("subagents", () => agrees("subagents", SUBAGENTS)) + test("trust", () => agrees("trust", { ...TRUST })) + test("review", () => agrees("review", REVIEW)) + test("updater, whose one key is written inline where it is read", () => + agrees("updater", { updateCheck: true })) + test("trail, which has only the shared keys", () => expect(OWN_KEYS.trail).toEqual([])) + + test("status: the same keys as it checks, and the two booleans it defaults", () => { + expect(OWN_KEYS.status.map((info) => info.key).sort()).toEqual(Object.keys(STATUS).sort()) + const own = Object.fromEntries(OWN_KEYS.status.map((info) => [info.key, info.default])) + expect({ icons: own.icons, debug: own.debug }).toEqual({ icons: STATUS.icons, debug: STATUS.debug }) + }) +}) + +/** + * OpenCode's own `` letters, as 1.18.32 and 2.0.18 bind them by default — measured from both + * binaries. A Cockpit default on one of these takes the key from OpenCode (2's `w` closes the tab, + * `i` shows image attachments; `r` is redo on both), so none may be one. + */ +const OPENCODE_LEADER = new Set("abceghilmnqrstuwxy".split("")) + +/** A bay's interface entry, as source. */ +const tuiSource = (bay: string) => + readFileSync(join(import.meta.dir, "..", "..", bay, "src", "tui", "index.tsx"), "utf8") + +describe("default keys", () => { + const bays = Object.keys(DEFAULT_KEYS) as (keyof typeof DEFAULT_KEYS)[] + + test("each bay binds the catalog's defaults, and keeps no copy of its own", () => { + for (const bay of bays) { + const source = tuiSource(bay) + expect({ bay, reads: source.includes(`defaultKeys("${bay}")`) }).toEqual({ bay, reads: true }) + expect({ bay, copy: /const DEFAULT_KEYS = \{/.test(source) }).toEqual({ bay, copy: false }) + } + }) + + test("every Cockpit default is its own key, and none is one of OpenCode's", () => { + const keys = bays.flatMap((bay) => Object.values(DEFAULT_KEYS[bay] ?? {})) + expect(new Set(keys).size).toBe(keys.length) + for (const key of keys) { + const letter = key.match(/^([a-z])$/)?.[1] + expect({ key, leaderLetter: letter !== undefined }).toEqual({ key, leaderLetter: true }) + expect({ key, opencodes: OPENCODE_LEADER.has(letter as string) }).toEqual({ key, opencodes: false }) + } + }) +}) diff --git a/packages/opencode/test/duplicate.test.ts b/packages/opencode/test/duplicate.test.ts index 1c2c7df6..dc3bea21 100644 --- a/packages/opencode/test/duplicate.test.ts +++ b/packages/opencode/test/duplicate.test.ts @@ -49,18 +49,31 @@ describe("configured twice in one OpenCode instance", () => { * tools, because OpenCode does not deduplicate them and duplicate names fail the model request. */ expect(Object.keys(fromBundle.tool ?? {}).filter((name) => name.startsWith("shell_"))).toEqual([]) + /** Setup too: one `cockpit_settings`, one skill and one `/cockpit-setup` between the two entries. */ + expect(standalone.tool?.cockpit_settings).toBeDefined() + expect(fromBundle.tool?.cockpit_settings).toBeUndefined() + const config = {} as { command?: Record; skills?: { paths: string[] } } + await (standalone as { config?: (c: unknown) => Promise }).config?.(config) + await (fromBundle as { config?: (c: unknown) => Promise }).config?.(config) + expect(Object.keys(config.command ?? {}).sort()).toEqual(["cockpit-setup", "status-setup", "statusline"]) + expect(config.skills?.paths.map((path) => path.split("/").at(-1)).sort()).toEqual([ + "cockpit-setup", + "status-setup", + ]) }) test("features switched off load nothing; separate instances each get Shell", async () => { const off = await bundle.server(fakeInput(), { - features: { shell: false, review: false, subagents: false }, + features: { shell: false, review: false, subagents: false, trail: false }, }) - expect(off.tool).toBeUndefined() + /** Only what every install has: the tools the `cockpit-setup` skill reads and writes with. */ + expect(Object.keys(off.tool ?? {}).sort()).toEqual(["cockpit_conventions", "cockpit_settings"]) /** One bay off leaves the others alone, which is the whole point of the switches. */ const shellOff = await bundle.server(fakeInput(), { features: { shell: false } }) expect(Object.keys(shellOff.tool ?? {}).filter((name) => name.startsWith("shell_"))).toEqual([]) expect(Object.keys(shellOff.tool ?? {})).toContain("review_list") expect(Object.keys(shellOff.tool ?? {})).toContain("subagents_list") + expect(Object.keys(shellOff.tool ?? {})).toContain("trail_add") const a = await bundle.server(fakeInput()) const b = await bundle.server(fakeInput()) expect(a.tool?.shell_start).toBeDefined() diff --git a/packages/opencode/tsconfig.json b/packages/opencode/tsconfig.json index bfddfbaf..393d555f 100644 --- a/packages/opencode/tsconfig.json +++ b/packages/opencode/tsconfig.json @@ -24,6 +24,9 @@ { "path": "../subagents" }, + { + "path": "../trail" + }, { "path": "../trust" } diff --git a/packages/review/README.md b/packages/review/README.md index 5aeeec79..98c8d3a0 100644 --- a/packages/review/README.md +++ b/packages/review/README.md @@ -33,6 +33,7 @@ its own with `review_open`, which appear in the panel beside yours. | key | | | --- | --- | | `ctrl+x v` | open, or close | +| `ctrl+x k` | right pane or full screen, from anywhere | | `tab` | move between the file list and the diff | | `j` / `k` | next / previous — a file on the left; on the right a line, running on into the next file | | `enter` | on the left: jump to a file, or fold a folder. On the right: fold or unfold this file | @@ -47,7 +48,13 @@ its own with `review_open`, which appear in the panel beside yours. | `B` | what the branch is compared against: its nearest parent, or one you pick | | `w` | half the window, or all of it | | `p` | what the panel is costing, in the footer | -| `q` | close | +| `o` | open an image or other binary in your system's viewer — both versions | +| `?` | every key, in the panel | +| `esc` / `q` | close | + +`ctrl+p` still opens OpenCode's command palette while the review is up: the review steps aside for +it (on OpenCode 1 it comes back when the palette closes; on OpenCode 2 it closes, and the palette's +"Open or close the changes" brings it back where you were). The mouse works too: click a file in the list to jump to it, click a heading to fold it, or its `+ note` / `[ ] viewed` buttons; the wheel scrolls whichever side it is over. @@ -59,6 +66,24 @@ The mouse works too: click a file in the list to jump to it, click a heading to | **uncommitted** | everything not committed, *including files git has never seen*. The default, because it is what you are looking at nine times in ten | | **branch** | everything this branch changes against its *nearest parent* — what its pull request would show. On `main â†� feature â†� X`, X is compared with `feature`, not `main`. `B` picks a different base, remembered per branch | +## Images and other binaries + +A binary is never drawn as text — git's rule decides it (a NUL in the first 8000 bytes). Its card +says what is true about it: + +``` +PNG 2880×1800 · 807 KB → 789 KB +2.56% of pixels changed · 601×221 at 1900,300 +[o] Open Both +``` + +PNG, APNG, JPEG, GIF, WebP and BMP are named with their dimensions; anything else is +`binary · 12.3 KB → 14.0 KB`. PNG and GIF are also decoded — in slices, off the draw path — for the +pixel diff and a small before/after preview in half blocks, with what changed lit. A preview shows +*where*; `o` opens both versions in your system's viewer for *what*. Binaries are read up to 32 MB +(text stops at 400 KB) and decoded up to 4096×4096 pixels; past either, the card still names and sizes +the file. + ## Seeing it without OpenCode ```sh @@ -66,7 +91,8 @@ bun packages/review/src/cli/preview.ts --fixture sprawl --width 200 ``` Draws the whole view against sample change sets — forty files, a three-thousand-line file, a created -file, a deleted one — with no OpenCode running. This is where the design is made. +file, a deleted one, every state of a changed image (`--fixture images`) — with no OpenCode running. +`--keys` draws the keys screen. This is where the design is made. ## When the code moves @@ -90,6 +116,25 @@ middle you may well start a new chat. That should no more lose your review than Outside the project, because a review is not part of the work — the first time it turns up in someone's `git status` it becomes a thing to explain in a pull request. +## Settings + +All optional, in the `review` section of `~/.config/opencode-cockpit/config.json` or a project's +`.cockpit.json` — read by both halves: + +```json +{ "review": { "variant": "full", "source": "branch" } } +``` + +| Setting | Default | | +| --- | --- | --- | +| `variant` | `right` | where it opens: `right` or `full` | +| `source` | `worktree` | what it opens on: `worktree` (uncommitted) or `branch` | +| `keybinds` | `{ "cockpit.review.open": "v", "cockpit.review.place": "k" }` | the two keys that work from anywhere | +| `enabled` | `true` | `false` turns Review off, its agent tools and guidance too; so does `features.review: false` | + +A value Review does not know is the default and a `!` row in the pane naming the ones it does. Every +bay's settings are on [Configuration](https://codestz.github.io/opencode-cockpit/configuration/). + ## Notes - **The plugin never writes files.** One thing edits your code and it is the agent you are already diff --git a/packages/review/measure/agent.ts b/packages/review/measure/agent.ts new file mode 100644 index 00000000..00105292 --- /dev/null +++ b/packages/review/measure/agent.ts @@ -0,0 +1,94 @@ +#!/usr/bin/env bun +/** + * Review's measurement: whether the guidance changes what a real agent does. A comment is waiting on + * a line of this branch's diff, left in Review; the user asks the agent to take care of "the note" — + * without naming Review or its tools. The turn must read the thread with review_list and answer it + * with review_reply, not only in chat. + * + * A turn in which the model called nothing at all measured nothing, and is tried again. + * + * bun packages/review/measure/agent.ts OpenCode on PATH, this checkout + * OPENCODE=~/.opencode/bin/opencodeold bun packages/review/measure/agent.ts + * bun packages/review/measure/agent.ts --plugin --runs 3 --model --keep + * + * `--plugin` is the server half: a package directory, by default this checkout's `packages/review`, + * built (`bun run build`). Exits 0 when every run passed, 1 otherwise. + */ + +import { mkdtempSync, realpathSync, rmSync } from "node:fs" +import { join, resolve } from "node:path" +import { brief, flag, measure, openCode, turn, world } from "../../../scripts/measure-agent.ts" +import { reviewPaths } from "../src/core/store/paths.ts" +import { createPersistence } from "../src/core/store/persist.ts" + +const oc = openCode() +const plugin = resolve(flag("--plugin") ?? join(import.meta.dir, "..")) +const runs = Number(flag("--runs")) || 1 +const model = flag("--model") ?? "opencode/space-bunny-free" +const keep = process.argv.includes("--keep") + +/** Points at the note the way a person would, not at the tool. */ +export const PROMPT = "I left you a note on the code — take care of it, then tell me what you did." + +const BRANCH = "feat/payment-timeouts" +const FILE = "src/config.ts" + +function git(cwd: string, ...args: string[]) { + const result = Bun.spawnSync(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" }) + if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")}: ${result.stderr}`) +} + +async function once(index: number) { + const work = mkdtempSync(`/tmp/ck-review-${index}-`) + const at = await world(oc, work, plugin, model) + try { + await Bun.write(join(at.project, FILE), "export const config = {\n retries: 3,\n}\n") + git(at.project, "init", "-q", "-b", "main") + git(at.project, "config", "user.email", "measure@example.com") + git(at.project, "config", "user.name", "Measure") + git(at.project, "add", "-A") + git(at.project, "commit", "-qm", "init") + git(at.project, "checkout", "-qb", BRANCH) + await Bun.write(join(at.project, FILE), "export const config = {\n retries: 3,\n timeout: 30,\n}\n") + + /** The thread, where Review keeps this branch's: under the run's own Cockpit home. */ + const store = createPersistence( + reviewPaths(realpathSync(at.project), BRANCH, { COCKPIT_HOME: at.env.COCKPIT_HOME }), + ) + await store.save({ + id: "rv_000000001", + file: FILE, + line: 3, + quoted: [" timeout: 30,"], + entries: [ + { + author: "you", + body: "Why thirty? The payment API can take a minute. Make it 60, and say the unit (seconds) in a comment.", + at: Date.now(), + }, + ], + status: "open", + }) + + const done = turn(at, PROMPT) + const listed = done.calls.some((c) => c.tool === "review_list" && c.status === "completed") + const replied = done.calls.filter((c) => c.tool === "review_reply" && c.status === "completed") + const thread = (await store.load()).find((each) => each.id === "rv_000000001") + const report = [ + `review_list ${listed} review_reply ${replied.length} ${replied.map((c) => JSON.stringify(c.input)).join(" ")}`, + `thread: ${thread?.status} tools: ${done.calls.map((c) => c.tool).join(", ")}`, + `said: ${brief(done.said)}`, + ] + if (done.calls.length === 0) return { ok: false, measured: false, report } + return { ok: listed && replied.length > 0, measured: true, report } + } finally { + if (!keep) rmSync(work, { recursive: true, force: true }) + else console.log(`kept ${work}`) + } +} + +await measure( + `Review measurement: ${oc.version} (${oc.bin}), plugin ${plugin}, ${model}, ${runs} run(s)`, + runs, + once, +) diff --git a/packages/review/package.json b/packages/review/package.json index 88ece186..69dc059c 100644 --- a/packages/review/package.json +++ b/packages/review/package.json @@ -54,7 +54,7 @@ }, "dependencies": { "@opencode-cockpit/client": "workspace:*", - "@opencode-ai/plugin": "1.18.31" + "@opencode-ai/plugin": "1.18.33" }, "devDependencies": { "@opentui/core": "0.4.5", diff --git a/packages/review/src/agent/plugin.ts b/packages/review/src/agent/plugin.ts index 39b2e47f..94c7f198 100644 --- a/packages/review/src/agent/plugin.ts +++ b/packages/review/src/agent/plugin.ts @@ -1,10 +1,12 @@ import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client" import { dualServer, + openText, type ServerHost, type ServerParts, type ServerStart, } from "@opencode-cockpit/client/server" +import { loadReview } from "../core/config.ts" import { waitingOn } from "../core/model/thread.ts" import { reviewPaths } from "../core/store/paths.ts" import { createPersistence, type Persistence } from "../core/store/persist.ts" @@ -12,16 +14,21 @@ import { createTools } from "./tools/index.ts" import type { FileContents } from "./tools/shared.ts" /** - * What the agent is told about reviews, once, at the top of the conversation. + * What the agent is told about reviews, before every request. * * Short on purpose: the tools describe themselves, and this only has to say the thing the tool - * descriptions cannot — that the review is where this conversation happens, not the chat. + * descriptions cannot — that the review is where this conversation happens, not the chat. Tools are + * named as the model calls them: `tools.review_list` in OpenCode 2's Code Mode, whose catalog cuts + * each description at ~115 characters (docs/opencode/trail-server.md). */ -const GUIDANCE = `## Review comments (opencode-cockpit) -A person can leave comments on specific lines of this branch's diff. review_list shows the ones waiting on you. -Work them before answering in chat: change the code, then review_reply with resolved=true, or reply saying why not. +export function reviewGuidance(version: 1 | 2): string { + const t = (name: string) => (version === 2 ? `tools.${name}` : name) + return `## Review comments (opencode-cockpit) +A person can leave comments on specific lines of this branch's diff. ${t("review_list")} shows the ones waiting on you. +Work them before answering in chat: change the code, then ${t("review_reply")} with resolved=true, or reply saying why not. Resolving is checked against the file — a resolve on code you did not change is recorded as a reply instead. You can open threads yourself: leaving notes as you read is a way to plan work that survives this conversation.` +} export const REVIEW_PACKAGE = "@opencode-cockpit/review" @@ -32,18 +39,24 @@ export interface ReviewServerOptions { /** Review's server half as a factory, so bundles such as `opencode-cockpit` can include it. */ export function createReviewServer({ source = REVIEW_PACKAGE }: ReviewServerOptions = {}): ServerStart { - return async (host) => { + return async (host, options) => { + /** `enabled: false` is both halves: no tools and no guidance either. */ + const { config } = loadReview(host.directory, options) + if (!config.enabled) { + host.log.child("review").info("off in the settings") + return {} + } const claim = claimFeature(host.scope, "review", source) if (!claim.active) { host.log.warn(duplicateFeatureMessage("Review", claim.owner, source)) return {} } - const parts = await reviewParts(host) + const parts = await reviewParts(host, config.keybinds) return { ...parts, dispose: () => claim.release() } } } -async function reviewParts(host: ServerHost): Promise { +async function reviewParts(host: ServerHost, keybinds: Record): Promise { const { directory } = host /** * The branch is asked for per call, not cached. @@ -62,6 +75,7 @@ async function reviewParts(host: ServerHost): Promise { return (await proc.exited) === 0 && out && out !== "HEAD" ? out : undefined } + const guidance = reviewGuidance(host.version) const store = async (): Promise => createPersistence(reviewPaths(directory, await branchOf())) /** @@ -79,9 +93,17 @@ async function reviewParts(host: ServerHost): Promise { return { tools: createTools({ directory, store, contentsOf }), - /** Said once per conversation, the way Shell explains its shells. */ + /** For the Cockpit-wide line: where the threads are read and answered. */ + surfaces: [ + { + what: "review threads", + where: "Review", + open: openText("review", "cockpit.review.open", keybinds, "changes"), + }, + ], + /** Before every request, the way Shell explains its shells. */ system: async () => { - const system = [GUIDANCE] + const system = [guidance] /** * And what is actually waiting, so the agent does not have to ask to find out there is nothing. @@ -96,7 +118,7 @@ async function reviewParts(host: ServerHost): Promise { if (waiting.length > 0) { const files = [...new Set(waiting.map((thread) => thread.file))] system.push( - `${waiting.length} review comment${waiting.length === 1 ? "" : "s"} are waiting on you in ${files.join(", ")}. Read them with review_list.`, + `${waiting.length} review comment${waiting.length === 1 ? "" : "s"} are waiting on you in ${files.join(", ")}. Read them with ${host.version === 2 ? "tools.review_list" : "review_list"}.`, ) } return system diff --git a/packages/review/src/cli/preview.ts b/packages/review/src/cli/preview.ts index 3ec0984d..9f2e2698 100755 --- a/packages/review/src/cli/preview.ts +++ b/packages/review/src/cli/preview.ts @@ -30,6 +30,9 @@ if (args.includes("--help")) { --file which file to show the diff of (default: the second one) --width columns (default: this terminal) --height rows (default: this terminal) + --diff the cursor in the diff pane rather than the file list + --keys the [?] Keys screen + --settings n settings notices: the ! row under the header (1 or 2) `) process.exit(0) } @@ -81,12 +84,17 @@ const BG: Record = { const DIM = `${ESC}[2m` +/** A packed `0xRRGGBB` as an SGR colour: 38 for ink, 48 for background. */ +const exact = (layer: 38 | 48, colour: number) => + `${ESC}[${layer};2;${(colour >> 16) & 255};${(colour >> 8) & 255};${colour & 255}m` + const paint = (row: Row): string => row.runs - .map( - (run) => - `${BG[run.fill ?? "none"]}${FG[run.tone ?? "text"]}${run.bold ? BOLD : ""}${run.faint ? DIM : ""}${run.text}${RESET}`, - ) + .map((run) => { + const bg = typeof run.background === "number" ? exact(48, run.background) : BG[run.fill ?? "none"] + const fg = typeof run.color === "number" ? exact(38, run.color) : FG[run.tone ?? "text"] + return `${bg}${fg}${run.bold ? BOLD : ""}${run.faint ? DIM : ""}${run.text}${RESET}` + }) .join("") const width = Number(flag("width") ?? process.stdout.columns ?? 120) @@ -116,7 +124,28 @@ const file = flag("file") ?? changes.files[1]?.path ?? changes.files[0]?.path /** What the pane passes too: without it the footer offered `[s] Submit` dimmed with notes waiting. */ const waiting = waitingOnAgent(review).length -const rows = layout(changes, review, { file, cursor: file, context: 3, waiting }, { width, height }) +const rows = layout( + changes, + review, + { + file, + cursor: file, + context: 3, + waiting, + ...(fixture.looks ? { looks: fixture.looks } : {}), + ...(args.includes("--keys") ? { keys: true } : {}), + ...(args.includes("--settings") + ? { + settings: [ + 'settings: "review.sidebarOrder" is no longer read — run /cockpit-setup', + 'settings: "review.source" should be a string; the default is used', + ].slice(0, Number(flag("settings")) || 1), + } + : {}), + ...(args.includes("--diff") ? { pane: "diff" as const } : {}), + }, + { width, height }, +) console.log(`\n${BOLD}${name}${RESET} — ${fixture.about} ${FG.muted}${width}×${height}${RESET}`) console.log(`${FG.border}┌${"─".repeat(width - 2)}â”�${RESET}`) diff --git a/packages/review/src/core/config.ts b/packages/review/src/core/config.ts new file mode 100644 index 00000000..41e03afa --- /dev/null +++ b/packages/review/src/core/config.ts @@ -0,0 +1,80 @@ +/** + * Review's settings, through the loader every bay shares (`@opencode-cockpit/client/settings`): + * + * ~/.config/opencode-cockpit/config.json → /.cockpit.json → plugin-entry options + * + * Only the `review` section of a file is read. Before 0.9 Review read no file at all: its settings + * existed only on the plugin entry in `tui.json`. A value Review does not know (`"variant": "left"`) + * is a notice and the default, never a placement or a source that does not exist. + */ + +import { baySettings, OPTIONS_SOURCE, type SettingsNotice } from "@opencode-cockpit/client/settings" +import type { Source } from "./model/review.ts" +import { VARIANTS, type Variant } from "./view/frame.ts" + +const SOURCES: readonly Source[] = ["worktree", "branch"] + +export interface ReviewConfig { + /** Off switch for this bay, wherever it is written. `features.review: false` too. */ + enabled: boolean + /** Which placement to open in: right | full. */ + variant: Variant + /** + * What to review on open: worktree | branch. + * + * Uncommitted by default, because that is what you are looking at nine times in ten — the work + * that just happened. Branch is for reading a pull request, which is a thing you choose to do. + */ + source: Source + keybinds: Record +} + +export const DEFAULTS = { variant: "right" as Variant, source: "worktree" as Source } + +export interface LoadedReview { + config: ReviewConfig + /** Settings to fix: an old name, a wrong kind, a value Review does not know. */ + notices: SettingsNotice[] +} + +/** Reads and merges every source. Never throws. */ +export function loadReview( + directory: string, + options?: unknown, + env: Record = process.env, +): LoadedReview { + const loaded = baySettings("review", DEFAULTS, { options, where: { directory, env } }) + const notices = [...loaded.notices] + /** Where the value in force was written: the entry's options win, then the last file that set it. */ + const own = (options ?? {}) as Record | undefined> + const fileOf = (key: string): string => { + const entry = typeof own.review === "object" && own.review !== null ? own.review : own + if ((entry as Record)[key] !== undefined) return OPTIONS_SOURCE + const layer = [...loaded.settings.layers] + .reverse() + .find((each) => each.sections.review?.[key] !== undefined) + return layer?.path ?? OPTIONS_SOURCE + } + /** One of the names it knows, or the default and a notice saying which names those are. */ + const known = (key: "variant" | "source", valid: readonly T[], fallback: T): T => { + const value = loaded.config[key] + if ((valid as readonly string[]).includes(value)) return value as T + notices.push({ + bay: "review", + file: fileOf(key), + kind: "invalid", + old: `review.${key}`, + text: `"review.${key}" is "${value}"; it is one of ${valid.join(", ")} — "${fallback}" is used`, + }) + return fallback + } + return { + config: { + enabled: loaded.config.enabled, + variant: known("variant", VARIANTS, DEFAULTS.variant), + source: known("source", SOURCES, DEFAULTS.source), + keybinds: loaded.config.keybinds, + }, + notices, + } +} diff --git a/packages/review/src/core/diff/hunks.ts b/packages/review/src/core/diff/hunks.ts index dae99075..d34deaf6 100644 --- a/packages/review/src/core/diff/hunks.ts +++ b/packages/review/src/core/diff/hunks.ts @@ -91,8 +91,8 @@ function common(before: string[], after: string[]): { a: number; b: number }[] { * * The table below is `(n+1)×(m+1)` 32-bit entries, so the cost is the *product*: two 5,000-line files * would allocate ~100 MB inside the TUI's worker thread, which is not a price a diff view gets to - * charge. The trim above means this is reached only by two genuinely different large files — a - * wholesale rewrite — and those are shown as all-out-then-all-in, which is what they are. + * charge. A stretch past it is split on its unique lines first (`align`); only one with none left — + * a wholesale rewrite — is shown as all-out-then-all-in, which is what it is. */ const LIMIT = 2000 @@ -166,39 +166,7 @@ function diffUncached(before: string, after: string): Line[] { lines.push({ kind: "context", before: index + 1, after: index + 1, text: old[index] as string }) } - const oldMid = old.slice(head, old.length - tail) - const nowMid = now.slice(head, now.length - tail) - - /** - * Only a genuine rewrite reaches the cap now, and a rewrite really is "all of it out, all of it in" - * — aligning two unrelated files line by line produces noise, slowly. - */ - if (oldMid.length > LIMIT || nowMid.length > LIMIT) { - lines.push(...replaced(oldMid, nowMid, head, head)) - } else { - let a = 0 - let b = 0 - for (const pair of [...common(oldMid, nowMid), { a: oldMid.length, b: nowMid.length }]) { - while (a < pair.a) { - lines.push({ kind: "remove", before: head + a + 1, text: oldMid[a] as string }) - a++ - } - while (b < pair.b) { - lines.push({ kind: "add", after: head + b + 1, text: nowMid[b] as string }) - b++ - } - if (pair.a < oldMid.length) { - lines.push({ - kind: "context", - before: head + a + 1, - after: head + b + 1, - text: oldMid[pair.a] as string, - }) - a++ - b++ - } - } - } + align(old.slice(head, old.length - tail), now.slice(head, now.length - tail), head, head, lines) for (let index = 0; index < tail; index++) { const beforeAt = old.length - tail + index @@ -213,6 +181,143 @@ function diffUncached(before: string, after: string): Line[] { return lines } +/** + * Two stretches of the files, aligned line by line, onto `out`. `beforeAt` and `afterAt` are where + * each stretch starts in its file, so the numbers point at the real lines. + * + * Small enough, it is the table above. Too large for the table, it is split first — which is what + * makes two edits far apart a diff of two edits. The trim in `diffUncached` only removes what is + * identical at the very ends, so an edit at line 40 and another at line 2,900 left a 2,860-line + * middle; past the cap that was drawn as every line out and every line back in, a rewrite of a file + * that had two lines changed. + * + * The split is patience diff's: lines that occur exactly once on each side are almost always the + * same line, so the longest run of them in order is a set of fixed points, and the stretches between + * them are aligned on their own — each far smaller, and each split again if it is still too large. + * Only a stretch with no such line left is a genuine rewrite, and drawn as one. + */ +function align(old: string[], now: string[], beforeAt: number, afterAt: number, out: Line[]): void { + /** Identical ends of the stretch are context, and cost nothing to take off first. */ + let head = 0 + while (head < old.length && head < now.length && old[head] === now[head]) { + out.push({ + kind: "context", + before: beforeAt + head + 1, + after: afterAt + head + 1, + text: old[head] as string, + }) + head++ + } + let tail = 0 + while ( + tail < old.length - head && + tail < now.length - head && + old[old.length - 1 - tail] === now[now.length - 1 - tail] + ) + tail++ + const oldMid = head === 0 && tail === 0 ? old : old.slice(head, old.length - tail) + const nowMid = head === 0 && tail === 0 ? now : now.slice(head, now.length - tail) + const at = { before: beforeAt + head, after: afterAt + head } + + if (oldMid.length === 0 || nowMid.length === 0) out.push(...replaced(oldMid, nowMid, at.before, at.after)) + else if (oldMid.length <= LIMIT && nowMid.length <= LIMIT) aligned(oldMid, nowMid, at.before, at.after, out) + else { + const anchors = uniqueAnchors(oldMid, nowMid) + /** Nothing in common to hold on to: a rewrite, honestly drawn as one. */ + if (anchors.length === 0) out.push(...replaced(oldMid, nowMid, at.before, at.after)) + else { + let a = 0 + let b = 0 + for (const anchor of anchors) { + align(oldMid.slice(a, anchor.a), nowMid.slice(b, anchor.b), at.before + a, at.after + b, out) + out.push({ + kind: "context", + before: at.before + anchor.a + 1, + after: at.after + anchor.b + 1, + text: oldMid[anchor.a] as string, + }) + a = anchor.a + 1 + b = anchor.b + 1 + } + align(oldMid.slice(a), nowMid.slice(b), at.before + a, at.after + b, out) + } + } + + for (let index = 0; index < tail; index++) { + const a = old.length - tail + index + const b = now.length - tail + index + out.push({ kind: "context", before: beforeAt + a + 1, after: afterAt + b + 1, text: old[a] as string }) + } +} + +/** A stretch within the table's cap, aligned by the common subsequence. */ +function aligned(old: string[], now: string[], beforeAt: number, afterAt: number, out: Line[]): void { + let a = 0 + let b = 0 + for (const pair of [...common(old, now), { a: old.length, b: now.length }]) { + while (a < pair.a) { + out.push({ kind: "remove", before: beforeAt + a + 1, text: old[a] as string }) + a++ + } + while (b < pair.b) { + out.push({ kind: "add", after: afterAt + b + 1, text: now[b] as string }) + b++ + } + if (pair.a < old.length) { + out.push({ + kind: "context", + before: beforeAt + a + 1, + after: afterAt + b + 1, + text: old[pair.a] as string, + }) + a++ + b++ + } + } +} + +/** + * Lines that occur exactly once in each stretch, paired, and cut down to the longest run that is in + * order on both sides — patience sorting, `n log n`. These are the fixed points the stretch is split on. + */ +function uniqueAnchors(old: readonly string[], now: readonly string[]): { a: number; b: number }[] { + const once = (lines: readonly string[]) => { + const seen = new Map() + lines.forEach((line, index) => { + seen.set(line, seen.has(line) ? -1 : index) + }) + return seen + } + const inOld = once(old) + const inNow = once(now) + const pairs: { a: number; b: number }[] = [] + for (const [line, a] of inOld) { + const b = inNow.get(line) + if (a >= 0 && b !== undefined && b >= 0) pairs.push({ a, b }) + } + pairs.sort((x, y) => x.a - y.a) + + /** Longest increasing run of `b`: piles of their smallest tops, each card linked to the one before. */ + const tops: number[] = [] + const previous = new Int32Array(pairs.length).fill(-1) + for (let index = 0; index < pairs.length; index++) { + const b = (pairs[index] as { b: number }).b + let low = 0 + let high = tops.length + while (low < high) { + const mid = (low + high) >> 1 + if ((pairs[tops[mid] as number] as { b: number }).b < b) low = mid + 1 + else high = mid + } + if (low > 0) previous[index] = tops[low - 1] as number + tops[low] = index + } + const run: { a: number; b: number }[] = [] + for (let at = tops.at(-1) ?? -1; at >= 0; at = previous[at] as number) + run.push(pairs[at] as { a: number; b: number }) + return run.reverse() +} + /** * The changed parts, with a few unchanged lines either side for orientation, and the long runs of * untouched file left out — which is the whole reason a diff is readable at all. diff --git a/packages/review/src/core/fixtures.ts b/packages/review/src/core/fixtures.ts index fb77123c..aff4faba 100644 --- a/packages/review/src/core/fixtures.ts +++ b/packages/review/src/core/fixtures.ts @@ -6,7 +6,9 @@ * The preview draws all of these, so none of them is discovered in OpenCode. */ -import type { ChangeSet } from "./model/review.ts" +import { type ImageLook, tooLargeText } from "./image/looks.ts" +import { SAMPLE, sampleLook } from "./image/samples.ts" +import type { BinarySide, ChangeSet, FileChange } from "./model/review.ts" const lines = (count: number, token: string) => `${Array.from({ length: count }, (_, i) => `${token} ${i + 1}`).join("\n")}\n` @@ -42,7 +44,36 @@ export function merge(base: Config, over: Config): Config { } ` -export const FIXTURES: Record = { +/** A PNG side, as git and the header reader would describe it. */ +const png = (width: number, height: number, size: number): BinarySide => ({ + size, + image: { format: "png", width, height }, +}) + +/** A binary file change: no text, no counts, what each side is. */ +const image = ( + path: string, + before: BinarySide | undefined, + after: BinarySide | undefined, + change?: FileChange["change"], +): FileChange => ({ + path, + before: "", + after: "", + additions: 0, + deletions: 0, + ...(change ? { change } : {}), + binary: { ...(before ? { before, revision: "a1b2c3d" } : {}), ...(after ? { after } : {}) }, +}) + +export interface Fixture { + about: string + changes: ChangeSet + /** What the pane would know about the images once decoded. */ + looks?: ReadonlyMap +} + +export const FIXTURES: Record = { /** The ordinary case: a few files, edits you can read at a glance. */ turn: { about: "one turn's work — three files, a mix of edits", @@ -185,6 +216,45 @@ export const FIXTURES: Record = { }, }, + /** + * Binaries: every state an image change can be in, and a binary that is not an image. + * + * The pictures are drawn in code (`image/samples.ts`) and go through the real shrink and pixel diff, + * so what the preview shows is what the pane would. + */ + images: { + about: "binaries — changed, resized, new, deleted, a JPEG, one too large, one not an image", + changes: { + source: "branch", + files: [ + /** First, so the preview marks it viewed and the pictures below stay open. */ + image("assets/font.woff2", { size: 12_595 }, { size: 14_336 }), + image("media/dashboard.png", png(288, 180, 807_358), png(288, 180, 789_120)), + image("media/thumbnail.png", png(288, 180, 826_548), png(144, 90, 220_412)), + image("media/logo.png", undefined, png(96, 96, 12_904), "added"), + image( + "media/old-banner.gif", + { size: 574_310, image: { format: "gif", width: 288, height: 180 } }, + undefined, + "deleted", + ), + image( + "media/photo.jpg", + { size: 368_596, image: { format: "jpeg", width: 4032, height: 3024 } }, + { size: 341_022, image: { format: "jpeg", width: 4032, height: 3024 } }, + ), + image("media/poster.png", png(12_000, 9_000, 182_400_000), png(12_000, 9_000, 183_100_512)), + ], + }, + looks: new Map([ + ["media/dashboard.png", sampleLook(SAMPLE.before, SAMPLE.after, 1)], + ["media/thumbnail.png", sampleLook(SAMPLE.before, SAMPLE.resized, 2)], + ["media/logo.png", sampleLook(undefined, SAMPLE.logo, 3)], + ["media/old-banner.gif", sampleLook(SAMPLE.before, undefined, 4)], + ["media/poster.png", { problem: tooLargeText(12_000, 9_000), stamp: 5 }], + ]), + }, + /** Nothing to review. The first thing anyone sees, and the easiest to leave looking broken. */ clean: { about: "no changes at all — what you see before the agent has done anything", diff --git a/packages/review/src/core/git/sources.ts b/packages/review/src/core/git/sources.ts index 7cd44b9b..c634bea0 100644 --- a/packages/review/src/core/git/sources.ts +++ b/packages/review/src/core/git/sources.ts @@ -10,10 +10,10 @@ * repository, rather than mocked into agreeing with itself. */ -import { readFileSync } from "node:fs" import { join } from "node:path" import { countChanges, diffLines } from "../diff/hunks.ts" -import type { FileChange } from "../model/review.ts" +import { HEADER_BYTES, looksBinary, sniff } from "../image/sniff.ts" +import type { BinarySide, FileChange } from "../model/review.ts" export interface GitResult { files: FileChange[] @@ -25,8 +25,16 @@ export interface GitResult { /** More than this and the pane is not the right tool — and reading them all would stall the TUI. */ const MAX_FILES = 200 -/** A file bigger than this is almost certainly not being read line by line. */ +/** A text file bigger than this is almost certainly not being read line by line. */ const MAX_BYTES = 400_000 +/** + * A binary is read whole up to this — its own cap, far above the text one. + * + * Every real screenshot is over 400 KB, and under the text cap each one was skipped with "too large + * to review here": the file most worth a look silently was not in the review. Past this the header + * is still read, so the file is still named, sized and described. + */ +export const MAX_BINARY_BYTES = 32 * 1024 * 1024 export type RunGit = (args: string[], cwd: string) => Promise<{ ok: boolean; out: string }> @@ -55,21 +63,149 @@ export async function headOf(cwd: string, git: RunGit = runGit): Promise 0 ? head : undefined } -/** A file's contents at a revision, or "" when it did not exist there — which is what a diff wants. */ -async function show(git: RunGit, cwd: string, revision: string, path: string): Promise { - const result = await git(["show", `${revision}:${path}`], cwd) - return result.ok ? result.out : "" +/** Some or all of a file's bytes, and how many there are in all. */ +export interface Read { + bytes: Uint8Array + size: number + /** False when only the first bytes were read, the file being over the cap. */ + whole: boolean +} + +const joined = (chunks: readonly Uint8Array[], total: number): Uint8Array => { + if (chunks.length === 1) return chunks[0] as Uint8Array + const out = new Uint8Array(total) + let at = 0 + for (const chunk of chunks) { + out.set(chunk, at) + at += chunk.length + } + return out +} + +/** + * A file as git has it at a revision, as **bytes** — or undefined when it did not exist there. + * + * Read as text, git's output went through UTF-8 and a 91 KB PNG came out as 166 KB of something else: + * the bytes were gone, not merely ugly. `cat-file blob` rather than `show`, so nothing (a textconv, a + * pager) stands between the blob and what is read. Past `cap` the stream is cut and only the size is + * asked for — a 200 MB asset is described, never held. + */ +export async function readBlob( + cwd: string, + revision: string, + path: string, + cap = MAX_BINARY_BYTES, +): Promise { + const proc = Bun.spawn(["git", "cat-file", "blob", `${revision}:${path}`], { + cwd, + stdout: "pipe", + stderr: "ignore", + }) + const reader = proc.stdout.getReader() + const chunks: Uint8Array[] = [] + let total = 0 + let over = false + while (true) { + const { done, value } = await reader.read() + if (done) break + chunks.push(value) + total += value.length + if (total > cap) { + over = true + break + } + } + if (over) { + await reader.cancel().catch(() => {}) + proc.kill() + await proc.exited + const sized = await runGit(["cat-file", "-s", `${revision}:${path}`], cwd) + const size = Number(sized.out.trim()) + return { + bytes: joined(chunks, total).subarray(0, HEADER_BYTES), + size: sized.ok && Number.isFinite(size) ? size : total, + whole: false, + } + } + if ((await proc.exited) !== 0) return undefined + return { bytes: joined(chunks, total), size: total, whole: true } } -function readWorking(cwd: string, path: string): { text: string; error?: string } { +/** + * The working copy's bytes, or undefined when it is not there (deleted: an empty "after" is right). + * + * A file over the text cap is read in full only when its first bytes say it is binary and it is under + * the binary cap; otherwise its head is enough to say what it is. + */ +export async function readWorking(cwd: string, path: string): Promise { try { - const full = join(cwd, path) - const file = Bun.file(full) - if (file.size > MAX_BYTES) return { text: "", error: `${path}: too large to review here` } - return { text: readFileSync(full, "utf8") } + const file = Bun.file(join(cwd, path)) + const size = file.size + if (size > MAX_BYTES) { + const head = new Uint8Array(await file.slice(0, HEADER_BYTES).arrayBuffer()) + if (!looksBinary(head) || size > MAX_BINARY_BYTES) return { bytes: head, size, whole: false } + } + const bytes = new Uint8Array(await file.arrayBuffer()) + return { bytes, size: bytes.length, whole: true } } catch { - // Deleted from the working tree: an empty "after" is exactly right. - return { text: "" } + return undefined + } +} + +/** UTF-8, with a byte-order mark kept on both sides alike, so a BOM is never a change of its own. */ +const decoder = new TextDecoder("utf-8", { ignoreBOM: true }) + +const sameBytes = (a: Read | undefined, b: Read | undefined): boolean => { + if (!a || !b) return a === b + if (!a.whole || !b.whole || a.size !== b.size) return false + return Buffer.from(a.bytes.buffer, a.bytes.byteOffset, a.bytes.length).equals(b.bytes) +} + +/** One side of a binary: its size, and what its header says if it is an image. */ +const sideOf = (read: Read | undefined): BinarySide | undefined => { + if (!read) return undefined + const image = sniff(read.bytes) + return { size: read.size, ...(image ? { image } : {}) } +} + +/** + * Both sides read, one file decided: text, binary, too large, or unchanged. + * + * Binary is decided once, for the pair — a file that became binary, or stopped being, is not a text + * diff on either side. + */ +export function decide( + path: string, + before: Read | undefined, + after: Read | undefined, + change: FileChange["change"], + from: string | undefined, + revision: string, +): { file?: FileChange; error?: string } { + if (sameBytes(before, after) && change !== "renamed") return {} + if ((before && looksBinary(before.bytes)) || (after && looksBinary(after.bytes))) { + const sides = { before: sideOf(before), after: sideOf(after) } + return { + file: { + ...fileOf(path, "", "", change, from), + binary: { + ...(sides.before ? { before: sides.before } : {}), + ...(sides.after ? { after: sides.after } : {}), + ...(before ? { revision } : {}), + }, + }, + } + } + if ((after && !after.whole) || (after && after.size > MAX_BYTES) || (before && !before.whole)) + return { error: `${path}: too large to review here` } + return { + file: fileOf( + path, + before ? decoder.decode(before.bytes) : "", + after ? decoder.decode(after.bytes) : "", + change, + from, + ), } } @@ -102,14 +238,11 @@ export async function worktreeChanges(cwd: string, git: RunGit = runGit): Promis break } const change = statusChange(code, from) - const before = code.includes("?") || code.includes("C") ? "" : await show(git, cwd, "HEAD", from ?? path) - const { text: after, error } = readWorking(cwd, path) - if (error) { - errors.push(error) - continue - } - if (before === after && change !== "renamed") continue - files.push(fileOf(path, before, after, change, from)) + const before = + code.includes("?") || code.includes("C") ? undefined : await readBlob(cwd, "HEAD", from ?? path) + const { file, error } = decide(path, before, await readWorking(cwd, path), change, from, "HEAD") + if (error) errors.push(error) + if (file) files.push(file) } return { files, errors } } @@ -320,14 +453,10 @@ export async function branchChanges( break } const { change, from } = changed.get(path) ?? { change: undefined } - const before = change === "added" ? "" : await show(git, cwd, fork, from ?? path) - const { text: after, error } = readWorking(cwd, path) - if (error) { - errors.push(error) - continue - } - if (before === after && change !== "renamed") continue - files.push(fileOf(path, before, after, change, from)) + const before = change === "added" ? undefined : await readBlob(cwd, fork, from ?? path) + const { file, error } = decide(path, before, await readWorking(cwd, path), change, from, fork) + if (error) errors.push(error) + if (file) files.push(file) } return { files, errors, base: against } } @@ -337,5 +466,8 @@ export async function branchChanges( * sources for one number is how a list ends up saying +12 above a hunk showing eleven lines. */ export function withCounts(files: readonly FileChange[]): FileChange[] { - return files.map((file) => ({ ...file, ...countChanges(diffLines(file.before, file.after)) })) + /** A binary has no lines to count; its card says what changed instead. */ + return files.map((file) => + file.binary ? file : { ...file, ...countChanges(diffLines(file.before, file.after)) }, + ) } diff --git a/packages/review/src/core/image/describe.ts b/packages/review/src/core/image/describe.ts new file mode 100644 index 00000000..89c29101 --- /dev/null +++ b/packages/review/src/core/image/describe.ts @@ -0,0 +1,89 @@ +/** + * A binary file's change, in words. + * + * `PNG 2880×1800 · 807 KB → 789 KB`, then `2.56% of pixels changed · 601×221 at 1900,300`. In order of + * how often each is the whole answer (docs/opencode/images.md): did it change and how, how much and + * where. Plain strings, so the card, the preview CLI and anything else that ever has to say it say the + * same thing. + */ + +import type { BinaryChange, BinarySide } from "../model/review.ts" +import type { PixelDiff } from "./pixels.ts" +import { formatName } from "./sniff.ts" + +/** `512 B`, `12.3 KB`, `807 KB`, `13.5 MB`: three significant figures at most, in 1024s. */ +export function sizeText(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + const units = ["KB", "MB", "GB"] + let value = bytes / 1024 + let unit = 0 + while (value >= 1024 && unit < units.length - 1) { + value /= 1024 + unit++ + } + const shown = value >= 100 ? Math.round(value).toString() : value.toFixed(1) + return `${shown} ${units[unit]}` +} + +const dimensions = (side: BinarySide | undefined): string => + side?.image ? `${side.image.width}×${side.image.height}` : "" + +const kind = (side: BinarySide | undefined): string => (side?.image ? formatName(side.image) : "binary") + +/** + * What the file is, and what happened to its size and shape. + * + * PNG 2880×1800 · 807 KB → 789 KB edited, same dimensions + * PNG 2880×1800 → 1440×900 · 807 KB → 220 KB + * PNG 640×400 → JPEG 640×400 · 91 KB → 40 KB + * PNG 2880×1800 · 807 KB added or deleted: the one side there is + * binary · 12.3 KB → 14.0 KB not an image this bay can name + */ +export function summaryText(binary: BinaryChange): string { + const { before, after } = binary + const sizes = [before, after] + .filter((side): side is BinarySide => side !== undefined) + .map((side) => sizeText(side.size)) + .join(" → ") + if (!before || !after) { + const side = before ?? after + return [kind(side), dimensions(side)].filter(Boolean).join(" ") + (sizes ? ` · ${sizes}` : "") + } + const was = [kind(before), dimensions(before)].filter(Boolean).join(" ") + const now = [kind(after), dimensions(after)].filter(Boolean).join(" ") + let shape: string + if (was === now) shape = was + else if (kind(before) === kind(after)) shape = `${was} → ${dimensions(after)}` + else shape = `${was} → ${now}` + return `${shape} · ${sizes}` +} + +/** Whether both sides are images of one size — the only case a pixel percentage means anything. */ +export const sameDimensions = (binary: BinaryChange): boolean => + binary.before?.image !== undefined && + binary.after?.image !== undefined && + binary.before.image.width === binary.after.image.width && + binary.before.image.height === binary.after.image.height + +/** `2.56%`, `0.04%`, `<0.01%`, `100%`. */ +export function percentText(changed: number, total: number): string { + if (total === 0 || changed === 0) return "0%" + const percent = (changed / total) * 100 + if (percent < 0.01) return "<0.01%" + if (percent >= 99.995 && changed < total) return ">99.99%" + if (changed === total) return "100%" + return `${percent < 10 ? percent.toFixed(2) : percent.toFixed(1)}%` +} + +/** + * How much changed, and where: `2.56% of pixels changed · 601×221 at 1900,300`. + * + * Same pixels with different bytes is its own answer — the file was re-encoded or its metadata + * changed, and the picture did not. + */ +export function pixelText(diff: PixelDiff): string { + if (diff.changed === 0) return "Same pixels — only the encoding or metadata changed" + const box = diff.box + const where = box ? ` · ${box.width}×${box.height} at ${box.x},${box.y}` : "" + return `${percentText(diff.changed, diff.total)} of pixels changed${where}` +} diff --git a/packages/review/src/core/image/gif.ts b/packages/review/src/core/image/gif.ts new file mode 100644 index 00000000..64ea73fd --- /dev/null +++ b/packages/review/src/core/image/gif.ts @@ -0,0 +1,200 @@ +/** + * A GIF's first frame to RGBA, and how many frames it has. + * + * LZW, global and local colour tables, interlacing, the transparent index. Only the first frame is + * drawn — a review asks "what does it look like", and a frame counter answers "is it animated" — but + * every frame is walked over so the count is true. Byte-exact against PIL on the spike's fixtures. + */ + +import type { Pixels } from "./png.ts" +import { finish, finishSoon, SLICE, type Steps } from "./steps.ts" + +const u16 = (b: Uint8Array, o: number) => (b[o] ?? 0) | ((b[o + 1] ?? 0) << 8) + +function* gifSteps(b: Uint8Array): Steps { + if (b.length < 13 || String.fromCharCode(...b.subarray(0, 4)) !== "GIF8") throw new Error("not a GIF") + const width = u16(b, 6) + const height = u16(b, 8) + if (width === 0 || height === 0) throw new Error("GIF has no pixels") + const flags = b[10] as number + let o = 13 + let global: Uint8Array | undefined + if (flags & 0x80) { + const size = 3 << ((flags & 7) + 1) + global = b.subarray(o, o + size) + o += size + } + const out = new Uint8Array(width * height * 4) + let transparent = -1 + let frames = 0 + let decoded = false + /** Skips a run of sub-blocks, each a length byte and that many bytes, ended by a zero. */ + const skipBlocks = () => { + while (o < b.length && b[o] !== 0) o += (b[o] as number) + 1 + o++ + } + while (o < b.length) { + const block = b[o++] as number + if (block === 0x3b) break + if (block === 0x21) { + const label = b[o++] as number + /** The graphic control extension before the first image says which index is see-through. */ + if (label === 0xf9 && !decoded && ((b[o + 1] ?? 0) & 1) === 1) transparent = b[o + 4] ?? -1 + skipBlocks() + continue + } + if (block !== 0x2c) throw new Error("GIF block not understood") + frames++ + const left = u16(b, o) + const top = u16(b, o + 2) + const fw = u16(b, o + 4) + const fh = u16(b, o + 6) + const frameFlags = b[o + 8] ?? 0 + o += 9 + let table = global + if (frameFlags & 0x80) { + const size = 3 << ((frameFlags & 7) + 1) + table = b.subarray(o, o + size) + o += size + } + const minCode = b[o++] ?? 2 + if (decoded) { + skipBlocks() + continue + } + const parts: Uint8Array[] = [] + let total = 0 + while (o < b.length && b[o] !== 0) { + const n = b[o] as number + parts.push(b.subarray(o + 1, o + 1 + n)) + total += n + o += n + 1 + } + o++ + const data = new Uint8Array(total) + let written = 0 + for (const part of parts) { + data.set(part, written) + written += part.length + } + const indices = yield* lzw(data, minCode, fw * fh) + const rows = frameFlags & 0x40 ? interlaceOrder(fh) : undefined + for (let y = 0; y < fh; y++) { + const dy = top + (rows ? (rows[y] as number) : y) + if (dy >= height) continue + for (let x = 0; x < fw; x++) { + const dx = left + x + if (dx >= width) continue + const index = indices[y * fw + x] as number + if (index === transparent) continue + const d = (dy * width + dx) * 4 + out[d] = table?.[index * 3] ?? 0 + out[d + 1] = table?.[index * 3 + 1] ?? 0 + out[d + 2] = table?.[index * 3 + 2] ?? 0 + out[d + 3] = 255 + } + } + decoded = true + yield + } + if (!decoded) throw new Error("GIF has no image") + return { width, height, data: out, frames } +} + +/** The four passes of an interlaced GIF, as the order its rows arrive in. */ +function interlaceOrder(h: number): number[] { + const rows: number[] = [] + for (const [start, step] of [ + [0, 8], + [4, 8], + [2, 4], + [1, 2], + ] as const) + for (let y = start; y < h; y += step) rows.push(y) + return rows +} + +/** GIF's variable-width LZW to colour indices, yielding every slice of output. */ +function* lzw(data: Uint8Array, minCode: number, count: number): Steps { + const out = new Uint8Array(count) + const clear = 1 << minCode + const end = clear + 1 + const prefix = new Int16Array(4096) + const suffix = new Uint8Array(4096) + const first = new Uint8Array(4096) + const stack = new Uint8Array(4097) + let size = minCode + 1 + let mask = (1 << size) - 1 + let next = end + 1 + let old = -1 + let bits = 0 + let acc = 0 + let pos = 0 + let w = 0 + let work = 0 + for (let i = 0; i < clear; i++) { + prefix[i] = -1 + suffix[i] = i + first[i] = i + } + while (w < count) { + while (bits < size) { + if (pos >= data.length) return out + acc |= (data[pos++] as number) << bits + bits += 8 + } + const code = acc & mask + acc >>>= size + bits -= size + if (code === clear) { + size = minCode + 1 + mask = (1 << size) - 1 + next = end + 1 + old = -1 + continue + } + if (code === end) break + if (old === -1) { + out[w++] = suffix[code] as number + old = code + continue + } + let c = code + let sp = 0 + if (code >= next) { + stack[sp++] = first[old] as number + c = old + } + while (c >= clear) { + stack[sp++] = suffix[c] as number + c = prefix[c] as number + } + stack[sp++] = suffix[c] as number + if (next < 4096) { + prefix[next] = old + suffix[next] = first[c] as number + first[next] = first[old] as number + next++ + if ((next & mask) === 0 && next < 4096) { + size++ + mask = (1 << size) - 1 + } + } + const before = w + while (sp > 0 && w < count) out[w++] = stack[--sp] as number + old = code + work += w - before + if (work >= SLICE) { + work = 0 + yield + } + } + return out +} + +/** Decodes now, on this thread. For tests and the preview CLI. */ +export const decodeGif = (file: Uint8Array): Pixels => finish(gifSteps(file)) + +/** Decodes a slice at a time, giving the event loop back between them. For the pane. */ +export const decodeGifSoon = (file: Uint8Array, pause?: () => Promise): Promise => + finishSoon(gifSteps(file), pause) diff --git a/packages/review/src/core/image/looks.ts b/packages/review/src/core/image/looks.ts new file mode 100644 index 00000000..6df37bc9 --- /dev/null +++ b/packages/review/src/core/image/looks.ts @@ -0,0 +1,215 @@ +/** + * Looking at changed images: decode, compare, keep a small copy — off the draw path, and once. + * + * The pane shows a binary's metadata the moment git is read; this is what fills in the rest when it + * is ready — the pixel diff and the two thumbs the preview is drawn from. Decoding a screenshot costs + * 50–100 ms, so it never happens inside a paint: it runs here, in slices (`steps.ts`), one file at a + * time, and asks for a paint when each file is done. + * + * Cached by the bytes' hash, not by path: reloading the review (`g`, a source switch) re-reads the + * bytes — 8 ms through git — and finds every picture it has already decoded. + */ + +import type { FileChange } from "../model/review.ts" +import { decodeGifSoon } from "./gif.ts" +import { comparePixels, type PixelDiff, shrink, type Thumb } from "./pixels.ts" +import { decodePngSoon, type Pixels } from "./png.ts" +import { decodable, type ImageInfo, sniff } from "./sniff.ts" +import { finishSoon, turn } from "./steps.ts" + +/** + * Past this many pixels a side is described and not decoded: 4096×4096, 64 MB of RGBA. A 5K + * screenshot (5120×2880) fits; a print-resolution scan does not, and `o` is the answer for it. + */ +export const MAX_PIXELS = 4096 * 4096 + +/** What a pane can show of an image change beyond its metadata. */ +export interface ImageLook { + /** Still working: the card says so where the preview will appear. */ + pending?: boolean + before?: Thumb + after?: Thumb + /** Only for two decodable images of the same size: anything else would be a percentage that lies. */ + diff?: PixelDiff + /** Frames in an animation, when there is more than one. */ + frames?: { before?: number; after?: number } + /** Why there is no picture where there could have been one. */ + problem?: string + /** Changes whenever the look does, so a cached row knows it is stale. */ + stamp: number +} + +export type Side = "before" | "after" + +/** Reads one side's bytes in full, or undefined when there is nothing to read (or too much). */ +export type ReadSide = (file: FileChange, side: Side) => Promise + +/** Whether a file has anything worth decoding: an image this bay can decode on at least one side. */ +export const worthLooking = (file: FileChange): boolean => + file.binary !== undefined && (decodable(file.binary.before?.image) || decodable(file.binary.after?.image)) + +const tooBig = (info: ImageInfo | undefined): boolean => + info !== undefined && info.width * info.height > MAX_PIXELS + +/** Said where the picture would be; the card's `[o]` right under it is the way to see it anyway. */ +export const tooLargeText = (width: number, height: number): string => + `Too large to preview here — ${Math.round((width * height) / 1e6)} megapixels, past ${Math.round(MAX_PIXELS / 1e6)}` + +interface Decoded { + thumb: Thumb + frames?: number +} + +/** Small copies by content hash, so a reload never decodes twice. Thumbs are ≤ 300 KB each. */ +const thumbs = new Map() +const diffs = new Map() +const REMEMBERED = 48 + +const remember = (map: Map, key: string, value: T) => { + if (map.size >= REMEMBERED) { + const oldest = map.keys().next().value + if (oldest !== undefined) map.delete(oldest) + } + map.set(key, value) +} + +const hashOf = (bytes: Uint8Array): string => `${Bun.hash(bytes).toString(36)}:${bytes.length}` + +const decode = (info: ImageInfo, bytes: Uint8Array, pause: () => Promise): Promise => + info.format === "png" ? decodePngSoon(bytes, pause) : decodeGifSoon(bytes, pause) + +const message = (error: unknown) => (error instanceof Error ? error.message : String(error)) + +let stamps = 0 + +/** + * Everything worth showing about one image change: thumbs for whichever sides decode, and the pixel + * diff when both do and their dimensions match. + */ +export async function analyse( + file: FileChange, + read: ReadSide, + pause: () => Promise = turn, +): Promise { + const binary = file.binary + const look: ImageLook = { stamp: ++stamps } + if (!binary) return look + const sides: Side[] = ["before", "after"] + const problems: string[] = [] + const named = (side: Side) => (side === "before" ? "old" : "new") + + /** Both sides' bytes first: reading is cheap, and the hashes decide what needs decoding at all. */ + const bytes: Partial> = {} + for (const side of sides) { + const info = binary[side]?.image + if (!info || !decodable(info)) continue + if (tooBig(info)) { + problems.push(tooLargeText(info.width, info.height)) + continue + } + const data = await read(file, side) + /** Sniffed again: the working copy may have moved on since git was read. */ + const now = data ? sniff(data) : undefined + if (!data || !now || now.format !== info.format || tooBig(now)) { + problems.push(`the ${named(side)} side could not be read`) + continue + } + bytes[side] = { data, info: now, hash: hashOf(data) } + } + + const before = bytes.before + const after = bytes.after + const pair = + before && after && before.info.width === after.info.width && before.info.height === after.info.height + ? `${before.hash}>${after.hash}` + : undefined + /** The full pixels are only kept when a diff needs them and none is cached. */ + const comparing = pair !== undefined && !diffs.has(pair) + const full: Partial> = {} + + for (const side of sides) { + const got = bytes[side] + if (!got) continue + let known = thumbs.get(got.hash) + if (!known || comparing) { + try { + const pixels = await decode(got.info, got.data, pause) + if (comparing) full[side] = pixels + known ??= { + thumb: await finishSoon(shrink(pixels), pause), + ...(pixels.frames && pixels.frames > 1 ? { frames: pixels.frames } : {}), + } + remember(thumbs, got.hash, known) + } catch (error) { + problems.push(`could not decode the ${named(side)} side: ${message(error)}`) + continue + } + } + look[side] = known.thumb + if (known.frames) look.frames = { ...look.frames, [side]: known.frames } + } + + if (pair) { + const cached = diffs.get(pair) + if (cached) look.diff = cached + else if (full.before && full.after && look.after) { + const diff = await finishSoon( + comparePixels(full.before, full.after, look.after.width, look.after.height), + pause, + ) + remember(diffs, pair, diff) + look.diff = diff + } + } + if (problems[0]) look.problem = problems[0] + return look +} + +export interface Looks { + /** What is known about each image change, by path. A new map whenever anything in it changes. */ + current: () => ReadonlyMap + /** Starts looking at these files' images, one at a time; a later call supersedes an earlier one. */ + sync: (files: readonly FileChange[]) => void +} + +/** + * The pane's view of the images under review. + * + * `changed` asks for a paint. A file being worked on keeps what it showed before, marked pending, + * so a reload does not blank every preview on screen and draw them back one by one. + */ +export function createLooks(read: ReadSide, changed: () => void, pause: () => Promise = turn): Looks { + let looks: ReadonlyMap = new Map() + let generation = 0 + + const put = (path: string, look: ImageLook | undefined) => { + const next = new Map(looks) + if (look) next.set(path, look) + else next.delete(path) + looks = next + } + + return { + current: () => looks, + sync(files) { + const mine = ++generation + const wanted = files.filter(worthLooking) + /** Files no longer in the review are forgotten; the rest show "decoding…" until they are done. */ + const kept = new Map() + for (const file of wanted) + kept.set(file.path, { ...(looks.get(file.path) ?? {}), pending: true, stamp: ++stamps }) + looks = kept + if (wanted.length > 0) changed() + void (async () => { + for (const file of wanted) { + const look = await analyse(file, read, pause).catch( + (error): ImageLook => ({ problem: message(error), stamp: ++stamps }), + ) + if (mine !== generation) return + put(file.path, look) + changed() + } + })() + }, + } +} diff --git a/packages/review/src/core/image/pixels.ts b/packages/review/src/core/image/pixels.ts new file mode 100644 index 00000000..531b0715 --- /dev/null +++ b/packages/review/src/core/image/pixels.ts @@ -0,0 +1,265 @@ +/** + * Working with decoded pixels: comparing two images, shrinking one, and turning it into cells. + * + * Kept apart from the decoders because none of it cares where the pixels came from, and apart from + * the view because the expensive parts — every pixel of a 2880×1800 screenshot — must run in slices + * off the draw path, while the cheap part (a 320-pixel copy into fifty cells) runs inside a paint. + */ + +import type { Pixels } from "./png.ts" +import { SLICE, type Steps } from "./steps.ts" + +/** + * A small copy of an image, kept instead of the image. + * + * Mid-resolution on purpose: big enough that a wider pane or a width cycle (`w`) re-derives sharper + * cells from it in well under a millisecond, small enough (≤ 300 KB) that keeping one per side of + * every image in a review costs nothing. Full-size pixels are dropped as soon as this exists. + */ +export interface Thumb { + width: number + height: number + /** 8-bit RGBA. Alpha is kept: what it is composited over is the pane's colour, decided at paint. */ + data: Uint8Array +} + +/** The largest a thumb gets, either way. */ +export const THUMB_WIDTH = 320 +export const THUMB_HEIGHT = 240 + +/** A grid over the image marking which parts changed, at the thumb's resolution. */ +export interface ChangeMask { + width: number + height: number + /** One byte per cell, non-zero where any pixel inside it changed. */ + data: Uint8Array +} + +export interface PixelDiff { + /** Pixels that differ by more than the tolerance in any channel. */ + changed: number + total: number + /** The rectangle holding every changed pixel, in the image's own pixels. */ + box?: { x: number; y: number; width: number; height: number } + mask?: ChangeMask +} + +/** Dimensions that fit inside `maxWidth × maxHeight` with the aspect kept, never larger than the source. */ +export function fitWithin(width: number, height: number, maxWidth: number, maxHeight: number) { + const scale = Math.min(1, maxWidth / width, maxHeight / height) + return { + width: Math.max(1, Math.round(width * scale)), + height: Math.max(1, Math.round(height * scale)), + } +} + +/** + * Box-average downscale, every source pixel read once, colour weighted by alpha so a transparent + * pixel's (meaningless) colour does not bleed into its neighbours. + */ +export function* shrink(source: Pixels, maxWidth = THUMB_WIDTH, maxHeight = THUMB_HEIGHT): Steps { + const { width: w, height: h } = fitWithin(source.width, source.height, maxWidth, maxHeight) + const out = new Uint8Array(w * h * 4) + const r = new Float64Array(w) + const g = new Float64Array(w) + const b = new Float64Array(w) + const a = new Float64Array(w) + const n = new Float64Array(w) + const column = new Uint32Array(source.width) + for (let x = 0; x < source.width; x++) column[x] = Math.min(w - 1, Math.floor((x * w) / source.width)) + const d = source.data + let work = 0 + for (let ty = 0; ty < h; ty++) { + r.fill(0) + g.fill(0) + b.fill(0) + a.fill(0) + n.fill(0) + const y0 = Math.floor((ty * source.height) / h) + const y1 = Math.max(y0 + 1, Math.floor(((ty + 1) * source.height) / h)) + for (let y = y0; y < y1; y++) { + for (let x = 0, i = y * source.width * 4; x < source.width; x++, i += 4) { + const tx = column[x] as number + const alpha = d[i + 3] as number + r[tx] = (r[tx] as number) + (d[i] as number) * alpha + g[tx] = (g[tx] as number) + (d[i + 1] as number) * alpha + b[tx] = (b[tx] as number) + (d[i + 2] as number) * alpha + a[tx] = (a[tx] as number) + alpha + n[tx] = (n[tx] as number) + 1 + } + } + for (let tx = 0; tx < w; tx++) { + const weight = a[tx] as number + const o = (ty * w + tx) * 4 + if (weight > 0) { + out[o] = Math.round((r[tx] as number) / weight) + out[o + 1] = Math.round((g[tx] as number) / weight) + out[o + 2] = Math.round((b[tx] as number) / weight) + } + out[o + 3] = Math.round(weight / ((n[tx] as number) || 1)) + } + work += (y1 - y0) * source.width + if (work >= SLICE / 2) { + work = 0 + yield + } + } + return { width: w, height: h, data: out } +} + +/** + * Two same-size images compared pixel by pixel, at full resolution. + * + * Words first: identical pixels — nearly all of a re-taken screenshot — cost one 32-bit compare, and + * only a mismatch is checked per channel against `tolerance`, which absorbs an encoder's rounding. + * 6 ms for 2880×1800 in the spike. The mask is built on the way, at `maskWidth × maskHeight`, so the + * preview can light the cells that changed without a second pass over the pixels. + */ +export function* comparePixels( + before: Pixels, + after: Pixels, + maskWidth: number, + maskHeight: number, + tolerance = 8, +): Steps { + if (before.width !== after.width || before.height !== after.height) + throw new Error("a pixel diff needs two images of one size") + const { width, height } = after + const da = before.data + const db = after.data + const wa = new Uint32Array(da.buffer, da.byteOffset, da.length >> 2) + const wb = new Uint32Array(db.buffer, db.byteOffset, db.length >> 2) + const mask = new Uint8Array(maskWidth * maskHeight) + const mx = new Uint32Array(width) + for (let x = 0; x < width; x++) mx[x] = Math.min(maskWidth - 1, Math.floor((x * maskWidth) / width)) + let changed = 0 + let x0 = width + let y0 = height + let x1 = -1 + let y1 = -1 + let work = 0 + for (let y = 0, p = 0; y < height; y++) { + const row = Math.min(maskHeight - 1, Math.floor((y * maskHeight) / height)) * maskWidth + for (let x = 0; x < width; x++, p++) { + if (wa[p] === wb[p]) continue + const i = p * 4 + if ( + Math.abs((da[i] as number) - (db[i] as number)) > tolerance || + Math.abs((da[i + 1] as number) - (db[i + 1] as number)) > tolerance || + Math.abs((da[i + 2] as number) - (db[i + 2] as number)) > tolerance || + Math.abs((da[i + 3] as number) - (db[i + 3] as number)) > tolerance + ) { + changed++ + mask[row + (mx[x] as number)] = 1 + if (x < x0) x0 = x + if (x > x1) x1 = x + if (y < y0) y0 = y + if (y > y1) y1 = y + } + } + work += width + if (work >= SLICE * 2) { + work = 0 + yield + } + } + return { + changed, + total: width * height, + ...(changed > 0 + ? { + box: { x: x0, y: y0, width: x1 - x0 + 1, height: y1 - y0 + 1 }, + mask: { width: maskWidth, height: maskHeight, data: mask }, + } + : {}), + } +} + +/** + * A picture in half-block cells: `â–€` with the top pixel as ink and the bottom as background, so one + * cell is two square-ish pixels. Colours are packed `0xRRGGBB`. + */ +export interface Cells { + cols: number + rows: number + top: Uint32Array + bottom: Uint32Array +} + +/** The grid of pixels (one column × half a row each) a picture takes inside `cols × rows` cells. */ +export function cellGrid(width: number, height: number, cols: number, rows: number) { + const fit = fitWithin(width, height, cols, rows * 2) + /** A cell holds two pixels, so the height rounds to whole cells — never less than one. */ + return { width: fit.width, height: Math.max(2, fit.height + (fit.height % 2)) } +} + +/** `colour` pulled `amount` of the way toward `toward`. */ +export const mix = (colour: number, toward: number, amount: number): number => { + const channel = (shift: number) => + Math.round(((colour >> shift) & 255) * (1 - amount) + ((toward >> shift) & 255) * amount) + return (channel(16) << 16) | (channel(8) << 8) | channel(0) +} + +/** How far an unchanged part of a picture is faded, so the change is what the eye lands on. */ +export const FADE = 0.55 + +/** + * A thumb as cells inside `cols × rows`, composited over `canvas` (the pane's own colour). + * + * With a mask, every pixel whose part of the image did not change is faded toward the canvas: the + * picture stays recognisable, and what changed is the only thing at full strength. + */ +export function toCells(thumb: Thumb, cols: number, rows: number, canvas: number, mask?: ChangeMask): Cells { + const grid = cellGrid(thumb.width, thumb.height, cols, rows) + const w = grid.width + const h = grid.height + const px = new Uint32Array(w * h) + const d = thumb.data + const cr = (canvas >> 16) & 255 + const cg = (canvas >> 8) & 255 + const cb = canvas & 255 + for (let ty = 0; ty < h; ty++) { + const y0 = Math.floor((ty * thumb.height) / h) + const y1 = Math.max(y0 + 1, Math.floor(((ty + 1) * thumb.height) / h)) + for (let tx = 0; tx < w; tx++) { + const x0 = Math.floor((tx * thumb.width) / w) + const x1 = Math.max(x0 + 1, Math.floor(((tx + 1) * thumb.width) / w)) + let r = 0 + let g = 0 + let b = 0 + let n = 0 + for (let y = y0; y < y1; y++) + for (let x = x0, i = (y * thumb.width + x0) * 4; x < x1; x++, i += 4) { + const alpha = (d[i + 3] as number) / 255 + r += (d[i] as number) * alpha + cr * (1 - alpha) + g += (d[i + 1] as number) * alpha + cg * (1 - alpha) + b += (d[i + 2] as number) * alpha + cb * (1 - alpha) + n++ + } + let colour = (Math.round(r / n) << 16) | (Math.round(g / n) << 8) | Math.round(b / n) + if (mask && !maskHit(mask, tx / w, ty / h, (tx + 1) / w, (ty + 1) / h)) + colour = mix(colour, canvas, FADE) + px[ty * w + tx] = colour + } + } + const cellRows = h / 2 + const top = new Uint32Array(w * cellRows) + const bottom = new Uint32Array(w * cellRows) + for (let row = 0; row < cellRows; row++) + for (let x = 0; x < w; x++) { + top[row * w + x] = px[row * 2 * w + x] as number + bottom[row * w + x] = px[(row * 2 + 1) * w + x] as number + } + return { cols: w, rows: cellRows, top, bottom } +} + +/** Whether any marked cell of the mask falls inside this fraction of the image. */ +function maskHit(mask: ChangeMask, fx0: number, fy0: number, fx1: number, fy1: number): boolean { + const x0 = Math.floor(fx0 * mask.width) + const y0 = Math.floor(fy0 * mask.height) + const x1 = Math.max(x0 + 1, Math.ceil(fx1 * mask.width)) + const y1 = Math.max(y0 + 1, Math.ceil(fy1 * mask.height)) + for (let y = y0; y < y1 && y < mask.height; y++) + for (let x = x0; x < x1 && x < mask.width; x++) if (mask.data[y * mask.width + x]) return true + return false +} diff --git a/packages/review/src/core/image/png.ts b/packages/review/src/core/image/png.ts new file mode 100644 index 00000000..4fa142c4 --- /dev/null +++ b/packages/review/src/core/image/png.ts @@ -0,0 +1,282 @@ +/** + * PNG to RGBA, in TypeScript and `node:zlib`. + * + * Chunks, inflate, unfilter (None/Sub/Up/Average/Paeth), expand to 8-bit RGBA. Every colour type + * (grey, RGB, palette, grey+alpha, RGBA), every bit depth (1/2/4/8/16), palette transparency and the + * grey/RGB colour key, and Adam7 interlacing. Gamma and ICC are ignored: this is for a pixel diff and a + * preview a few dozen cells wide, not for proofing colour. + * + * Byte-exact against PIL on every fixture in `test/image/` (the spike's twelve, up to 20.7 MB of RGBA). + */ + +import { inflate, inflateSync } from "node:zlib" +import { finish, finishSoon, SLICE, type Steps } from "./steps.ts" + +export interface Pixels { + width: number + height: number + /** 8-bit RGBA, row-major, `width * height * 4` bytes. */ + data: Uint8Array + /** For an animation: how many frames it has. Only the first is decoded. */ + frames?: number +} + +interface PngParts { + width: number + height: number + depth: number + colour: number + interlace: number + palette?: Uint8Array + trns?: Uint8Array + /** Every IDAT's payload, joined: one zlib stream. */ + idat: Uint8Array +} + +/** Samples per pixel, by colour type. */ +const CHANNELS: Record = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 } +/** Bit depths the specification allows for each colour type. */ +const DEPTHS: Record = { + 0: [1, 2, 4, 8, 16], + 2: [8, 16], + 3: [1, 2, 4, 8], + 4: [8, 16], + 6: [8, 16], +} + +const u32 = (b: Uint8Array, o: number) => + (b[o] as number) * 0x1000000 + + ((b[o + 1] as number) << 16) + + ((b[o + 2] as number) << 8) + + (b[o + 3] as number) + +function partsOf(file: Uint8Array): PngParts { + let o = 8 + let header: Omit | undefined + let palette: Uint8Array | undefined + let trns: Uint8Array | undefined + const idat: Uint8Array[] = [] + while (o + 8 <= file.length) { + const length = u32(file, o) + const type = String.fromCharCode(...file.subarray(o + 4, o + 8)) + const body = file.subarray(o + 8, o + 8 + length) + if (type === "IHDR") + header = { + width: u32(body, 0), + height: u32(body, 4), + depth: body[8] ?? 0, + colour: body[9] ?? 0, + interlace: body[12] ?? 0, + } + else if (type === "PLTE") palette = body + else if (type === "tRNS") trns = body + else if (type === "IDAT") idat.push(body) + else if (type === "IEND") break + o += 12 + length + } + if (!header || header.width === 0 || header.height === 0) throw new Error("not a PNG") + if (!DEPTHS[header.colour]?.includes(header.depth)) + throw new Error(`PNG colour type ${header.colour} at ${header.depth}-bit is not valid`) + if (header.colour === 3 && !palette) throw new Error("PNG palette missing") + if (idat.length === 0) throw new Error("PNG has no image data") + return { ...header, idat: idat.length === 1 ? (idat[0] as Uint8Array) : Buffer.concat(idat), palette, trns } +} + +/** + * The inflated scanlines to RGBA, a slice at a time. + * + * Inflate is not in here: done asynchronously it already runs off the thread (the spike's longest + * stall inflating a 13.5 MB file was 1.2 ms), and that leaves unfiltering and expanding — the part + * that is JavaScript, and the part that has to yield. + */ +function* unfilterPng(parts: PngParts, raw: Uint8Array): Steps { + const { width, height, depth, colour, palette, trns } = parts + const channels = CHANNELS[colour] as number + /** Bytes per whole pixel, for the filters' "the pixel to the left". At least one, below 8-bit. */ + const bpp = Math.max(1, (channels * depth) >> 3) + const out = new Uint8Array(width * height * 4) + + /** Grey and RGB images name one colour as transparent, at the image's own depth. */ + const key = + trns && colour === 0 && trns.length >= 2 + ? [((trns[0] as number) << 8) | (trns[1] as number)] + : trns && colour === 2 && trns.length >= 6 + ? [ + ((trns[0] as number) << 8) | (trns[1] as number), + ((trns[2] as number) << 8) | (trns[3] as number), + ((trns[4] as number) << 8) | (trns[5] as number), + ] + : undefined + const max = (1 << depth) - 1 + const scale = (v: number) => (depth === 16 ? v >> 8 : depth === 8 ? v : Math.round((v * 255) / max)) + + let read = 0 + let work = 0 + + /** One row's samples into RGBA at its place in the image — every `dx` pixels from `x0` on row `y`. */ + const expand = (line: Uint8Array, pw: number, y: number, x0: number, dx: number) => { + let dst = (y * width + x0) * 4 + const step = dx * 4 + /** The common cases, one branch per row rather than per pixel. */ + if (depth === 8 && colour === 6) { + if (dx === 1) out.set(line.subarray(0, pw * 4), dst) + else + for (let i = 0, s = 0; i < pw; i++, s += 4, dst += step) { + out[dst] = line[s] as number + out[dst + 1] = line[s + 1] as number + out[dst + 2] = line[s + 2] as number + out[dst + 3] = line[s + 3] as number + } + return + } + if (depth === 8 && colour === 2) { + for (let i = 0, s = 0; i < pw; i++, s += 3, dst += step) { + const r = line[s] as number + const g = line[s + 1] as number + const b = line[s + 2] as number + out[dst] = r + out[dst + 1] = g + out[dst + 2] = b + out[dst + 3] = key && r === key[0] && g === key[1] && b === key[2] ? 0 : 255 + } + return + } + const sample = (index: number): number => { + if (depth === 8) return line[index] as number + if (depth === 16) return ((line[index * 2] as number) << 8) | (line[index * 2 + 1] as number) + const bit = index * depth + return ((line[bit >> 3] as number) >> (8 - depth - (bit & 7))) & max + } + for (let i = 0; i < pw; i++, dst += step) { + let r: number + let g: number + let b: number + let a = 255 + switch (colour) { + case 0: { + const v = sample(i) + r = g = b = scale(v) + if (key && v === key[0]) a = 0 + break + } + case 2: { + const vr = sample(i * 3) + const vg = sample(i * 3 + 1) + const vb = sample(i * 3 + 2) + r = scale(vr) + g = scale(vg) + b = scale(vb) + if (key && vr === key[0] && vg === key[1] && vb === key[2]) a = 0 + break + } + case 3: { + const p = sample(i) + r = palette?.[p * 3] ?? 0 + g = palette?.[p * 3 + 1] ?? 0 + b = palette?.[p * 3 + 2] ?? 0 + a = trns && p < trns.length ? (trns[p] as number) : 255 + break + } + case 4: + r = g = b = scale(sample(i * 2)) + a = scale(sample(i * 2 + 1)) + break + default: + r = scale(sample(i * 4)) + g = scale(sample(i * 4 + 1)) + b = scale(sample(i * 4 + 2)) + a = scale(sample(i * 4 + 3)) + } + out[dst] = r + out[dst + 1] = g + out[dst + 2] = b + out[dst + 3] = a + } + } + + /** One pass of the image — all of it, or one of Adam7's seven — with a yield every slice of pixels. */ + function* pass(x0: number, y0: number, dx: number, dy: number): Steps { + const pw = Math.ceil((width - x0) / dx) + const ph = Math.ceil((height - y0) / dy) + if (pw <= 0 || ph <= 0) return + const stride = Math.ceil((pw * channels * depth) / 8) + let previous = new Uint8Array(stride) + let current = new Uint8Array(stride) + for (let row = 0; row < ph; row++) { + if (read + 1 + stride > raw.length) throw new Error("PNG image data is cut short") + const filter = raw[read++] as number + current.set(raw.subarray(read, read + stride)) + read += stride + unfilter(filter, current, previous, bpp) + expand(current, pw, y0 + row * dy, x0, dx) + const swap = previous + previous = current + current = swap + work += pw + if (work >= SLICE) { + work = 0 + yield + } + } + } + + if (parts.interlace) { + yield* pass(0, 0, 8, 8) + yield* pass(4, 0, 8, 8) + yield* pass(0, 4, 4, 8) + yield* pass(2, 0, 4, 4) + yield* pass(0, 2, 2, 4) + yield* pass(1, 0, 2, 2) + yield* pass(0, 1, 1, 2) + } else yield* pass(0, 0, 1, 1) + return { width, height, data: out } +} + +function unfilter(filter: number, cur: Uint8Array, prev: Uint8Array, bpp: number) { + const n = cur.length + switch (filter) { + case 0: + return + case 1: + for (let i = bpp; i < n; i++) cur[i] = ((cur[i] as number) + (cur[i - bpp] as number)) & 255 + return + case 2: + for (let i = 0; i < n; i++) cur[i] = ((cur[i] as number) + (prev[i] as number)) & 255 + return + case 3: + for (let i = 0; i < bpp; i++) cur[i] = ((cur[i] as number) + ((prev[i] as number) >> 1)) & 255 + for (let i = bpp; i < n; i++) + cur[i] = ((cur[i] as number) + (((cur[i - bpp] as number) + (prev[i] as number)) >> 1)) & 255 + return + case 4: + for (let i = 0; i < bpp; i++) cur[i] = ((cur[i] as number) + (prev[i] as number)) & 255 + for (let i = bpp; i < n; i++) { + const a = cur[i - bpp] as number + const b = prev[i] as number + const c = prev[i - bpp] as number + const p = a + b - c + const pa = Math.abs(p - a) + const pb = Math.abs(p - b) + const pc = Math.abs(p - c) + cur[i] = ((cur[i] as number) + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c)) & 255 + } + return + default: + throw new Error(`PNG row filter ${filter} does not exist`) + } +} + +/** Decodes now, on this thread. For tests and the preview CLI. */ +export function decodePng(file: Uint8Array): Pixels { + const parts = partsOf(file) + return finish(unfilterPng(parts, inflateSync(parts.idat))) +} + +const inflateOffThread = (data: Uint8Array): Promise => + new Promise((resolve, reject) => inflate(data, (error, out) => (error ? reject(error) : resolve(out)))) + +/** Decodes without holding the thread: inflate in zlib's pool, the rest in slices. For the pane. */ +export async function decodePngSoon(file: Uint8Array, pause?: () => Promise): Promise { + const parts = partsOf(file) + return finishSoon(unfilterPng(parts, await inflateOffThread(parts.idat)), pause) +} diff --git a/packages/review/src/core/image/samples.ts b/packages/review/src/core/image/samples.ts new file mode 100644 index 00000000..ec32439f --- /dev/null +++ b/packages/review/src/core/image/samples.ts @@ -0,0 +1,64 @@ +/** + * Pictures drawn in code, for the preview CLI and the tests: a small "screenshot" of a dark UI, the + * same with one panel changed, and the same scaled down. Real pixels through the real pipeline — the + * shrink, the pixel diff, the cells — with no image file shipped. + */ + +import type { ImageLook } from "./looks.ts" +import { comparePixels, shrink } from "./pixels.ts" +import type { Pixels } from "./png.ts" +import { finish } from "./steps.ts" + +type Paint = (x: number, y: number) => [number, number, number, number] + +const picture = (width: number, height: number, paint: Paint): Pixels => { + const data = new Uint8Array(width * height * 4) + for (let y = 0; y < height; y++) + for (let x = 0; x < width; x++) data.set(paint(x / width, y / height), (y * width + x) * 4) + return { width, height, data } +} + +const inside = (u: number, v: number, x0: number, y0: number, x1: number, y1: number) => + u >= x0 && u < x1 && v >= y0 && v < y1 + +/** A sidebar, a header, three cards and some "text" lines — the shape of the screenshots reviews get. */ +const scene = + (changed: boolean): Paint => + (u, v) => { + if (inside(u, v, 0, 0, 1, 0.08)) return [49, 50, 68, 255] + if (inside(u, v, 0, 0.08, 0.2, 1)) + return inside(u, v, 0.02, 0.14, 0.18, 0.2) ? [137, 180, 250, 255] : [24, 24, 37, 255] + if (inside(u, v, 0.24, 0.14, 0.48, 0.5)) return [166, 227, 161, 255] + if (inside(u, v, 0.52, 0.14, 0.96, 0.5)) + return changed && inside(u, v, 0.7, 0.2, 0.92, 0.42) ? [243, 139, 168, 255] : [250, 179, 135, 255] + if (inside(u, v, 0.24, 0.56, 0.96, 0.94)) { + const row = Math.floor(v * 40) + return row % 3 === 0 && u < 0.3 + ((row * 37) % 60) / 100 ? [205, 214, 244, 255] : [30, 30, 46, 255] + } + return [30, 30, 46, 255] + } + +/** A logo with a transparent background, for an added file. */ +const logo: Paint = (u, v) => { + const d = (u - 0.5) ** 2 + (v - 0.5) ** 2 + if (d < 0.16 && d > 0.09) return [203, 166, 247, 255] + if (d < 0.05) return [148, 226, 213, 255] + return [0, 0, 0, 0] +} + +export const SAMPLE = { + before: picture(288, 180, scene(false)), + after: picture(288, 180, scene(true)), + resized: picture(144, 90, scene(false)), + logo: picture(96, 96, logo), +} + +/** A look as the pane would have it once the pictures are decoded. */ +export function sampleLook(before: Pixels | undefined, after: Pixels | undefined, stamp: number): ImageLook { + const look: ImageLook = { stamp } + if (before) look.before = finish(shrink(before)) + if (after) look.after = finish(shrink(after)) + if (before && after && look.after && before.width === after.width && before.height === after.height) + look.diff = finish(comparePixels(before, after, look.after.width, look.after.height)) + return look +} diff --git a/packages/review/src/core/image/sniff.ts b/packages/review/src/core/image/sniff.ts new file mode 100644 index 00000000..866fd49d --- /dev/null +++ b/packages/review/src/core/image/sniff.ts @@ -0,0 +1,154 @@ +/** + * What a file is, from its first bytes: binary or text, and — for an image — its format and size. + * + * No decoding. Every reader here looks at a few dozen bytes of header, so it costs nothing to run on + * every binary in a review (0.08 ms measured, docs/opencode/images.md), and it is what turns "a PNG + * shown as four hundred lines of U+FFFD" into one true sentence about it. + * + * Checked against PIL and `sips` on every fixture in `test/image/`: each PNG colour type and depth, + * interlaced, APNG, baseline and progressive JPEG, a JPEG whose size sits behind a 30 KB EXIF block, + * lossy, lossless and extended WebP, BMP. + */ + +export type ImageFormat = "png" | "jpeg" | "gif" | "webp" | "bmp" + +export interface ImageInfo { + format: ImageFormat + width: number + height: number + /** An APNG, an animated WebP. A GIF's frames are only known once it is decoded. */ + animated?: boolean +} + +/** How many bytes git looks at to decide a file is binary, and how many this bay does. */ +export const BINARY_PROBE = 8000 + +/** + * Git's own rule: a NUL in the first 8000 bytes means binary. + * + * The same rule as git so the two never disagree about a file — a file `git diff` calls binary and + * this bay drew as text (or the other way round) would be two answers to one question. + */ +export const looksBinary = (bytes: Uint8Array): boolean => bytes.subarray(0, BINARY_PROBE).indexOf(0) !== -1 + +/** Enough of a file to find a JPEG's size behind its EXIF and ICC blocks, which come first. */ +export const HEADER_BYTES = 64 * 1024 + +const at = (b: Uint8Array, o: number): number => b[o] ?? 0 +const u16be = (b: Uint8Array, o: number) => (at(b, o) << 8) | at(b, o + 1) +const u16le = (b: Uint8Array, o: number) => at(b, o) | (at(b, o + 1) << 8) +const u24le = (b: Uint8Array, o: number) => at(b, o) | (at(b, o + 1) << 8) | (at(b, o + 2) << 16) +const u32be = (b: Uint8Array, o: number) => + at(b, o) * 0x1000000 + (at(b, o + 1) << 16) + (at(b, o + 2) << 8) + at(b, o + 3) +const u32le = (b: Uint8Array, o: number) => + at(b, o) + (at(b, o + 1) << 8) + (at(b, o + 2) << 16) + at(b, o + 3) * 0x1000000 +const ascii = (b: Uint8Array, o: number, n: number) => String.fromCharCode(...b.subarray(o, o + n)) + +/** PNG: the signature, then IHDR — always the first chunk. An `acTL` before the image data is an APNG. */ +export function readPng(b: Uint8Array): ImageInfo | undefined { + if (b.length < 33 || at(b, 0) !== 0x89 || ascii(b, 1, 3) !== "PNG" || ascii(b, 12, 4) !== "IHDR") + return undefined + let animated = false + for (let o = 8; o + 8 <= b.length; ) { + const type = ascii(b, o + 4, 4) + if (type === "acTL") animated = true + if (type === "IDAT" || type === "IEND") break + o += 12 + u32be(b, o) + } + return { format: "png", width: u32be(b, 16), height: u32be(b, 20), ...(animated ? { animated } : {}) } +} + +/** GIF: the logical screen descriptor, right after the signature. */ +export function readGif(b: Uint8Array): ImageInfo | undefined { + if (b.length < 10 || ascii(b, 0, 4) !== "GIF8") return undefined + return { format: "gif", width: u16le(b, 6), height: u16le(b, 8) } +} + +/** + * WebP: three containers, three places for the size. + * + * `VP8 ` (lossy) keeps 14-bit fields after its start code; `VP8L` (lossless) packs both into one + * little-endian word; `VP8X` (extended — alpha, animation) stores each less one in 24 bits. + */ +export function readWebp(b: Uint8Array): ImageInfo | undefined { + if (b.length < 16 || ascii(b, 0, 4) !== "RIFF" || ascii(b, 8, 4) !== "WEBP") return undefined + const kind = ascii(b, 12, 4) + if (kind === "VP8 " && b.length >= 30) + return { format: "webp", width: u16le(b, 26) & 0x3fff, height: u16le(b, 28) & 0x3fff } + if (kind === "VP8L" && b.length >= 25) { + const bits = u32le(b, 21) + return { format: "webp", width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 } + } + if (kind === "VP8X" && b.length >= 30) { + const animated = (at(b, 20) & 0x02) !== 0 + return { + format: "webp", + width: u24le(b, 24) + 1, + height: u24le(b, 27) + 1, + ...(animated ? { animated } : {}), + } + } + return undefined +} + +/** + * JPEG: walk the markers to the first start-of-frame. + * + * Any SOF0–SOF15 carries the size, except C4 (Huffman tables), C8 (reserved) and CC (arithmetic + * conditioning), which share the range. EXIF and ICC segments come first and can be tens of KB, so + * the size may be far in — `HEADER_BYTES` covers what cameras and screenshot tools write. + */ +export function readJpeg(b: Uint8Array): ImageInfo | undefined { + if (b.length < 4 || at(b, 0) !== 0xff || at(b, 1) !== 0xd8) return undefined + let o = 2 + while (o + 9 < b.length) { + if (at(b, o) !== 0xff) return undefined + const marker = at(b, o + 1) + /** Fill bytes, and markers that stand alone with no length after them. */ + if (marker === 0xff) { + o++ + continue + } + if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { + o += 2 + continue + } + if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) + return { format: "jpeg", height: u16be(b, o + 5), width: u16be(b, o + 7) } + o += 2 + u16be(b, o + 2) + } + return undefined +} + +/** + * BMP: the DIB header after the 14-byte file header. + * + * The old OS/2 core header (12 bytes) has 16-bit sizes; every later one 32-bit, with a negative + * height for an image stored top-down. + */ +export function readBmp(b: Uint8Array): ImageInfo | undefined { + if (b.length < 26 || at(b, 0) !== 0x42 || at(b, 1) !== 0x4d) return undefined + const dib = u32le(b, 14) + if (dib === 12) return { format: "bmp", width: u16le(b, 18), height: u16le(b, 20) } + if (dib < 40) return undefined + return { format: "bmp", width: u32le(b, 18) | 0, height: Math.abs(u32le(b, 22) | 0) } +} + +/** The image a header describes, or undefined for anything this bay cannot name. */ +export function sniff(bytes: Uint8Array): ImageInfo | undefined { + const found = + readPng(bytes) ?? readGif(bytes) ?? readWebp(bytes) ?? readJpeg(bytes) ?? readBmp(bytes) ?? undefined + /** A header that claims no pixels is a damaged file, not an image to describe. */ + return found && found.width > 0 && found.height > 0 ? found : undefined +} + +/** The format as people write it: `PNG`, `APNG`, `JPEG`, `GIF`, `WebP`, `BMP`. */ +export function formatName(info: ImageInfo): string { + if (info.format === "png") return info.animated ? "APNG" : "PNG" + if (info.format === "webp") return "WebP" + return info.format.toUpperCase() +} + +/** Formats this bay can decode to pixels — for a pixel diff and a preview. JPEG and WebP are metadata only. */ +export const decodable = (info: ImageInfo | undefined): boolean => + info?.format === "png" || info?.format === "gif" diff --git a/packages/review/src/core/image/steps.ts b/packages/review/src/core/image/steps.ts new file mode 100644 index 00000000..a2472587 --- /dev/null +++ b/packages/review/src/core/image/steps.ts @@ -0,0 +1,33 @@ +/** + * Long work, cut into slices that give the event loop back between them. + * + * The TUI is one thread: a 2880×1800 PNG takes ~50 ms to unfilter in JavaScript, and inside OpenCode + * the pair took 114 ms — a 16 ms frame seven times over (docs/opencode/images.md). So the decoders are + * generators that `yield` every few milliseconds of work, and the caller decides what a yield means: + * nothing at all in a test or the preview CLI, a turn of the event loop in the pane. + */ + +export type Steps = Generator + +/** Pixels of work per slice: about 3–5 ms of unfiltering or LZW on a laptop. */ +export const SLICE = 256 * 1024 + +/** Runs every slice now. For tests, the CLI, and anything already off the interface thread. */ +export function finish(steps: Steps): T { + let next = steps.next() + while (!next.done) next = steps.next() + return next.value +} + +/** A macrotask, so a paint queued meanwhile gets its turn before the next slice. */ +export const turn = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)) + +/** Runs the slices with the event loop's turn between each. */ +export async function finishSoon(steps: Steps, pause: () => Promise = turn): Promise { + let next = steps.next() + while (!next.done) { + await pause() + next = steps.next() + } + return next.value +} diff --git a/packages/review/src/core/model/review.ts b/packages/review/src/core/model/review.ts index a30e7bad..d397e5ba 100644 --- a/packages/review/src/core/model/review.ts +++ b/packages/review/src/core/model/review.ts @@ -6,6 +6,7 @@ * own; it renders one of these and calls one of these functions. */ +import type { ImageInfo } from "../image/sniff.ts" import { type Anchor, type Author, @@ -37,6 +38,31 @@ export interface FileChange { change?: "added" | "deleted" | "renamed" /** Where a renamed file was. Its "before" is read from here, so the diff shows the edit and not a copy. */ from?: string + /** + * Set when either side is binary — git's rule, a NUL in the first 8000 bytes. `before` and `after` + * are then empty and the counts zero: a binary has no lines, and drawing its bytes as UTF-8 was a + * diff of four hundred lines of U+FFFD that said nothing true about the file. + */ + binary?: BinaryChange +} + +/** One side of a binary file: how big it is and, for an image, what its header says. */ +export interface BinarySide { + /** Bytes. */ + size: number + image?: ImageInfo +} + +export interface BinaryChange { + /** Absent for a file that was added. */ + before?: BinarySide + /** Absent for a file that was deleted. */ + after?: BinarySide + /** + * The revision the old side was read at — `HEAD` for uncommitted work, the fork point for a branch — + * so it can be read again as bytes: to open it in a viewer, or to decode it for a pixel diff. + */ + revision?: string } export interface ChangeSet { diff --git a/packages/review/src/core/palette.ts b/packages/review/src/core/palette.ts new file mode 100644 index 00000000..b6290068 --- /dev/null +++ b/packages/review/src/core/palette.ts @@ -0,0 +1,45 @@ +/** + * The host's command palette, and the key that opens it. + * + * The review's keys are a *global* layer — the only kind that fires (see `tui/panel/keys.ts`) — so + * while it is open it owns `j`, `k`, `enter`, `escape` and most of the alphabet. `ctrl+p` opened + * OpenCode's palette underneath the panel, and every letter typed into it moved the review instead: + * the palette looked dead. So the review watches for the palette's own key and steps aside. + */ + +/** A key as the keymap reports it — the fields a binding names. */ +export interface KeyLike { + name?: string + ctrl?: boolean + meta?: boolean + shift?: boolean +} + +/** + * The palette's bindings, as the person's config has them (`keybinds.command_list`), default `ctrl+p`. + * + * Leader chords are left out: the leader key alone arrives first and means something else, and the + * chord's second key is not one the review could tell apart from its own. + */ +export function paletteBindings(config: unknown): string[] { + const value = (config as { keybinds?: { command_list?: unknown } } | undefined)?.keybinds?.command_list + const raw = typeof value === "string" && value.trim() !== "" ? value : "ctrl+p" + if (raw.trim() === "none") return [] + return raw + .split(",") + .map((binding) => binding.trim().toLowerCase()) + .filter((binding) => binding !== "" && !binding.includes("")) +} + +/** Whether `key` is `binding` (`ctrl+p`, `ctrl+shift+k`, `f2`) — every modifier exactly. */ +export function matchesBinding(key: KeyLike, binding: string): boolean { + const parts = binding.toLowerCase().split("+") + const name = parts.pop() + const modifiers = new Set(parts) + return ( + key.name?.toLowerCase() === name && + Boolean(key.ctrl) === modifiers.has("ctrl") && + Boolean(key.meta) === (modifiers.has("meta") || modifiers.has("alt")) && + Boolean(key.shift) === modifiers.has("shift") + ) +} diff --git a/packages/review/src/core/view/chrome.ts b/packages/review/src/core/view/chrome.ts index 831dfab6..516f0b27 100644 --- a/packages/review/src/core/view/chrome.ts +++ b/packages/review/src/core/view/chrome.ts @@ -6,7 +6,7 @@ * trouble, the performance numbers, a selection — *replaces* what is there rather than adding to it. */ -import { closeHint, fitHints, GLYPH, HINT_GAP, type Hint } from "@opencode-cockpit/client/design" +import { closeHint, fitHints, GLYPH, HINT_GAP, type Hint, warnRows } from "@opencode-cockpit/client/design" import { type ChangeSet, progress, type Review } from "../model/review.ts" import type { Columns } from "./geometry.ts" import { clipRuns, type Fill, type Row, type Run, rowWidth } from "./rows.ts" @@ -70,8 +70,37 @@ export function headerRows(changes: ChangeSet, review: Review, width: number, la ] } +/** + * The settings notices as the one row under the header, in place of its rule — the header stays two + * rows, so nothing below it moves. The shared `!` row (`warnRows`) cut to one line; with several, + * how many comes first, so a narrow pane still says there is more than the one it shows. + */ +export function settingsRow(notices: readonly string[], width: number): Row | undefined { + const [first] = notices + if (first === undefined) return undefined + const text = + notices.length === 1 + ? first + : `settings: ${notices.length} to fix, run /cockpit-setup — ${first.replace(/^settings: /, "")}` + const [row = []] = warnRows(text, width, 1) + return { runs: row.map((run) => ({ text: run.text, ...(run.tone ? { tone: run.tone } : {}) })) } +} + +/** + * The way to every key the row had no room for. Ranked just under the way out, so a narrow row keeps + * it and gives up `[w] Width` first: one key that leads to all of them beats one more of them. + */ +const KEYS: Hint = { key: "?", label: "Keys", priority: 100 } + /** The keys, on screen, because a surface whose keys are undiscoverable has none. */ -export function footerRows(width: number, _columns: Columns, state: ViewState = {}, empty = false): Row[] { +export function footerRows( + width: number, + _columns: Columns, + state: ViewState = {}, + empty = false, + /** The file under the cursor is a binary: `o` opens it, and is worth a place in the row. */ + binary = false, +): Row[] { const inDiff = state.pane === "diff" const selecting = inDiff && state.anchor !== undefined const lines = @@ -146,12 +175,14 @@ export function footerRows(width: number, _columns: Columns, state: ViewState = return [rule, { runs: clipRuns(trouble, width, "none") }] } if (state.stats) return [rule, { runs: clipRuns([...state.stats], width, "none") }] + /** On the keys screen the only key worth a row is the way back to the review. */ + if (state.keys) return [rule, { runs: keyRow([], [closeHint("Hide Keys")], width) }] /** * Nothing to review: only the keys that act. Moving, noting and marking have nothing to land on, * and a row that offers them anyway teaches that its keys do not always mean anything. */ - if (empty) return [rule, { runs: keyRow([], [...sources, close], width) }] + if (empty) return [rule, { runs: keyRow([], [...sources, KEYS, close], width) }] /** A selection says how many lines it holds before the keys that act on them. */ const lead: Run[] = selecting ? [{ text: `${lines} line${lines === 1 ? "" : "s"}`, tone: "accent", bold: true }] @@ -161,7 +192,16 @@ export function footerRows(width: number, _columns: Columns, state: ViewState = { runs: keyRow( lead, - [...moving, ...extra, submit, ...sources, { key: "w", label: "Width" }, close], + [ + ...moving, + ...(binary ? [{ key: "o", label: "Open" }] : []), + ...extra, + submit, + ...sources, + { key: "w", label: "Width" }, + KEYS, + close, + ], width, ), }, diff --git a/packages/review/src/core/view/diff.ts b/packages/review/src/core/view/diff.ts index c8aeb29e..587959e9 100644 --- a/packages/review/src/core/view/diff.ts +++ b/packages/review/src/core/view/diff.ts @@ -12,6 +12,7 @@ import { type FileChange, type Review, threadAnchor, threadsFor, threadsOnLine } import { metrics } from "../perf.ts" import { cardRows } from "./card.ts" import { tallyOf } from "./counts.ts" +import { binaryRows, lookKey } from "./image.ts" import { cell, clipRuns, elidePath, type Fill, type Row, type Run, skipColumns, type Tone } from "./rows.ts" import type { ViewState } from "./state.ts" import { languageOf, type SyntaxState, tokenize } from "./syntax/index.ts" @@ -151,6 +152,8 @@ const signature = (file: FileChange, review: Review, state: ViewState, width: nu state.shift ?? 0, threads.some((thread) => thread.id === state.thread) ? state.thread : "", threads.map((thread) => `${thread.id}:${thread.status}:${thread.entries.length}`).join(","), + /** A binary's card changes when its pictures arrive, and with the pane's colour behind them. */ + file.binary ? `${lookKey(state.looks?.get(file.path))}:${state.canvas ?? ""}` : "", ].join("|") } @@ -245,6 +248,12 @@ function buildDiffRows( ) } + /** A binary has no lines: its card says what it is, how it changed, and shows it. */ + if (file.binary) { + rows.push(...binaryRows(file, state.looks?.get(file.path), width, state.canvas, paint)) + return rows + } + const language = languageOf(file.path) const body = codeWidth(width) diff --git a/packages/review/src/core/view/image.ts b/packages/review/src/core/view/image.ts new file mode 100644 index 00000000..851c819c --- /dev/null +++ b/packages/review/src/core/view/image.ts @@ -0,0 +1,158 @@ +/** + * A binary file's card: what it is, what changed, a look at it, and the key that opens it. + * + * PNG 2880×1800 · 807 KB → 789 KB + * 2.56% of pixels changed · 601×221 at 1900,300 + * [o] Open Both + * + * before after + * ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + * + * The picture is rows like every other row — runs of `â–€` with an exact ink and an exact background — + * so the pool's diffing, the grid test and the preview CLI all apply to it unchanged. It shows *where* + * and roughly *what colour*; a 2880-pixel screenshot at fifty columns shows no legible text, which is + * why `o` is on the card: the real viewer is the answer to "what". + */ + +import { hintRuns } from "@opencode-cockpit/client/design" +import { pixelText, sameDimensions, summaryText } from "../image/describe.ts" +import type { ImageLook } from "../image/looks.ts" +import { type Cells, cellGrid, type Thumb, toCells } from "../image/pixels.ts" +import { decodable, formatName } from "../image/sniff.ts" +import type { BinaryChange, FileChange } from "../model/review.ts" +import { cell, clipRuns, type Row, type Run } from "./rows.ts" + +/** Columns in from the card's edge: the code column's margin, near enough, without its gutters. */ +const MARGIN = 2 +/** Between the two pictures. */ +const GAP = 3 +/** The tallest a picture gets: enough to see a layout, not so tall a review becomes a gallery. */ +export const PICTURE_ROWS = 14 +/** Below this a picture says nothing, and the room goes to the words. */ +const NARROWEST = 8 + +/** The pane's colour when none is known (the preview CLI's default theme). */ +export const CANVAS = 0x1e1e2e + +const blank = (width: number): Row => ({ runs: [{ text: " ".repeat(width) }] }) + +/** A line of words in the card, inset by the margin and exactly `width` wide. */ +const line = (runs: Run[], width: number): Row => ({ + runs: clipRuns([{ text: " ".repeat(MARGIN) }, ...runs], width), +}) + +/** What opening it means, for the key on the card: one side has nothing to open on the other. */ +export function openLabel(binary: BinaryChange): string { + if (binary.before && binary.after) return "Open Both" + return binary.after ? "Open" : "Open Old Version" +} + +/** What sits where the picture would, when there is no picture. Undefined: say nothing. */ +function lookLine(binary: BinaryChange, look: ImageLook | undefined): Run[] | undefined { + if (look?.diff) { + const lit = look.diff.changed > 0 + return [{ text: pixelText(look.diff), tone: lit ? "accent" : "muted", bold: lit }] + } + const images = [binary.before?.image, binary.after?.image] + const decodes = images.some(decodable) + if (look?.pending && decodes) return [{ text: "Comparing pixels…", tone: "muted" }] + if (look?.problem) return [{ text: look.problem, tone: "muted" }] + if (binary.before?.image && binary.after?.image && !sameDimensions(binary)) + return [{ text: "Resized — every pixel moved, so no pixel diff", tone: "muted" }] + /** JPEG, WebP, BMP: described, not decoded. Said once, plainly, so a missing picture is not a bug. */ + const named = images.find((image) => image && !decodable(image)) + if (named) + return [ + { text: `No preview for ${formatName(named)} here — the header is all that is read`, tone: "muted" }, + ] + return undefined +} + +/** One picture's row `row` as runs of identical cells merged: `▀▀▀` in one ink on one background. */ +function cellRuns(cells: Cells | undefined, row: number, width: number): Run[] { + if (!cells || row >= cells.rows) return [{ text: " ".repeat(width) }] + const runs: Run[] = [] + let at = row * cells.cols + const end = at + cells.cols + while (at < end) { + const top = cells.top[at] as number + const bottom = cells.bottom[at] as number + let length = 1 + while (at + length < end && cells.top[at + length] === top && cells.bottom[at + length] === bottom) + length++ + runs.push({ text: "â–€".repeat(length), color: top, background: bottom }) + at += length + } + if (cells.cols < width) runs.push({ text: " ".repeat(width - cells.cols) }) + return runs +} + +/** + * The pictures, side by side: before and after, or the one side an added or deleted file has. + * + * With a pixel diff, the after side shows what changed at full strength and the rest faded toward the + * pane — the old side is drawn as it was, so the two still compare by eye. + */ +function pictureRows(look: ImageLook, width: number, canvas: number, paint: boolean): Row[] { + const sides = [look.before, look.after] + const shown = sides.filter((thumb): thumb is Thumb => thumb !== undefined) + if (shown.length === 0) return [] + const room = width - MARGIN + const panel = + shown.length === 2 ? Math.floor((room - GAP) / 2) : Math.min(room, Math.floor((room - GAP) / 2)) + if (panel < NARROWEST) return [] + const mask = + look.diff && look.diff.changed > 0 && look.diff.changed < look.diff.total ? look.diff.mask : undefined + /** Measuring needs only the grid; the cells are worked out when drawing. */ + const grids = sides.map((thumb) => + thumb ? cellGrid(thumb.width, thumb.height, panel, PICTURE_ROWS) : undefined, + ) + const height = Math.max(...grids.map((grid) => (grid ? grid.height / 2 : 0))) + const cells = paint + ? sides.map((thumb, index) => + thumb ? toCells(thumb, panel, PICTURE_ROWS, canvas, index === 1 ? mask : undefined) : undefined, + ) + : [undefined, undefined] + + const rows: Row[] = [] + const caption = (text: string) => ({ text: cell(text, panel), tone: "muted" as const }) + const labels: Run[] = + shown.length === 2 + ? [caption("before"), { text: " ".repeat(GAP) }, caption(mask ? "after · changes lit" : "after")] + : [caption(look.before ? "before — deleted" : "after — new")] + rows.push(line(labels, width)) + for (let row = 0; row < height; row++) { + const runs: Run[] = + shown.length === 2 + ? [...cellRuns(cells[0], row, panel), { text: " ".repeat(GAP) }, ...cellRuns(cells[1], row, panel)] + : cellRuns(look.before ? cells[0] : cells[1], row, panel) + rows.push(line(runs, width)) + } + return rows +} + +/** + * The card's body for a binary file, under its heading and any note on the whole file. + * + * `paint` false measures: the same rows, the same count, without working out a single cell. + */ +export function binaryRows( + file: FileChange, + look: ImageLook | undefined, + width: number, + canvas = CANVAS, + paint = true, +): Row[] { + const binary = file.binary + if (!binary || width <= 0) return [] + const rows: Row[] = [blank(width), line([{ text: summaryText(binary), tone: "text", bold: true }], width)] + const said = lookLine(binary, look) + if (said) rows.push(line(said, width)) + rows.push(line([...hintRuns({ key: "o", label: openLabel(binary) })], width)) + const pictures = look ? pictureRows(look, width, canvas, paint) : [] + if (pictures.length > 0) rows.push(blank(width), ...pictures) + return rows +} + +/** What a binary card's rows depend on beyond the file, for the row cache's key. */ +export const lookKey = (look: ImageLook | undefined): string => (look ? String(look.stamp) : "") diff --git a/packages/review/src/core/view/keys.ts b/packages/review/src/core/view/keys.ts new file mode 100644 index 00000000..ae231909 --- /dev/null +++ b/packages/review/src/core/view/keys.ts @@ -0,0 +1,94 @@ +/** + * `[?] Keys`: every key the review takes, in the body's place. + * + * The footer has room for the keys you use constantly and says `…` for the rest; this is the rest, one + * line each, what each does wrapped under its own column rather than cut — the same screen Trust and + * Shell have, so `?` means the same thing in every bay. + */ + +import { keyName } from "@opencode-cockpit/client/design" +import { clipRuns, type Row, type Run, wrapText } from "./rows.ts" + +export interface KeyLine { + keys: string[] + does: string +} + +/** In the order a review is done: move, read, say something, finish — then the pane itself. */ +export const REVIEW_KEYS: readonly KeyLine[] = [ + { keys: ["j/k", "↑/↓"], does: "Move: files in the list, lines in the diff" }, + { keys: ["tab"], does: "Switch between the file list and the diff" }, + { + keys: ["enter", "l"], + does: "Open the file under the cursor, or fold a folder; in the diff, fold the file", + }, + { keys: ["h", "â†�"], does: "Back to the file list" }, + { keys: ["d", "u"], does: "Scroll down or up a few lines (also pgdn, pgup)" }, + { + keys: ["L", "H"], + does: "Scroll the code sideways, for lines longer than the pane (also shift+→, shift+â†�)", + }, + { keys: ["v"], does: "Select lines; the cursor is the moving end" }, + { keys: ["c", "n"], does: "Comment on the line or the selection — or reply, on a thread" }, + { keys: ["f"], does: "Comment on the whole file" }, + { keys: ["x"], does: "Remove the thread here" }, + { keys: ["space", "m"], does: "Mark the file viewed and go to the next one that is not" }, + { keys: ["z"], does: "Fold or unfold the file" }, + { keys: ["o"], does: "Open an image or other binary in your system's viewer — both versions" }, + { keys: ["s"], does: "Hand the review to the agent" }, + { keys: ["b"], does: "Next source: uncommitted work, or what this branch changes" }, + { keys: ["B"], does: "Choose the branch to compare against" }, + { keys: ["g"], does: "Read the changes again" }, + { keys: ["w"], does: "Right pane or full screen" }, + { keys: ["p"], does: "Show what the review costs to draw" }, + { keys: ["?", "esc"], does: "Hide these keys; esc again closes the review" }, +] + +const key = (name: string): Run => ({ text: `[${keyName(name)}]`, tone: "accent", bold: true }) + +/** One key line: its keys in a column, what it does wrapped beside them. */ +function keyLineRows(line: KeyLine, column: number, width: number): Row[] { + const keys = line.keys.flatMap((name, at): Run[] => [...(at > 0 ? [{ text: " " }] : []), key(name)]) + const used = keys.reduce((sum, run) => sum + run.text.length, 0) + const indent = 1 + column + 3 + return wrapText(line.does, Math.max(1, width - indent - 1)).map((text, at) => ({ + runs: clipRuns( + [ + { text: " " }, + ...(at === 0 + ? [...keys, { text: " ".repeat(column - used + 3) }] + : [{ text: " ".repeat(indent - 1) }]), + { text, tone: "muted" }, + ], + width, + ), + })) +} + +/** As many key lines as fit in `height` rows; a list cut short says how many keys are below. */ +export function keyRows(width: number, height: number, list: readonly KeyLine[] = REVIEW_KEYS): Row[] { + const column = Math.max( + ...list.map((each) => each.keys.map((name) => `[${keyName(name)}]`).join(" ").length), + ) + const rows: Row[] = [{ runs: clipRuns([{ text: " KEYS", tone: "text", bold: true }], width) }] + rows.push({ runs: [{ text: " ".repeat(width) }] }) + let shown = 0 + for (const line of list) { + const lines = keyLineRows(line, column, width) + const last = shown === list.length - 1 + if (rows.length + lines.length > (last ? height : height - 1)) break + rows.push(...lines) + shown++ + } + if (shown < list.length) { + const left = list.length - shown + rows.push({ + runs: clipRuns( + [{ text: ` ↓ ${left} more key${left === 1 ? "" : "s"} — a taller pane shows them`, tone: "muted" }], + width, + ), + }) + } + while (rows.length < height) rows.push({ runs: [{ text: " ".repeat(width) }] }) + return rows.slice(0, height) +} diff --git a/packages/review/src/core/view/layout.ts b/packages/review/src/core/view/layout.ts index eb535050..6c214ae2 100644 --- a/packages/review/src/core/view/layout.ts +++ b/packages/review/src/core/view/layout.ts @@ -14,8 +14,9 @@ import type { ChangeSet, Review } from "../model/review.ts" import { metrics } from "../perf.ts" -import { footerRows, headerRows } from "./chrome.ts" +import { footerRows, headerRows, settingsRow } from "./chrome.ts" import { type Columns, FOOTER_ROWS, GUTTER, HEADER_ROWS, inset, splitColumns, window } from "./geometry.ts" +import { keyRows } from "./keys.ts" import { fileRows, listScroll, listWidth } from "./list.ts" import { cell, faint, type Row } from "./rows.ts" import type { Viewport, ViewState } from "./state.ts" @@ -55,8 +56,10 @@ function compose(changes: ChangeSet, review: Review, state: ViewState, viewport: /** The rule spans the pane, edge to edge; everything with words in it sits inside the gutter. */ const rule: Row = { runs: [{ text: "─".repeat(inner), tone: "border" }] } + /** A setting Review does not read takes the rule's place under the header, so nothing moves. */ + const warning = settingsRow(state.settings ?? [], content) const rows: Row[] = headerRows(changes, review, content, state.label).map((row, index) => - index === HEADER_ROWS - 1 ? rule : inset(row, inner), + index === HEADER_ROWS - 1 ? (warning ? inset(warning, inner) : rule) : inset(row, inner), ) const body = Math.max(1, viewport.height - HEADER_ROWS - FOOTER_ROWS) @@ -65,10 +68,17 @@ function compose(changes: ChangeSet, review: Review, state: ViewState, viewport: const blank = (width: number): Row => ({ runs: [{ text: " ".repeat(width) }] }) const close = (built: Row[]): Row[] => { - const feet = footerRows(content, columns, state, changes.files.length === 0) + const binary = changes.files.find((file) => file.path === state.file)?.binary !== undefined + const feet = footerRows(content, columns, state, changes.files.length === 0, binary) return [...built, ...feet.map((row, index) => (index === 0 ? rule : inset(row, inner)))] } + /** `?`: the keys take the body, and the footer is only the way back. */ + if (state.keys) { + for (const row of keyRows(content, body)) rows.push(inset(row, inner)) + return close(rows) + } + /** * Nothing to review, and why. * diff --git a/packages/review/src/core/view/rows.ts b/packages/review/src/core/view/rows.ts index 82496c92..14d77101 100644 --- a/packages/review/src/core/view/rows.ts +++ b/packages/review/src/core/view/rows.ts @@ -84,9 +84,17 @@ export interface Run { * * A real highlighter returns colours, not categories — so it sets this and the renderers prefer it * over `tone`. Untyped because this file is pure: it is the terminal library's own colour object, - * carried through untouched. + * carried through untouched — or a packed `0xRRGGBB` number, which the renderers turn into one. */ color?: unknown + /** + * An exact background, beside the named `fill`, and preferred over it. + * + * A picture drawn in half blocks needs two exact colours per cell — `â–€` in the top pixel's colour on + * the bottom pixel's — and a named surface cannot be a pixel. A packed `0xRRGGBB` number here, so the + * view stays pure and the preview CLI can paint it too. + */ + background?: unknown } export interface Row { diff --git a/packages/review/src/core/view/state.ts b/packages/review/src/core/view/state.ts index 0a0edc7c..c03b954f 100644 --- a/packages/review/src/core/view/state.ts +++ b/packages/review/src/core/view/state.ts @@ -7,6 +7,7 @@ * the thing that draws the whole screen. */ +import type { ImageLook } from "../image/looks.ts" import type { Run } from "./rows.ts" /** @@ -82,6 +83,12 @@ export interface ViewState { * use than a row of keys you can get back with `?`. */ notice?: string + /** + * Settings Review does not read — an old name, a value of the wrong kind — each a sentence + * (`noticeText`). Review has no sidebar block to say them in, so the pane does: one `!` row in + * place of the header's rule, for as long as the file is wrong, not a toast gone in ten seconds. + */ + settings?: readonly string[] /** The numbers, when you have asked to see them. Same place, same reasoning. */ stats?: readonly Run[] /** @@ -98,4 +105,14 @@ export interface ViewState { * you have while reading, and the answer was previously only available by pressing the key. */ waiting?: number + /** + * What is known about each changed image beyond its header — the pixel diff, the thumbs a preview + * is drawn from — by path. Filled in off the draw path (`image/looks.ts`); absent, a binary card + * shows its metadata and nothing it would have to guess. + */ + looks?: ReadonlyMap + /** The pane's own colour, packed `0xRRGGBB`: what a transparent pixel in a preview shows. */ + canvas?: number + /** `?`: every key and what it does, in the body's place. */ + keys?: boolean } diff --git a/packages/review/src/core/viewer.ts b/packages/review/src/core/viewer.ts new file mode 100644 index 00000000..75552f81 --- /dev/null +++ b/packages/review/src/core/viewer.ts @@ -0,0 +1,147 @@ +/** + * `o`: both versions of a binary, in the system's own viewer. + * + * The honest answer to "what changed in this image": a preview at terminal resolution shows *where*, + * never *what* — text in a 2880-pixel screenshot at fifty columns is noise. The old bytes come out of + * git into a temporary file named `@`, so the viewer picks the right app and its + * title bar says which side it is; the new side is the working copy itself. + * + * Which program opens them is client's `openerFor`, shared with Trail: `COCKPIT_OPENER` replaces it + * for a sandbox or a live check. Everything that touches the system is injected, so tests never launch + * a real viewer. + */ + +import { spawn as nodeSpawn } from "node:child_process" +import { mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { basename, extname, join } from "node:path" +import { isRunnable, type OpenerWhere, openerFor, openerName } from "@opencode-cockpit/client/opener" +import type { FileChange } from "./model/review.ts" + +/** A launched process, as far as this file cares: it can fail to start, and it is not waited on. */ +export interface Launched { + on(event: "error", listener: (error: Error) => void): unknown + unref(): void +} + +export interface ViewerDeps { + platform: NodeJS.Platform + env: Record + /** + * Finds a command on a PATH. `Bun.which`, given the PATH explicitly: `Bun.spawn` resolves a bare name + * from the *parent's* PATH, not the env passed to it — the spike's PATH shim was bypassed until the + * binary was resolved like this. It is also how "no opener here" is noticed and said. + */ + which: (command: string, options: { PATH: string }) => string | null + /** Whether a file exists and can be run: macOS's `/usr/bin/open` is tried before PATH. */ + exists: (path: string) => boolean + /** Starts the opener without waiting for it. Never `spawnSync`: on the TUI thread it takes the renderer down. */ + spawn: (command: string, args: string[], env: Record) => Launched + /** The old side's bytes, from git. */ + readOld: (file: FileChange) => Promise + /** Where temporary copies go; one directory per pane, made on first use. */ + tmp?: string + /** A launch that failed after `open` had already returned. */ + report: (problem: string) => void +} + +/** The real system: Node's spawn (it has an `error` event; Bun's throws or exits instead), detached. */ +export const systemSpawn: ViewerDeps["spawn"] = (command, args, env) => + nodeSpawn(command, args, { env, detached: true, stdio: "ignore" }) + +export const systemWhich: ViewerDeps["which"] = (command, options) => Bun.which(command, options) + +export const systemExists: ViewerDeps["exists"] = isRunnable + +const PREFIX = "cockpit-review-" +/** A leftover directory this old is from a pane that never closed cleanly — a crash, a kill. */ +const STALE_MS = 24 * 60 * 60 * 1000 + +/** `shot@a1b2c3d.png`: the name, which side, and the extension the viewer goes by. */ +export function oldName(file: FileChange): string { + const path = file.from ?? file.path + const extension = extname(path) + const revision = file.binary?.revision ?? "old" + const short = /^[0-9a-f]{12,}$/.test(revision) ? revision.slice(0, 7) : revision + return `${basename(path, extension)}@${short}${extension}` +} + +export interface Viewer { + /** + * Opens what there is of `file`: both sides, or the one an added or deleted file has. Resolves once + * the openers are launched — not when anything is viewed. Says what went wrong, if anything did. + */ + open: (cwd: string, file: FileChange) => Promise<{ opened: number; problem?: string }> + /** Removes this pane's temporary copies. */ + clean: () => Promise +} + +export function createViewer(deps: ViewerDeps): Viewer { + let dir: string | undefined + let swept = false + + /** Leftovers from panes that did not close cleanly, swept once — only old ones, never a live pane's. */ + const sweep = async (root: string) => { + if (swept) return + swept = true + const names = await readdir(root).catch(() => [] as string[]) + const now = Date.now() + for (const name of names) { + if (!name.startsWith(PREFIX)) continue + const full = join(root, name) + const info = await stat(full).catch(() => undefined) + if (info && now - info.mtimeMs > STALE_MS) + await rm(full, { recursive: true, force: true }).catch(() => {}) + } + } + + const directory = async (): Promise => { + if (dir) return dir + const root = deps.tmp ?? tmpdir() + await sweep(root) + dir = await mkdtemp(join(root, PREFIX)) + return dir + } + + return { + async open(cwd, file) { + const PATH = deps.env.PATH ?? "" + const where: OpenerWhere = { + platform: deps.platform, + exists: deps.exists, + which: (name) => deps.which(name, { PATH }) ?? undefined, + ...(deps.env.COCKPIT_OPENER ? { override: deps.env.COCKPIT_OPENER } : {}), + } + if (!openerFor(join(cwd, file.path), where)) + return { + opened: 0, + problem: `Nothing to open files with: ${openerName(deps.platform)} is not on PATH`, + } + + const targets: string[] = [] + const binary = file.binary + if (binary?.before) { + const bytes = await deps.readOld(file) + if (!bytes) return { opened: 0, problem: `Could not read the old ${basename(file.path)} from git` } + const path = join(await directory(), oldName(file)) + await writeFile(path, bytes) + targets.push(path) + } + if (!binary || binary.after) targets.push(join(cwd, file.path)) + + for (const target of targets) { + const opener = openerFor(target, where) + if (!opener) continue + const launched = deps.spawn(opener.command, opener.args, deps.env) + launched.on("error", (error) => deps.report(`Could not open ${basename(target)}: ${error.message}`)) + launched.unref() + } + return { opened: targets.length } + }, + async clean() { + const was = dir + dir = undefined + if (was) await rm(was, { recursive: true, force: true }).catch(() => {}) + }, + } +} diff --git a/packages/review/src/tui/index.tsx b/packages/review/src/tui/index.tsx index c501e4cd..2671a849 100644 --- a/packages/review/src/tui/index.tsx +++ b/packages/review/src/tui/index.tsx @@ -1,15 +1,21 @@ /** @jsxImportSource @opentui/solid */ +import { defaultKeys } from "@opencode-cockpit/client/catalog" import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client/feature" import { bindingLookup, dualTui, type Host } from "@opencode-cockpit/client/host" +import { noticeText } from "@opencode-cockpit/client/settings" import type { BoxRenderable } from "@opentui/core" -import { headOf } from "../core/git/sources.ts" +import { loadReview, type ReviewConfig } from "../core/config.ts" +import { headOf, readBlob, readWorking } from "../core/git/sources.ts" +import { createLooks } from "../core/image/looks.ts" import type { Source } from "../core/model/review.ts" +import { matchesBinding, paletteBindings } from "../core/palette.ts" import { metrics } from "../core/perf.ts" import { reviewPaths } from "../core/store/paths.ts" import { createPersistence } from "../core/store/persist.ts" -import { frameBounds, VARIANTS, type Variant } from "../core/view/frame.ts" +import { frameBounds, VARIANTS } from "../core/view/frame.ts" import { FOOTER_ROWS, HEADER_ROWS } from "../core/view/geometry.ts" +import { createViewer, systemExists, systemSpawn, systemWhich } from "../core/viewer.ts" import { createStore } from "./data/changes.ts" import { createActions } from "./panel/actions.ts" import { paneLayer } from "./panel/keys.ts" @@ -21,11 +27,7 @@ import { createTrouble } from "./panel/trouble.ts" import { Overlay } from "./view/overlay.tsx" import { createRowPool, type RowPool } from "./view/pool.ts" -/** Global keys, leader-prefixed and few. `` is OpenCode's own prefix — `ctrl+x` by default. */ -const DEFAULT_KEYS = { - "cockpit.review.open": "v", - "cockpit.review.place": "r", -} +const DEFAULT_KEYS = defaultKeys("review") const REVIEW_PACKAGE = "@opencode-cockpit/review" @@ -34,18 +36,8 @@ const SLOT_ORDER = 180 const SOURCES: Source[] = ["worktree", "branch"] -export interface ReviewTuiOptions { - /** Which placement to open in: right | full. */ - variant?: Variant - /** - * What to review on open: worktree | branch | session. - * - * Uncommitted by default, because that is what you are looking at nine times in ten — the work - * that just happened. Branch is for reading a pull request, which is a thing you choose to do. - */ - source?: Source - keybinds?: Record -} +/** The `review` section of the config files, then the plugin entry's options (core/config.ts). */ +export type ReviewTuiOptions = Partial /** * Review's TUI half: what it is made of, and who owns what. @@ -74,10 +66,26 @@ export function createReviewTui({ source = REVIEW_PACKAGE }: { source?: string } } api.lifecycle.onDispose(() => claim.release()) - const options = (rawOptions ?? {}) as ReviewTuiOptions + const { config: options, notices } = loadReview(api.state.path.directory, rawOptions) + if (!options.enabled) { + api.log.info("review: off in the settings") + return + } + /** + * Review draws no sidebar block, so its settings notices are a `!` row in the pane (in place of + * the header's rule, `chrome.ts`), said once more as the session starts, and kept in the log. + */ + for (const notice of notices) api.log.warn("review: settings", { file: notice.file, notice: notice.text }) + if (notices.length > 0) + api.ui.toast({ + variant: "warning", + title: "Review", + message: notices.map(noticeText).join("\n"), + duration: 10_000, + }) const keys = bindingLookup({ ...DEFAULT_KEYS, ...options.keybinds }) - const store = createStore(api, options.source ?? "worktree") - const surface = createSurface(options.variant ?? "right") + const store = createStore(api, options.source) + const surface = createSurface(options.variant) /** * The renderables the slot hands back, which do not exist until it mounts. @@ -125,6 +133,25 @@ export function createReviewTui({ source = REVIEW_PACKAGE }: { source?: string } const { guard, notice } = createTrouble({ api, surface, store }) + const directory = () => api.state.path.worktree || api.state.path.directory + + /** + * The images under review, decoded off the draw path once git has been read: the pixel diff and + * the thumbs the preview is drawn from. A paint is asked for as each file is done. + */ + const looks = createLooks( + async (file, side) => { + const read = + side === "after" + ? await readWorking(directory(), file.path) + : file.binary?.revision + ? await readBlob(directory(), file.binary.revision, file.from ?? file.path) + : undefined + return read?.whole ? read.bytes : undefined + }, + () => draw(), + ) + const { draw } = createPainter({ api, surface, @@ -133,6 +160,32 @@ export function createReviewTui({ source = REVIEW_PACKAGE }: { source?: string } queries, notice, boxes: () => ({ backdrop, panel, pool }), + looks: () => looks.current(), + settings: notices.map(noticeText), + }) + + /** `o`: the old side out of git, uncapped — a viewer needs the whole file, not its header. */ + const viewer = createViewer({ + platform: process.platform, + env: process.env, + which: systemWhich, + exists: systemExists, + spawn: systemSpawn, + readOld: async (file) => + file.binary?.revision + ? ( + await readBlob( + directory(), + file.binary.revision, + file.from ?? file.path, + Number.POSITIVE_INFINITY, + ) + )?.bytes + : undefined, + report: (problem) => { + surface.said = { text: problem, at: Date.now() } + draw() + }, }) /** The branch can change under us, and the review that belongs to it changes with it. */ @@ -144,6 +197,7 @@ export function createReviewTui({ source = REVIEW_PACKAGE }: { source?: string } const [, threads, at] = await Promise.all([store.load(), persistence.load(), headOf(directory)]) head = at surface.review = { ...surface.review, threads } + looks.sync(store.current().changes.files) /** Land on something worth reading rather than on an empty pane. */ const files = queries.files() if (!surface.view.file || !files.some((file) => file.path === surface.view.file)) { @@ -178,10 +232,15 @@ export function createReviewTui({ source = REVIEW_PACKAGE }: { source?: string } const close = () => { surface.open = false + surface.yielded = false clearInterval(watching) watching = undefined + clearInterval(waiting) + waiting = undefined panel?.blur() dropKeys() + /** The old versions `o` wrote out are only for as long as the pane is up. */ + void viewer.clean() draw() /** The prompt wants its cursor back, exactly where the host had it. */ const at = api.renderer.getCursorState?.() @@ -191,6 +250,7 @@ export function createReviewTui({ source = REVIEW_PACKAGE }: { source?: string } const show = () => { surface.open = true + surface.yielded = false panel?.focus() takeKeys() draw() @@ -201,6 +261,51 @@ export function createReviewTui({ source = REVIEW_PACKAGE }: { source?: string } const toggle = () => (surface.open ? close() : show()) + /** + * Stepping aside for the host's command palette. + * + * The review's keys are a global layer (the only kind that fires), so with the pane open `ctrl+p` + * opened OpenCode's palette under it and every key typed into the palette moved the review + * instead. The palette's own key is let through untouched; the review hides and gives up its keys, + * and comes back when the palette closes — on OpenCode 1, which says how deep its dialogs are. On + * OpenCode 2 nothing says when the palette closes, so the review closes instead; the palette's + * "Open or close the changes" brings it back where it was. + */ + let waiting: ReturnType | undefined + const paletteKeys = paletteBindings(api.v1?.state.config) + const stepAside = () => { + if (!api.v1) return close() + surface.yielded = true + dropKeys() + draw() + const since = Date.now() + let opened = false + clearInterval(waiting) + waiting = setInterval(() => { + const depth = api.ui.dialog.depth + if (depth > 0) opened = true + /** Closed again, or never opened at all: either way the review comes back. */ + if ((opened && depth === 0) || (!opened && Date.now() - since > 1_000)) { + clearInterval(waiting) + waiting = undefined + if (!surface.open || !surface.yielded) return + surface.yielded = false + takeKeys() + draw() + } + }, 100) + } + api.lifecycle.onDispose( + api.keymap.intercept( + (context) => { + if (!surface.open || surface.yielded || !disposeKeys) return + if (paletteKeys.some((binding) => matchesBinding(context.event, binding))) + guard.run("palette", stepAside) + }, + { priority: 10_000 }, + ), + ) + const cycle = () => { surface.variant = VARIANTS[(VARIANTS.indexOf(surface.variant) + 1) % VARIANTS.length] ?? "right" draw() @@ -225,6 +330,7 @@ export function createReviewTui({ source = REVIEW_PACKAGE }: { source?: string } sources: SOURCES, head: () => head, viewport, + viewer, }) const pointer = createPointer({ diff --git a/packages/review/src/tui/panel/actions.ts b/packages/review/src/tui/panel/actions.ts index 23de0b2c..3f098044 100644 --- a/packages/review/src/tui/panel/actions.ts +++ b/packages/review/src/tui/panel/actions.ts @@ -40,6 +40,7 @@ import { streamScroll, streamWindow, } from "../../core/view/stream.ts" +import type { Viewer } from "../../core/viewer.ts" import type { Store } from "../data/changes.ts" import { askForBase, askForNote, noteFields, replyFields, submitFields } from "../view/dialogs.tsx" import type { Queries } from "./queries.ts" @@ -75,6 +76,8 @@ export interface ActionDeps { head: () => string | undefined /** The pane's size, which is what the stream's heights are measured against. */ viewport: () => Viewport + /** Opens a binary's two versions in the system viewer. */ + viewer: Viewer } export interface Actions { @@ -104,6 +107,14 @@ export interface Actions { cycle: () => void close: () => void reload: () => void + /** `o`: the file under the cursor, both versions, in the system's viewer. */ + openExternal: () => void + /** `?`: every key, in the body's place — or back from it. */ + toggleKeys: () => void + /** Back from the keys screen, if it is showing; true when it was. */ + leaveKeys: () => boolean + /** `esc`: the keys screen first, then the review. */ + quit: () => void } export function createActions(deps: ActionDeps): Actions { @@ -678,6 +689,50 @@ export function createActions(deps: ActionDeps): Actions { draw() } + /** Said in the footer, where you are looking, for a few seconds. */ + const tell = (text: string) => { + surface.said = { text, at: Date.now() } + draw() + } + + /** + * Both versions of the binary under the cursor, in the system's own viewer. + * + * Launched and not waited on — nothing here may block the thread the pane draws on. What goes wrong + * later (the opener failing to start) arrives through the viewer's `report` and is said the same way. + */ + const openExternal = () => { + const file = store.current().changes.files.find((each) => each.path === surface.view.file) + if (!file) return + if (!file.binary) return tell("o opens images and other binaries in your viewer; this file is text") + const cwd = api.state.path.worktree || api.state.path.directory + guard.task("open", async () => { + const result = await deps.viewer.open(cwd, file) + if (result.problem) tell(result.problem) + }) + } + + const toggleKeys = () => { + surface.view = { ...surface.view, keys: !surface.view.keys } + draw() + } + + const leaveKeys = (): boolean => { + if (!surface.view.keys) return false + surface.view = { ...surface.view, keys: false } + draw() + return true + } + + /** + * Escape closes the nearest thing first, and the close is deferred: closing disposes the layer the + * key is dispatching through. + */ + const quit = () => { + if (leaveKeys()) return + setTimeout(() => close(), 0) + } + return { move, scroll, @@ -702,5 +757,9 @@ export function createActions(deps: ActionDeps): Actions { cycle: deps.cycle, close, reload: deps.refresh, + openExternal, + toggleKeys, + leaveKeys, + quit, } } diff --git a/packages/review/src/tui/panel/keys.ts b/packages/review/src/tui/panel/keys.ts index 823d4c17..c9c256d5 100644 --- a/packages/review/src/tui/panel/keys.ts +++ b/packages/review/src/tui/panel/keys.ts @@ -25,19 +25,30 @@ type Command = NonNullable[number] * Wrapped here, at the one place commands are registered, rather than at twenty call sites — a safety * net with a hole in it because somebody forgot a line is not a safety net. */ -const guarded = (guard: Guard, commands: Command[]): Command[] => +const guarded = (guard: Guard, actions: Actions, commands: Command[]): Command[] => commands.map((command) => ({ ...command, run: (...args: Parameters) => { metrics.count("keys") - guard.run(command.name.replace("cockpit.review.", ""), () => command.run(...args)) + guard.run(command.name.replace("cockpit.review.", ""), () => { + /** Any key that acts on the review takes you back to it from the keys screen, then acts. */ + if (!STAY_ON_KEYS.has(command.name)) actions.leaveKeys() + return command.run(...args) + }) }, })) +/** The keys that mean something on the keys screen itself: leaving it, and the numbers. */ +const STAY_ON_KEYS = new Set([ + "cockpit.review.pane.keys", + "cockpit.review.pane.quit", + "cockpit.review.pane.stats", +]) + export function paneLayer(actions: Actions, guard: Guard): Layer { return { priority: 100, - commands: guarded(guard, [ + commands: guarded(guard, actions, [ { name: "cockpit.review.pane.down", title: "Down", run: () => actions.move(1) }, { name: "cockpit.review.pane.up", title: "Up", run: () => actions.move(-1) }, { name: "cockpit.review.pane.swap", title: "Switch pane", run: () => actions.swap() }, @@ -104,12 +115,15 @@ export function paneLayer(actions: Actions, guard: Guard): Layer { { name: "cockpit.review.pane.quit", title: "Close the review", - /** - * Escape closes the nearest thing first, and the close is deferred: closing disposes the layer - * this handler is dispatching through. - */ - run: () => setTimeout(() => actions.close(), 0), + /** The keys screen first, then the review — see `actions.quit`. */ + run: () => actions.quit(), + }, + { + name: "cockpit.review.pane.open", + title: "Open both versions in the system viewer", + run: () => actions.openExternal(), }, + { name: "cockpit.review.pane.keys", title: "Show every key", run: () => actions.toggleKeys() }, { name: "cockpit.review.pane.stats", title: "Show what the review is costing", @@ -139,6 +153,8 @@ export function paneLayer(actions: Actions, guard: Guard): Layer { { key: "w", cmd: "cockpit.review.pane.cycle", desc: "Width" }, { key: "p", cmd: "cockpit.review.pane.stats", desc: "Numbers" }, { key: "s", cmd: "cockpit.review.pane.submit", desc: "Submit" }, + { key: "o", cmd: "cockpit.review.pane.open", desc: "Open in viewer" }, + { key: "?,shift+/", cmd: "cockpit.review.pane.keys", desc: "Keys" }, { key: "q,escape", cmd: "cockpit.review.pane.quit", desc: "Close" }, ], } diff --git a/packages/review/src/tui/panel/paint.ts b/packages/review/src/tui/panel/paint.ts index 89f0f7d2..b43a2fd0 100644 --- a/packages/review/src/tui/panel/paint.ts +++ b/packages/review/src/tui/panel/paint.ts @@ -10,6 +10,7 @@ import type { Host } from "@opencode-cockpit/client/host" import type { BoxRenderable } from "@opentui/core" import type { Guard } from "../../core/guard.ts" +import type { ImageLook } from "../../core/image/looks.ts" import { filesElsewhere } from "../../core/model/review.ts" import { waitingOnAgent } from "../../core/model/submit.ts" import { metrics } from "../../core/perf.ts" @@ -17,7 +18,7 @@ import { frameBounds } from "../../core/view/frame.ts" import { layout } from "../../core/view/layout.ts" import { statsRuns } from "../../core/view/stats.ts" import type { Store } from "../data/changes.ts" -import { type RowPool, solidSurface } from "../view/pool.ts" +import { packed, type RowPool, solidSurface } from "../view/pool.ts" import type { Queries } from "./queries.ts" import type { Surface } from "./surface.ts" @@ -37,8 +38,15 @@ export interface PaintDeps { /** What went wrong recently, if anything, for the footer to say so. */ notice: () => string | undefined boxes: () => Boxes + /** What is known about the images under review, beyond their headers. */ + looks: () => ReadonlyMap + /** Settings Review does not read, as sentences: the `!` row under the header. */ + settings?: readonly string[] } +/** How long something the review said stays in the footer. */ +const SAID_MS = 8_000 + export interface Painter { /** Asks for a paint. Several asks in one turn are one paint, of the state the turn ended on. */ draw: () => void @@ -53,15 +61,18 @@ export function createPainter(deps: PaintDeps): Painter { if (!backdrop || !panel) return const screen = { width: api.renderer.width, height: api.renderer.height } const frame = frameBounds(surface.variant, screen) + /** Stepped aside for the host's palette, the review is open but not drawn. */ + const showing = surface.open && !surface.yielded backdrop.width = screen.width - backdrop.height = surface.open ? screen.height : 0 - backdrop.visible = surface.open + backdrop.height = showing ? screen.height : 0 + backdrop.visible = showing /** Opaque whatever the theme says, or the conversation shows through (transparent themes). */ - panel.backgroundColor = solidSurface(api.theme.current, "panel") + const behind = solidSurface(api.theme.current, "panel") + panel.backgroundColor = behind panel.width = frame.width - panel.height = surface.open ? screen.height : 0 + panel.height = showing ? screen.height : 0 /** * No border, and no title. * @@ -71,7 +82,7 @@ export function createPainter(deps: PaintDeps): Painter { */ panel.title = "" - if (!surface.open) { + if (!showing) { pool?.clear() api.renderer.requestRender() return @@ -86,7 +97,7 @@ export function createPainter(deps: PaintDeps): Painter { * back, so the last word has to be ours. */ setTimeout(() => { - if (surface.open) api.renderer.setCursorPosition(0, 0, false) + if (surface.open && !surface.yielded) api.renderer.setCursorPosition(0, 0, false) }, 0) /** @@ -97,7 +108,8 @@ export function createPainter(deps: PaintDeps): Painter { * pane with no explanation. A diagnostic written to a field nobody renders is worse than none: it * looks like the feature simply does not work. */ - const trouble = deps.notice() ?? store.current().notice + const said = surface.said && Date.now() - surface.said.at < SAID_MS ? surface.said.text : undefined + const trouble = deps.notice() ?? said ?? store.current().notice const thread = queries.hereThreadId() const away = filesElsewhere(surface.review, store.current().changes) const rows = layout( @@ -109,6 +121,9 @@ export function createPainter(deps: PaintDeps): Painter { waiting: waitingOnAgent(surface.review).length, ...(thread ? { thread } : {}), ...(away.length > 0 ? { elsewhere: away } : {}), + looks: deps.looks(), + canvas: packed(behind), + ...(deps.settings?.length ? { settings: deps.settings } : {}), /** Trouble outranks the numbers; both outrank the keys, and the footer stays two rows. */ ...(trouble ? { notice: trouble } diff --git a/packages/review/src/tui/panel/surface.ts b/packages/review/src/tui/panel/surface.ts index 7d3514de..6f2b6e20 100644 --- a/packages/review/src/tui/panel/surface.ts +++ b/packages/review/src/tui/panel/surface.ts @@ -26,6 +26,13 @@ export interface Surface { variant: Variant /** Whether the footer is showing the numbers instead of the keys. */ showStats: boolean + /** + * Open, but stepped aside for the host's palette: hidden, keys given up, back when it closes. + * Separate from `open` so a palette command that toggles the review still sees it open. + */ + yielded: boolean + /** Something to say in the footer for a while — what `o` could not do, say. */ + said?: { text: string; at: number } } export function createSurface(variant: Variant): Surface { @@ -36,5 +43,6 @@ export function createSurface(variant: Variant): Surface { open: false, variant, showStats: false, + yielded: false, } } diff --git a/packages/review/src/tui/view/pool.ts b/packages/review/src/tui/view/pool.ts index 1bf123d5..bfb50407 100644 --- a/packages/review/src/tui/view/pool.ts +++ b/packages/review/src/tui/view/pool.ts @@ -42,7 +42,8 @@ const sameRow = (was: Row | undefined, now: Row): boolean => { before.bold !== after.bold || before.italic !== after.italic || before.faint !== after.faint || - before.color !== after.color + before.color !== after.color || + before.background !== after.background ) return false } @@ -208,23 +209,65 @@ const soften = (ink: RGBA, behind: RGBA): RGBA => { return out } +/** A colour as packed `0xRRGGBB`, tolerant of both 0..1 and 0..255 channels. */ +export const packed = (colour: RGBA): number => { + const scale = Math.max(colour.r, colour.g, colour.b) > 1 ? 1 : 255 + const channel = (value: number) => Math.max(0, Math.min(255, Math.round(value * scale))) + return (channel(colour.r) << 16) | (channel(colour.g) << 8) | channel(colour.b) +} + +/** + * Packed colours as the host's own colour objects, made once each. + * + * A picture's cells arrive as numbers (the view is pure), and a screenshot repeats its colours + * heavily, so each is built once from the host's class — read off a theme colour, the way `soften` + * gets it, so nothing of OpenTUI is imported — and reused for every cell that shares it. + */ +const exact = new Map() +let exactClass: unknown +const EXACT = 8192 + +const exactColour = (theme: TuiThemeCurrent, value: unknown): RGBA | undefined => { + if (typeof value !== "number") return value as RGBA | undefined + const Colour = theme.text.constructor as unknown as { + fromInts?: (r: number, g: number, b: number, a: number) => RGBA + } + if (Colour !== exactClass) { + exact.clear() + exactClass = Colour + } + const known = exact.get(value) + if (known) return known + if (typeof Colour.fromInts !== "function") return undefined + const made = Colour.fromInts((value >> 16) & 255, (value >> 8) & 255, value & 255, 255) + if (exact.size >= EXACT) exact.clear() + exact.set(value, made) + return made +} + /** One styled run becomes one chunk. An exact colour wins: a parser knows better than a tone does. */ const chunk = (theme: TuiThemeCurrent, run: Row["runs"][number]): TextChunk => { - const fill = fillColour(theme, run.fill) + const background = run.background === undefined ? undefined : exactColour(theme, run.background) + const fill = background ?? fillColour(theme, run.fill) const base = run.tone === "inverse" ? inkOn(theme, fill) : toneColour(theme, run.tone) - /** Blend when there is a solid colour to blend toward; a see-through pane falls back to DIM. */ - const behind = opaque(fill) ? fill : opaque(theme.backgroundPanel) ? theme.backgroundPanel : undefined + /** + * Blend when there is a solid colour to blend toward; a see-through pane falls back to DIM. A + * picture's background is its lower pixel, not a surface: both its pixels fade toward the pane. + */ + const panel = opaque(theme.backgroundPanel) ? theme.backgroundPanel : undefined + const behind = background ? panel : opaque(fill) ? fill : panel const blend = run.faint && behind !== undefined const ink = blend && base ? soften(base, behind) : base const attributes = (run.bold ? BOLD : 0) | (run.italic ? ITALIC : 0) | (run.faint && !blend ? DIM : 0) // No ink reads on this block, so the block goes: the badge becomes its own colour, in words. if (run.tone === "inverse" && ink === undefined) return { __isChunk: true, text: run.text, fg: fill ?? theme.text, bg: undefined, attributes } as TextChunk + const colour = run.color === undefined ? undefined : exactColour(theme, run.color) return { __isChunk: true, text: run.text, - fg: run.color ? (blend ? soften(run.color as RGBA, behind as RGBA) : (run.color as RGBA)) : ink, - bg: fill, + fg: colour ? (blend ? soften(colour, behind as RGBA) : colour) : ink, + bg: background && blend ? soften(background, behind as RGBA) : fill, attributes, } as TextChunk } diff --git a/packages/review/test/agent/plugin.test.ts b/packages/review/test/agent/plugin.test.ts index 15c4c876..3a500525 100644 --- a/packages/review/test/agent/plugin.test.ts +++ b/packages/review/test/agent/plugin.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os" import { join } from "node:path" import type { Hooks, PluginInput } from "@opencode-ai/plugin" import { partsToV1Hooks, serverFromV1 } from "@opencode-cockpit/client/server" -import { createReviewServer } from "../../src/agent/plugin.ts" +import { createReviewServer, reviewGuidance } from "../../src/agent/plugin.ts" import type { Thread } from "../../src/core/model/thread.ts" import { reviewPaths } from "../../src/core/store/paths.ts" import { createPersistence } from "../../src/core/store/persist.ts" @@ -126,6 +126,27 @@ describe("what the agent is told", () => { }) }) +describe("named for each OpenCode, and where the user reads the threads", () => { + test("tools.review_list in OpenCode 2's Code Mode, review_list on OpenCode 1", () => { + expect(reviewGuidance(2)).toContain("tools.review_list shows the ones waiting on you") + expect(reviewGuidance(2)).toContain("then tools.review_reply with resolved=true") + expect(reviewGuidance(1)).not.toContain("tools.") + }) + + test("the threads are in Review, opened with its key — for the Cockpit-wide line", async () => { + const parts = await createReviewServer()(serverFromV1(input()), undefined) + expect(parts.surfaces).toEqual([{ what: "review threads", where: "Review", open: "ctrl+x v" }]) + const rebound = await createReviewServer()(serverFromV1(input()), { + keybinds: { "cockpit.review.open": "none" }, + }) + expect(rebound.surfaces?.[0]?.open).toBe("/changes") + }) + + test("enabled: false is both halves — no tools, no guidance", async () => { + expect(await createReviewServer()(serverFromV1(input()), { enabled: false })).toEqual({}) + }) +}) + describe("two copies of Review", () => { /** One from the bundle, one installed directly: the second stands down rather than double-register. */ test("the second registers nothing, and says why in the log", async () => { diff --git a/packages/review/test/config.test.ts b/packages/review/test/config.test.ts new file mode 100644 index 00000000..4826473d --- /dev/null +++ b/packages/review/test/config.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { loadReview } from "../src/core/config.ts" + +/** + * Review reads the files now, through the loader every bay shares: the `review` section, then the + * plugin entry. Before 0.9 its settings lived only on the entry in `tui.json`. + */ + +const dirs: string[] = [] +const temp = () => { + const dir = mkdtempSync("/tmp/ck-review-conf-") + dirs.push(dir) + return dir +} +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + +function files(global: string | object | undefined, project?: string | object) { + const home = temp() + const directory = temp() + const text = (value: string | object) => (typeof value === "string" ? value : JSON.stringify(value)) + if (global !== undefined) { + mkdirSync(join(home, "opencode-cockpit"), { recursive: true }) + writeFileSync(join(home, "opencode-cockpit", "config.json"), text(global)) + } + if (project !== undefined) writeFileSync(join(directory, ".cockpit.json"), text(project)) + return { directory, env: { XDG_CONFIG_HOME: home } } +} + +describe("settings", () => { + test("defaults: the right pane, uncommitted work", () => { + const { directory, env } = files(undefined) + const { config, notices } = loadReview(directory, undefined, env) + expect(config).toMatchObject({ enabled: true, variant: "right", source: "worktree", keybinds: {} }) + expect(notices).toEqual([]) + }) + + test("the `review` section of either file, and the entry's options over both", () => { + const { directory, env } = files( + `{ "review": { "variant": "full", "source": "branch", } // comments are fine + }`, + { review: { keybinds: { "cockpit.review.open": "c" } } }, + ) + const fromFiles = loadReview(directory, undefined, env).config + expect(fromFiles).toMatchObject({ variant: "full", source: "branch" }) + expect(fromFiles.keybinds).toEqual({ "cockpit.review.open": "c" }) + /** A standalone entry carries its own keys; the bundle hands over a `review` section. */ + for (const options of [{ variant: "right" }, { review: { variant: "right" } }]) + expect(loadReview(directory, options, env).config.variant).toBe("right") + }) + + test("a value Review does not know is the default and a notice naming the ones it does", () => { + const { directory, env } = files({ review: { variant: "left" } }) + const { config, notices } = loadReview(directory, { source: "session" }, env) + expect(config.variant).toBe("right") + expect(config.source).toBe("worktree") + const variant = notices.find((notice) => notice.old === "review.variant") + expect(variant?.text).toContain("right, full") + expect(variant?.file).toEndWith("config.json") + expect(notices.find((notice) => notice.old === "review.source")?.file).toBe("plugin options") + }) + + test("`features.review: false` in a file turns it off", () => { + const { directory, env } = files({ features: { review: false } }) + expect(loadReview(directory, undefined, env).config.enabled).toBe(false) + }) +}) diff --git a/packages/review/test/diff/hunks.test.ts b/packages/review/test/diff/hunks.test.ts index 6fa88255..3e1898d7 100644 --- a/packages/review/test/diff/hunks.test.ts +++ b/packages/review/test/diff/hunks.test.ts @@ -212,6 +212,61 @@ describe("large files", () => { expect(added?.text).toBe("line 1500 — changed") }) + /** + * The 0.8 limitation: the trim takes only what is identical at the very ends, so two edits 2,860 + * lines apart left a 2,860-line middle — past the table's cap, and drawn as every line out and + * every line back in. It is split on its unique lines now, and reads as the two edits it is. + */ + test("two edits more than 2,000 lines apart are two edits, not a rewrite", () => { + const lines = Array.from({ length: 3000 }, (_, index) => `field ${index + 1}`) + const edited = [...lines] + edited[39] = "renamed 40" + edited[2899] = "renamed 2900" + + const started = performance.now() + const diff = diffLines(lines.join("\n"), edited.join("\n")) + const elapsed = performance.now() - started + + expect(countChanges(diff)).toEqual({ additions: 2, deletions: 2 }) + expect(numbered(diff.filter((line) => line.kind !== "context"))).toEqual([ + "40||field 40", + "|40|renamed 40", + "2900||field 2900", + "|2900|renamed 2900", + ]) + expect(toHunks(lines.join("\n"), edited.join("\n"))).toHaveLength(2) + expect(elapsed).toBeLessThan(250) + }) + + test("far apart, with lines inserted and removed between: every number still points at its file", () => { + const lines = Array.from({ length: 6000 }, (_, index) => `row ${index + 1}`) + const edited = [...lines] + edited.splice(4500, 2) // rows 4501–4502 removed + edited.splice(100, 0, "new a", "new b", "new c") // three inserted after row 100 + const diff = diffLines(lines.join("\n"), edited.join("\n")) + + expect(countChanges(diff)).toEqual({ additions: 3, deletions: 2 }) + expect(numbered(diff.filter((line) => line.kind !== "context"))).toEqual([ + "|101|new a", + "|102|new b", + "|103|new c", + "4501||row 4501", + "4502||row 4502", + ]) + /** Context either side of the far edit is numbered from each file. */ + const after = diff.find((line) => line.text === "row 4503") + expect([after?.before, after?.after]).toEqual([4503, 4504]) + }) + + test("a stretch with no line unique to hold on to is still a rewrite, bounded", () => { + const repeated = Array.from({ length: 4400 }, (_, index) => (index % 2 ? "}" : "{")) + const before = ["start", ...repeated, "end"].join("\n") + const after = ["START", ...repeated.slice(1), "x", "END"].join("\n") + const lines = diffLines(before, after) + expect(lines.length).toBeGreaterThan(0) + expect(lines.filter((line) => line.kind !== "add").at(-1)?.before).toBe(4402) + }) + test("a rewrite is reported as everything out then everything in, still numbered from the file", () => { const before = Array.from({ length: 2500 }, (_, index) => `old ${index}`).join("\n") const after = Array.from({ length: 2500 }, (_, index) => `new ${index}`).join("\n") diff --git a/packages/review/test/git/binary.test.ts b/packages/review/test/git/binary.test.ts new file mode 100644 index 00000000..1bb32556 --- /dev/null +++ b/packages/review/test/git/binary.test.ts @@ -0,0 +1,128 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test" +import { copyFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { branchChanges, readBlob, runGit, withCounts, worktreeChanges } from "../../src/core/git/sources.ts" + +/** + * Binaries through a real repository: what git hands over, read as bytes. + * + * The bug this guards: images were read as UTF-8 — a PNG under 400 KB became a "+412 −268" diff of + * U+FFFD, and one over it was skipped with "too large", silently absent from the review. + */ + +let repo: string +const fixtures = join(import.meta.dir, "..", "image", "fixtures") + +const git = async (...args: string[]) => { + const result = await runGit(args, repo) + if (!result.ok) throw new Error(`git ${args.join(" ")} failed`) + return result.out +} +const put = (fixture: string, path: string) => copyFileSync(join(fixtures, fixture), join(repo, path)) +/** A PNG's signature and IHDR in front of 600 KB of noise: over the text cap, under the binary one. */ +const bigPng = (seed: number) => { + const noise = new Uint8Array(600_000) + for (let index = 0; index < noise.length; index++) noise[index] = (index * 7919 + seed) & 255 + return Buffer.concat([readFileSync(join(fixtures, "before.png")).subarray(0, 33), noise]) +} + +beforeAll(async () => { + repo = mkdtempSync(join(tmpdir(), "ck-review-binary-")) + await git("init", "--initial-branch=main") + await git("config", "user.email", "test@example.com") + await git("config", "user.name", "Test") + put("before.png", "shot.png") + put("still.gif", "gone.gif") + put("blob.bin", "data.bin") + put("baseline.jpg", "photo.jpg") + writeFileSync(join(repo, "big.png"), bigPng(1)) + writeFileSync(join(repo, "notes.txt"), "one\ntwo\n") + await git("add", "-A") + await git("commit", "-m", "first") + await git("checkout", "-b", "feature") +}) + +afterAll(() => rmSync(repo, { recursive: true, force: true })) + +describe("uncommitted binaries", () => { + test("a changed PNG is a binary change with both headers, not a text diff", async () => { + put("after.png", "shot.png") + const files = withCounts((await worktreeChanges(repo)).files) + const shot = files.find((file) => file.path === "shot.png") + expect(shot?.before).toBe("") + expect(shot?.after).toBe("") + expect([shot?.additions, shot?.deletions]).toEqual([0, 0]) + expect(shot?.binary?.before?.image).toEqual({ format: "png", width: 64, height: 40 }) + expect(shot?.binary?.after?.image).toEqual({ format: "png", width: 64, height: 40 }) + expect(shot?.binary?.before?.size).toBe(278) + expect(shot?.binary?.after?.size).toBe(300) + expect(shot?.binary?.revision).toBe("HEAD") + }) + + test("over the text cap, an image is still in the review — described, not skipped", async () => { + writeFileSync(join(repo, "big.png"), bigPng(2)) + const result = await worktreeChanges(repo) + expect(result.errors.join(" ")).not.toContain("big.png") + const big = result.files.find((file) => file.path === "big.png") + expect(big?.binary?.after?.size).toBe(600_033) + expect(big?.binary?.after?.image).toMatchObject({ format: "png", width: 64, height: 40 }) + }) + + test("added, deleted, a JPEG, and a binary that is no image", async () => { + put("anim.gif", "new.gif") + rmSync(join(repo, "gone.gif")) + put("progressive.jpg", "photo.jpg") + writeFileSync(join(repo, "data.bin"), Buffer.concat([Buffer.from([0, 1, 2]), Buffer.alloc(5000, 9)])) + const files = (await worktreeChanges(repo)).files + const by = (path: string) => files.find((file) => file.path === path) + expect(by("new.gif")?.change).toBe("added") + expect(by("new.gif")?.binary).toEqual({ + after: { size: 594, image: { format: "gif", width: 64, height: 40 } }, + }) + expect(by("gone.gif")?.change).toBe("deleted") + expect(by("gone.gif")?.binary?.after).toBeUndefined() + expect(by("gone.gif")?.binary?.before?.image?.format).toBe("gif") + expect(by("photo.jpg")?.binary?.after?.image).toEqual({ format: "jpeg", width: 64, height: 40 }) + expect(by("data.bin")?.binary?.before).toEqual({ size: 1028 }) + expect(by("data.bin")?.binary?.after).toEqual({ size: 5003 }) + }) + + test("text stays text", async () => { + writeFileSync(join(repo, "notes.txt"), "one\nTWO\n") + const notes = (await worktreeChanges(repo)).files.find((file) => file.path === "notes.txt") + expect(notes?.binary).toBeUndefined() + expect(notes?.after).toBe("one\nTWO\n") + }) +}) + +describe("branch binaries", () => { + test("committed on the branch, read against the fork point", async () => { + await git("add", "-A") + await git("commit", "-m", "second") + const result = await branchChanges(repo, "main") + const shot = result.files.find((file) => file.path === "shot.png") + expect(shot?.binary?.after?.size).toBe(300) + /** The fork point's id, so the old bytes can be read again — by `o`, by the pixel diff. */ + expect(shot?.binary?.revision).toMatch(/^[0-9a-f]{40}$/) + }) +}) + +describe("reading a blob as bytes", () => { + test("whole, and byte for byte what was committed", async () => { + const read = await readBlob(repo, "main", "shot.png") + expect(read?.whole).toBe(true) + expect(Buffer.from(read?.bytes ?? []).equals(readFileSync(join(fixtures, "before.png")))).toBe(true) + }) + + test("past the cap: the head, the true size, and no more", async () => { + const read = await readBlob(repo, "main", "big.png", 4096) + expect(read?.whole).toBe(false) + expect(read?.size).toBe(600_033) + expect(read?.bytes.length).toBeLessThanOrEqual(64 * 1024) + }) + + test("not there: nothing", async () => { + expect(await readBlob(repo, "main", "no-such-file.png")).toBeUndefined() + }) +}) diff --git a/packages/review/test/image/decode.test.ts b/packages/review/test/image/decode.test.ts new file mode 100644 index 00000000..3cd9c457 --- /dev/null +++ b/packages/review/test/image/decode.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test" +import { readdirSync, readFileSync } from "node:fs" +import { join } from "node:path" +import { decodeGif, decodeGifSoon } from "../../src/core/image/gif.ts" +import { decodePng, decodePngSoon } from "../../src/core/image/png.ts" + +/** + * Each fixture beside a `.rgba` reference: for the PNGs computed by the generator from the samples it + * wrote (not by any decoder), for the GIFs by PIL. Every row filter is used, rows cycle through them. + */ +const dir = join(import.meta.dir, "fixtures") +const bytes = (name: string) => new Uint8Array(readFileSync(join(dir, name))) +const references = readdirSync(dir) + .filter((name) => name.endsWith(".rgba")) + .map((name) => name.slice(0, -".rgba".length)) + +/** + * Where two RGBA buffers first differ, for a failure that says something. + * + * A fully transparent pixel has no colour worth comparing: PIL keeps the palette entry under alpha 0, + * this decoder leaves it black, and both draw nothing. + */ +const firstDifference = (a: Uint8Array, b: Uint8Array): number => { + if (a.length !== b.length) return Math.min(a.length, b.length) + for (let index = 0; index < a.length; index += 4) { + if (a[index + 3] !== b[index + 3]) return index + 3 + if (a[index + 3] === 0) continue + for (let channel = 0; channel < 3; channel++) if (a[index + channel] !== b[index + channel]) return index + } + return -1 +} + +describe("PNG decodes byte-exact", () => { + const pngs = references.filter((name) => name.endsWith(".png")) + test("every colour type and depth is covered", () => expect(pngs.length).toBeGreaterThanOrEqual(19)) + + test.each(pngs)("%s", (name) => { + const pixels = decodePng(bytes(name)) + expect([pixels.width, pixels.height]).toEqual([23, 13]) + expect(firstDifference(pixels.data, bytes(`${name}.rgba`))).toBe(-1) + }) + + test("an APNG decodes to its default image", () => { + expect(firstDifference(decodePng(bytes("anim.apng")).data, bytes("rgb8.png.rgba"))).toBe(-1) + }) + + test("in slices, the same pixels, and the event loop gets turns", async () => { + let turns = 0 + const pixels = await decodePngSoon(bytes("rgba16.png"), async () => { + turns++ + }) + expect(firstDifference(pixels.data, bytes("rgba16.png.rgba"))).toBe(-1) + expect(turns).toBeGreaterThanOrEqual(0) + }) + + test("a damaged file says so instead of drawing garbage", () => { + const cut = bytes("rgb8.png").subarray(0, 60) + expect(() => decodePng(cut)).toThrow() + expect(() => decodePng(bytes("still.gif"))).toThrow("not a PNG") + }) +}) + +describe("GIF decodes its first frame byte-exact", () => { + test.each(["still.gif", "interlaced.gif", "anim.gif"])("%s", (name) => { + const pixels = decodeGif(bytes(name)) + expect([pixels.width, pixels.height]).toEqual([64, 40]) + expect(firstDifference(pixels.data, bytes(`${name}.rgba`))).toBe(-1) + }) + + test("and counts the frames", () => { + expect(decodeGif(bytes("anim.gif")).frames).toBe(3) + expect(decodeGif(bytes("still.gif")).frames).toBe(1) + }) + + test("in slices too", async () => { + const pixels = await decodeGifSoon(bytes("interlaced.gif"), async () => {}) + expect(firstDifference(pixels.data, bytes("interlaced.gif.rgba"))).toBe(-1) + }) +}) diff --git a/packages/review/test/image/fixtures/after.gif b/packages/review/test/image/fixtures/after.gif new file mode 100644 index 00000000..1ea3e49d Binary files /dev/null and b/packages/review/test/image/fixtures/after.gif differ diff --git a/packages/review/test/image/fixtures/after.png b/packages/review/test/image/fixtures/after.png new file mode 100644 index 00000000..497c182f Binary files /dev/null and b/packages/review/test/image/fixtures/after.png differ diff --git a/packages/review/test/image/fixtures/alpha.webp b/packages/review/test/image/fixtures/alpha.webp new file mode 100644 index 00000000..00a7f222 Binary files /dev/null and b/packages/review/test/image/fixtures/alpha.webp differ diff --git a/packages/review/test/image/fixtures/anim.apng b/packages/review/test/image/fixtures/anim.apng new file mode 100644 index 00000000..30c58980 Binary files /dev/null and b/packages/review/test/image/fixtures/anim.apng differ diff --git a/packages/review/test/image/fixtures/anim.gif b/packages/review/test/image/fixtures/anim.gif new file mode 100644 index 00000000..de1d540a Binary files /dev/null and b/packages/review/test/image/fixtures/anim.gif differ diff --git a/packages/review/test/image/fixtures/anim.gif.rgba b/packages/review/test/image/fixtures/anim.gif.rgba new file mode 100644 index 00000000..c9e7b0de --- /dev/null +++ b/packages/review/test/image/fixtures/anim.gif.rgba @@ -0,0 +1 @@ +.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ \ No newline at end of file diff --git a/packages/review/test/image/fixtures/anim.webp b/packages/review/test/image/fixtures/anim.webp new file mode 100644 index 00000000..16f1f241 Binary files /dev/null and b/packages/review/test/image/fixtures/anim.webp differ diff --git a/packages/review/test/image/fixtures/baseline.jpg b/packages/review/test/image/fixtures/baseline.jpg new file mode 100644 index 00000000..be5fbaec Binary files /dev/null and b/packages/review/test/image/fixtures/baseline.jpg differ diff --git a/packages/review/test/image/fixtures/before.gif b/packages/review/test/image/fixtures/before.gif new file mode 100644 index 00000000..c07f1647 Binary files /dev/null and b/packages/review/test/image/fixtures/before.gif differ diff --git a/packages/review/test/image/fixtures/before.png b/packages/review/test/image/fixtures/before.png new file mode 100644 index 00000000..b69a2499 Binary files /dev/null and b/packages/review/test/image/fixtures/before.png differ diff --git a/packages/review/test/image/fixtures/blob.bin b/packages/review/test/image/fixtures/blob.bin new file mode 100644 index 00000000..ed9d4961 Binary files /dev/null and b/packages/review/test/image/fixtures/blob.bin differ diff --git a/packages/review/test/image/fixtures/bottomup.bmp b/packages/review/test/image/fixtures/bottomup.bmp new file mode 100644 index 00000000..d4e6c2d6 Binary files /dev/null and b/packages/review/test/image/fixtures/bottomup.bmp differ diff --git a/packages/review/test/image/fixtures/core.bmp b/packages/review/test/image/fixtures/core.bmp new file mode 100644 index 00000000..5460cb08 Binary files /dev/null and b/packages/review/test/image/fixtures/core.bmp differ diff --git a/packages/review/test/image/fixtures/exif.jpg b/packages/review/test/image/fixtures/exif.jpg new file mode 100644 index 00000000..9e6ae07c Binary files /dev/null and b/packages/review/test/image/fixtures/exif.jpg differ diff --git a/packages/review/test/image/fixtures/grey-alpha16.png b/packages/review/test/image/fixtures/grey-alpha16.png new file mode 100644 index 00000000..ce53735a Binary files /dev/null and b/packages/review/test/image/fixtures/grey-alpha16.png differ diff --git a/packages/review/test/image/fixtures/grey-alpha16.png.rgba b/packages/review/test/image/fixtures/grey-alpha16.png.rgba new file mode 100644 index 00000000..a3eb1510 Binary files /dev/null and b/packages/review/test/image/fixtures/grey-alpha16.png.rgba differ diff --git a/packages/review/test/image/fixtures/grey-alpha8.png b/packages/review/test/image/fixtures/grey-alpha8.png new file mode 100644 index 00000000..9d5347dc Binary files /dev/null and b/packages/review/test/image/fixtures/grey-alpha8.png differ diff --git a/packages/review/test/image/fixtures/grey-alpha8.png.rgba b/packages/review/test/image/fixtures/grey-alpha8.png.rgba new file mode 100644 index 00000000..cdc5713e Binary files /dev/null and b/packages/review/test/image/fixtures/grey-alpha8.png.rgba differ diff --git a/packages/review/test/image/fixtures/grey1-adam7.png b/packages/review/test/image/fixtures/grey1-adam7.png new file mode 100644 index 00000000..434bd531 Binary files /dev/null and b/packages/review/test/image/fixtures/grey1-adam7.png differ diff --git a/packages/review/test/image/fixtures/grey1-adam7.png.rgba b/packages/review/test/image/fixtures/grey1-adam7.png.rgba new file mode 100644 index 00000000..15c74375 Binary files /dev/null and b/packages/review/test/image/fixtures/grey1-adam7.png.rgba differ diff --git a/packages/review/test/image/fixtures/grey1.png b/packages/review/test/image/fixtures/grey1.png new file mode 100644 index 00000000..568cca38 Binary files /dev/null and b/packages/review/test/image/fixtures/grey1.png differ diff --git a/packages/review/test/image/fixtures/grey1.png.rgba b/packages/review/test/image/fixtures/grey1.png.rgba new file mode 100644 index 00000000..5c0660c1 Binary files /dev/null and b/packages/review/test/image/fixtures/grey1.png.rgba differ diff --git a/packages/review/test/image/fixtures/grey16.png b/packages/review/test/image/fixtures/grey16.png new file mode 100644 index 00000000..b593582e Binary files /dev/null and b/packages/review/test/image/fixtures/grey16.png differ diff --git a/packages/review/test/image/fixtures/grey16.png.rgba b/packages/review/test/image/fixtures/grey16.png.rgba new file mode 100644 index 00000000..c5200fa5 Binary files /dev/null and b/packages/review/test/image/fixtures/grey16.png.rgba differ diff --git a/packages/review/test/image/fixtures/grey2.png b/packages/review/test/image/fixtures/grey2.png new file mode 100644 index 00000000..9c6cfc20 Binary files /dev/null and b/packages/review/test/image/fixtures/grey2.png differ diff --git a/packages/review/test/image/fixtures/grey2.png.rgba b/packages/review/test/image/fixtures/grey2.png.rgba new file mode 100644 index 00000000..67a94efd Binary files /dev/null and b/packages/review/test/image/fixtures/grey2.png.rgba differ diff --git a/packages/review/test/image/fixtures/grey4.png b/packages/review/test/image/fixtures/grey4.png new file mode 100644 index 00000000..19c151e0 Binary files /dev/null and b/packages/review/test/image/fixtures/grey4.png differ diff --git a/packages/review/test/image/fixtures/grey4.png.rgba b/packages/review/test/image/fixtures/grey4.png.rgba new file mode 100644 index 00000000..0b7e5bc1 Binary files /dev/null and b/packages/review/test/image/fixtures/grey4.png.rgba differ diff --git a/packages/review/test/image/fixtures/grey8-key.png b/packages/review/test/image/fixtures/grey8-key.png new file mode 100644 index 00000000..60caaaed Binary files /dev/null and b/packages/review/test/image/fixtures/grey8-key.png differ diff --git a/packages/review/test/image/fixtures/grey8-key.png.rgba b/packages/review/test/image/fixtures/grey8-key.png.rgba new file mode 100644 index 00000000..7c127807 Binary files /dev/null and b/packages/review/test/image/fixtures/grey8-key.png.rgba differ diff --git a/packages/review/test/image/fixtures/grey8.png b/packages/review/test/image/fixtures/grey8.png new file mode 100644 index 00000000..0e854f2a Binary files /dev/null and b/packages/review/test/image/fixtures/grey8.png differ diff --git a/packages/review/test/image/fixtures/grey8.png.rgba b/packages/review/test/image/fixtures/grey8.png.rgba new file mode 100644 index 00000000..9e111a8e Binary files /dev/null and b/packages/review/test/image/fixtures/grey8.png.rgba differ diff --git a/packages/review/test/image/fixtures/interlaced.gif b/packages/review/test/image/fixtures/interlaced.gif new file mode 100644 index 00000000..12de986a Binary files /dev/null and b/packages/review/test/image/fixtures/interlaced.gif differ diff --git a/packages/review/test/image/fixtures/interlaced.gif.rgba b/packages/review/test/image/fixtures/interlaced.gif.rgba new file mode 100644 index 00000000..fa22feba Binary files /dev/null and b/packages/review/test/image/fixtures/interlaced.gif.rgba differ diff --git a/packages/review/test/image/fixtures/lossless.webp b/packages/review/test/image/fixtures/lossless.webp new file mode 100644 index 00000000..5022f48b Binary files /dev/null and b/packages/review/test/image/fixtures/lossless.webp differ diff --git a/packages/review/test/image/fixtures/lossy.webp b/packages/review/test/image/fixtures/lossy.webp new file mode 100644 index 00000000..cdff431b Binary files /dev/null and b/packages/review/test/image/fixtures/lossy.webp differ diff --git a/packages/review/test/image/fixtures/palette1.png b/packages/review/test/image/fixtures/palette1.png new file mode 100644 index 00000000..82c6312a Binary files /dev/null and b/packages/review/test/image/fixtures/palette1.png differ diff --git a/packages/review/test/image/fixtures/palette1.png.rgba b/packages/review/test/image/fixtures/palette1.png.rgba new file mode 100644 index 00000000..9aa4d543 --- /dev/null +++ b/packages/review/test/image/fixtures/palette1.png.rgba @@ -0,0 +1 @@ +#Æßÿ÷"�ÿ÷"�ÿ÷"�ÿ÷"�ÿ÷"�ÿ÷"�ÿ÷"�ÿ#Æßÿ#Æßÿ÷"�ÿ#Æßÿ÷"�ÿ#Æßÿ#Æßÿ#Æßÿ#Æßÿ÷"�ÿ#Æßÿ÷"�ÿ#Æßÿ÷"�ÿ÷"�ÿ#Æßÿ#Æßÿ÷"�ÿ#Æßÿ#Æßÿ#Æßÿ÷"�ÿ#Æßÿ#Æßÿ#Æßÿ÷"�ÿ#Æßÿ÷"�ÿ#Æßÿ#Æßÿ#Æßÿ÷"�ÿ÷"�ÿ÷"�ÿ÷"�ÿ#Æßÿ#Æßÿ÷"�ÿ#Æßÿ#Æßÿ÷"�ÿ÷"�ÿ÷"�ÿ#Æßÿ#Æßÿ÷"�ÿ÷"�ÿ÷"�ÿ#Æßÿ#Æßÿ#Æßÿ÷"�ÿ#Æßÿ÷"�ÿ#Æßÿ#Æßÿ÷"�ÿ÷"�ÿ#Æßÿ÷"�ÿ÷"�ÿ#Æßÿ÷"�ÿ#Æßÿ÷"�ÿ#Æßÿ#Æßÿ#Æßÿ÷"�ÿ#Æßÿ÷"�ÿ#Æßÿ#Æßÿ÷"�ÿ#Æßÿ÷"�ÿ÷"�ÿ÷"�ÿ÷"�ÿ÷"�ÿ÷"�ÿ÷"�ÿ÷"�ÿ÷"�ÿ#Æßÿ#Æßÿ#Æßÿ÷"�ÿ#Æßÿ#Æßÿ#Æßÿ#Æßÿ÷"�ÿ÷"�ÿ÷"�ÿ÷"�ÿ÷"�ÿ÷"�ÿ#Æßÿ#Æßÿ÷"�ÿ#Æßÿ÷"�ÿ#Æßÿ÷"�ÿ#Æßÿ#Æßÿ÷"�ÿ÷"�ÿ÷"�ÿ÷"�ÿ#Æßÿ÷"�ÿ÷"�ÿ#Æßÿ#Æßÿ#Æßÿ#Æßÿ÷"�ÿ#Æßÿ#Æßÿ#Æßÿ÷"�ÿ#Æßÿ#Æßÿ÷"�ÿ#Æßÿ#Æßÿ÷"�ÿ÷"�ÿ÷"�ÿ÷"�ÿ÷"�ÿ÷"�ÿ÷"�ÿ÷"�ÿ÷"�ÿ÷"�ÿ÷"�ÿ#Æßÿ#Æßÿ÷"�ÿ÷"�ÿ÷"�ÿ#Æßÿ#Æßÿ#Æßÿ#Æßÿ÷"�ÿ÷"�ÿ#Æßÿ#Æßÿ÷"�ÿ÷"�ÿ#Æßÿ#Æßÿ÷"�ÿ#Æßÿ#Æßÿ÷"�ÿ÷"�ÿ#Æßÿ#Æßÿ#Æßÿ#Æßÿ÷"�ÿ÷"�ÿ#Æßÿ÷"�ÿ#Æßÿ÷"�ÿ#Æßÿ÷"�ÿ÷"�ÿ÷"�ÿ#Æßÿ÷"�ÿ#Æßÿ#Æßÿ÷"�ÿ#Æßÿ#Æßÿ#Æßÿ#Æßÿ÷"�ÿ#Æßÿ#Æßÿ#Æßÿ#Æßÿ#Æßÿ÷"�ÿ#Æßÿ#Æßÿ#Æßÿ#Æßÿ÷"�ÿ#Æßÿ÷"�ÿ#Æßÿ÷"�ÿ÷"�ÿ#Æßÿ÷"�ÿ#Æßÿ÷"�ÿ#Æßÿ#Æßÿ#Æßÿ÷"�ÿ#Æßÿ#Æßÿ÷"�ÿ#Æßÿ#Æßÿ#Æßÿ#Æßÿ#Æßÿ#Æßÿ÷"�ÿ#Æßÿ÷"�ÿ÷"�ÿ#Æßÿ÷"�ÿ#Æßÿ÷"�ÿ#Æßÿ#Æßÿ÷"�ÿ#Æßÿ÷"�ÿ÷"�ÿ#Æßÿ#Æßÿ÷"�ÿ÷"�ÿ#Æßÿ#Æßÿ#Æßÿ#Æßÿ÷"�ÿ÷"�ÿ÷"�ÿ÷"�ÿ÷"�ÿ#Æßÿ#Æßÿ÷"�ÿ#Æßÿ#Æßÿ#Æßÿ÷"�ÿ#Æßÿ÷"�ÿ#Æßÿ÷"�ÿ÷"�ÿ÷"�ÿ÷"�ÿ÷"�ÿ÷"�ÿ÷"�ÿ#Æßÿ÷"�ÿ#Æßÿ#Æßÿ#Æßÿ#Æßÿ÷"�ÿ÷"�ÿ#Æßÿ÷"�ÿ#Æßÿ÷"�ÿ÷"�ÿ÷"�ÿ÷"�ÿ÷"�ÿ#Æßÿ÷"�ÿ#Æßÿ#Æßÿ÷"�ÿ÷"�ÿ÷"�ÿ÷"�ÿ÷"�ÿ#Æßÿ#Æßÿ#Æßÿ÷"�ÿ \ No newline at end of file diff --git a/packages/review/test/image/fixtures/palette2.png b/packages/review/test/image/fixtures/palette2.png new file mode 100644 index 00000000..7eac5eec Binary files /dev/null and b/packages/review/test/image/fixtures/palette2.png differ diff --git a/packages/review/test/image/fixtures/palette2.png.rgba b/packages/review/test/image/fixtures/palette2.png.rgba new file mode 100644 index 00000000..3edf0865 --- /dev/null +++ b/packages/review/test/image/fixtures/palette2.png.rgba @@ -0,0 +1 @@ +E ¥ÿ]’;ÿ]’;ÿ̧põ]’;ÿ]’;ÿE ¥ÿ̧põ]’;ÿ]’;ÿE ¥ÿ]’;ÿ̧põ]’;ÿE ¥ÿ̧põ]’;ÿ̧põ]’;ÿ]’;ÿ]’;ÿE ¥ÿ®PÎáE ¥ÿ̧põ̧põ®PÎá®PÎá̧põ®PÎá®PÎá]’;ÿ®PÎá®PÎá̧põE ¥ÿ]’;ÿ̧põ̧põ®PÎá̧põ®PÎáE ¥ÿ®PÎá̧põE ¥ÿE ¥ÿE ¥ÿ̧põ®PÎá®PÎá]’;ÿ̧põ̧põ̧põ®PÎá̧põ̧põ̧põ]’;ÿ®PÎá̧põE ¥ÿ®PÎá̧põ®PÎá®PÎá]’;ÿ®PÎá]’;ÿ̧põE ¥ÿ]’;ÿE ¥ÿE ¥ÿ]’;ÿE ¥ÿ®PÎáE ¥ÿ̧põ®PÎá®PÎá®PÎá®PÎá]’;ÿ̧põE ¥ÿ̧põE ¥ÿ̧põE ¥ÿE ¥ÿ̧põ]’;ÿE ¥ÿ®PÎá̧põ®PÎáE ¥ÿ®PÎáE ¥ÿ®PÎá̧põ̧põ]’;ÿE ¥ÿ®PÎáE ¥ÿE ¥ÿ]’;ÿE ¥ÿ®PÎá]’;ÿE ¥ÿ®PÎá]’;ÿ®PÎá̧põ]’;ÿ]’;ÿ]’;ÿ]’;ÿ®PÎáE ¥ÿ]’;ÿ̧põE ¥ÿ̧põ®PÎáE ¥ÿ̧põE ¥ÿ]’;ÿE ¥ÿ̧põ̧põ®PÎá̧põE ¥ÿ]’;ÿ̧põE ¥ÿE ¥ÿ]’;ÿ̧põE ¥ÿ]’;ÿ̧põ̧põE ¥ÿ]’;ÿ®PÎá̧põ]’;ÿ]’;ÿ]’;ÿ®PÎáE ¥ÿ]’;ÿE ¥ÿ]’;ÿE ¥ÿE ¥ÿ̧põ̧põ®PÎá]’;ÿ̧põ®PÎá]’;ÿE ¥ÿ®PÎá]’;ÿ̧põE ¥ÿE ¥ÿ̧põ̧põ̧põ̧põ®PÎáE ¥ÿE ¥ÿ̧põ]’;ÿ]’;ÿ®PÎá®PÎá̧põ®PÎá]’;ÿ]’;ÿ®PÎá®PÎá®PÎáE ¥ÿ®PÎá]’;ÿ]’;ÿ̧põ®PÎá®PÎá]’;ÿ̧põE ¥ÿE ¥ÿ®PÎá̧põE ¥ÿE ¥ÿ]’;ÿ̧põE ¥ÿ®PÎáE ¥ÿE ¥ÿ®PÎá]’;ÿ®PÎá̧põ]’;ÿE ¥ÿ®PÎá®PÎáE ¥ÿE ¥ÿE ¥ÿ]’;ÿ]’;ÿ̧põE ¥ÿ]’;ÿ®PÎá]’;ÿ̧põ]’;ÿE ¥ÿ̧põ®PÎáE ¥ÿ]’;ÿ]’;ÿE ¥ÿ®PÎá]’;ÿ̧põ]’;ÿ®PÎáE ¥ÿ̧põ]’;ÿE ¥ÿ]’;ÿ]’;ÿE ¥ÿ®PÎá®PÎá]’;ÿ®PÎá®PÎá]’;ÿ]’;ÿ®PÎá̧põE ¥ÿ̧põ®PÎá̧põ®PÎá̧põ®PÎá̧põ]’;ÿ̧põ®PÎá̧põ]’;ÿ̧põE ¥ÿ̧põ]’;ÿ]’;ÿ̧põE ¥ÿ]’;ÿ®PÎá̧põ®PÎá®PÎá®PÎá̧põ®PÎá̧põ]’;ÿ]’;ÿE ¥ÿE ¥ÿ̧põ̧põ \ No newline at end of file diff --git a/packages/review/test/image/fixtures/palette4-adam7.png b/packages/review/test/image/fixtures/palette4-adam7.png new file mode 100644 index 00000000..b0d1b0aa Binary files /dev/null and b/packages/review/test/image/fixtures/palette4-adam7.png differ diff --git a/packages/review/test/image/fixtures/palette4-adam7.png.rgba b/packages/review/test/image/fixtures/palette4-adam7.png.rgba new file mode 100644 index 00000000..0e7cf04d Binary files /dev/null and b/packages/review/test/image/fixtures/palette4-adam7.png.rgba differ diff --git a/packages/review/test/image/fixtures/palette4.png b/packages/review/test/image/fixtures/palette4.png new file mode 100644 index 00000000..39d9f37a Binary files /dev/null and b/packages/review/test/image/fixtures/palette4.png differ diff --git a/packages/review/test/image/fixtures/palette4.png.rgba b/packages/review/test/image/fixtures/palette4.png.rgba new file mode 100644 index 00000000..eb2b9e86 --- /dev/null +++ b/packages/review/test/image/fixtures/palette4.png.rgba @@ -0,0 +1 @@ +ML8ÿKÛºÿôÆÛÿML8ÿKÛºÿ·½‚ÿHSPÿ ÿ?Qžÿ ÿíÿ ÿíÿ ÿ¶{/ÿ1þÓÿ ÿôÆÛÿML8ÿ¾»Dÿ·½‚ÿ1þÓÿ¶{/ÿíÿÚÅRÿ¶{/ÿ·½‚ÿKÛºÿ«óÿÚÅRÿ·½‚ÿKÛºÿ1þÓÿKÛºÿíÿKÛºÿÚÅRÿHSPÿy{ÿ·½‚ÿML8ÿHSPÿ¶{/ÿ?QžÿôÆÛÿ ÿML8ÿôÆÛÿÙ8ŠÿML8ÿy{ÿÚÅRÿHSPÿ¶{/ÿ ÿ1þÓÿxØGÿíÿML8ÿ?QžÿíÿML8ÿKÛºÿy{ÿ¶{/ÿ¶{/ÿ ÿ¾»Dÿ ÿ ÿKÛºÿHSPÿ?QžÿÚÅRÿy{ÿ«óÿ?QžÿHSPÿÚÅRÿ«óÿôÆÛÿ1þÓÿ ÿ«óÿ¾»Dÿ¾»Dÿ ÿ«óÿ¶{/ÿ¾»DÿÙ8Šÿ·½‚ÿKÛºÿ ÿÚÅRÿíÿML8ÿKÛºÿML8ÿ?QžÿxØGÿ·½‚ÿxØGÿ¶{/ÿ?Qžÿ·½‚ÿôÆÛÿôÆÛÿ·½‚ÿÙ8Šÿ¾»Dÿ ÿ¶{/ÿML8ÿ¾»Dÿ?QžÿÚÅRÿÚÅRÿy{ÿ ÿML8ÿÙ8Šÿ¾»DÿKÛºÿxØGÿ ÿ1þÓÿíÿôÆÛÿHSPÿ1þÓÿHSPÿ«óÿíÿ¾»DÿxØGÿxØGÿML8ÿ?Qžÿ¾»DÿÙ8Šÿ¶{/ÿ¶{/ÿ·½‚ÿKÛºÿ1þÓÿ?QžÿÚÅRÿôÆÛÿxØGÿ«óÿML8ÿy{ÿ1þÓÿ1þÓÿy{ÿKÛºÿ?Qžÿ«óÿíÿHSPÿôÆÛÿxØGÿ1þÓÿôÆÛÿxØGÿ ÿxØGÿHSPÿML8ÿ¶{/ÿ?QžÿxØGÿÙ8Šÿ?QžÿKÛºÿíÿxØGÿ¶{/ÿKÛºÿ¶{/ÿ1þÓÿML8ÿíÿíÿ«óÿÚÅRÿ·½‚ÿ«óÿÚÅRÿxØGÿHSPÿ?Qžÿ«óÿKÛºÿ¾»Dÿíÿ¶{/ÿôÆÛÿ1þÓÿ¶{/ÿ¾»Dÿíÿ¾»Dÿ·½‚ÿ·½‚ÿxØGÿ?QžÿKÛºÿML8ÿíÿKÛºÿ·½‚ÿKÛºÿ¶{/ÿ¶{/ÿHSPÿ¾»DÿxØGÿÚÅRÿÚÅRÿ?QžÿôÆÛÿy{ÿ?QžÿÙ8Šÿy{ÿy{ÿ·½‚ÿy{ÿÙ8ŠÿxØGÿÚÅRÿíÿíÿ¾»DÿKÛºÿHSPÿHSPÿÙ8ŠÿML8ÿ1þÓÿÚÅRÿHSPÿML8ÿML8ÿ«óÿíÿÚÅRÿKÛºÿíÿ ÿôÆÛÿÚÅRÿML8ÿHSPÿôÆÛÿ1þÓÿ¶{/ÿxØGÿÙ8ŠÿML8ÿÚÅRÿíÿy{ÿML8ÿ¶{/ÿML8ÿíÿML8ÿy{ÿKÛºÿ1þÓÿ?Qžÿ«óÿ«óÿôÆÛÿÙ8ŠÿKÛºÿy{ÿôÆÛÿML8ÿ·½‚ÿ«óÿy{ÿ·½‚ÿÚÅRÿ?QžÿôÆÛÿ ÿ1þÓÿôÆÛÿHSPÿÙ8Šÿ ÿ ÿ ÿ·½‚ÿxØGÿ \ No newline at end of file diff --git a/packages/review/test/image/fixtures/palette8.png b/packages/review/test/image/fixtures/palette8.png new file mode 100644 index 00000000..b1fb1c62 Binary files /dev/null and b/packages/review/test/image/fixtures/palette8.png differ diff --git a/packages/review/test/image/fixtures/palette8.png.rgba b/packages/review/test/image/fixtures/palette8.png.rgba new file mode 100644 index 00000000..4aa39608 Binary files /dev/null and b/packages/review/test/image/fixtures/palette8.png.rgba differ diff --git a/packages/review/test/image/fixtures/progressive.jpg b/packages/review/test/image/fixtures/progressive.jpg new file mode 100644 index 00000000..1b01b79c Binary files /dev/null and b/packages/review/test/image/fixtures/progressive.jpg differ diff --git a/packages/review/test/image/fixtures/resized.png b/packages/review/test/image/fixtures/resized.png new file mode 100644 index 00000000..24a7eb3f Binary files /dev/null and b/packages/review/test/image/fixtures/resized.png differ diff --git a/packages/review/test/image/fixtures/rgb16.png b/packages/review/test/image/fixtures/rgb16.png new file mode 100644 index 00000000..0401651c Binary files /dev/null and b/packages/review/test/image/fixtures/rgb16.png differ diff --git a/packages/review/test/image/fixtures/rgb16.png.rgba b/packages/review/test/image/fixtures/rgb16.png.rgba new file mode 100644 index 00000000..6d46dd84 Binary files /dev/null and b/packages/review/test/image/fixtures/rgb16.png.rgba differ diff --git a/packages/review/test/image/fixtures/rgb8-adam7.png b/packages/review/test/image/fixtures/rgb8-adam7.png new file mode 100644 index 00000000..b56a21ad Binary files /dev/null and b/packages/review/test/image/fixtures/rgb8-adam7.png differ diff --git a/packages/review/test/image/fixtures/rgb8-adam7.png.rgba b/packages/review/test/image/fixtures/rgb8-adam7.png.rgba new file mode 100644 index 00000000..25a97aa6 Binary files /dev/null and b/packages/review/test/image/fixtures/rgb8-adam7.png.rgba differ diff --git a/packages/review/test/image/fixtures/rgb8-key.png b/packages/review/test/image/fixtures/rgb8-key.png new file mode 100644 index 00000000..0146b75b Binary files /dev/null and b/packages/review/test/image/fixtures/rgb8-key.png differ diff --git a/packages/review/test/image/fixtures/rgb8-key.png.rgba b/packages/review/test/image/fixtures/rgb8-key.png.rgba new file mode 100644 index 00000000..f7571977 Binary files /dev/null and b/packages/review/test/image/fixtures/rgb8-key.png.rgba differ diff --git a/packages/review/test/image/fixtures/rgb8.png b/packages/review/test/image/fixtures/rgb8.png new file mode 100644 index 00000000..c8f9b62e Binary files /dev/null and b/packages/review/test/image/fixtures/rgb8.png differ diff --git a/packages/review/test/image/fixtures/rgb8.png.rgba b/packages/review/test/image/fixtures/rgb8.png.rgba new file mode 100644 index 00000000..ab713990 Binary files /dev/null and b/packages/review/test/image/fixtures/rgb8.png.rgba differ diff --git a/packages/review/test/image/fixtures/rgba16.png b/packages/review/test/image/fixtures/rgba16.png new file mode 100644 index 00000000..e2f68446 Binary files /dev/null and b/packages/review/test/image/fixtures/rgba16.png differ diff --git a/packages/review/test/image/fixtures/rgba16.png.rgba b/packages/review/test/image/fixtures/rgba16.png.rgba new file mode 100644 index 00000000..ef361fac Binary files /dev/null and b/packages/review/test/image/fixtures/rgba16.png.rgba differ diff --git a/packages/review/test/image/fixtures/rgba8.png b/packages/review/test/image/fixtures/rgba8.png new file mode 100644 index 00000000..4c24d4c5 Binary files /dev/null and b/packages/review/test/image/fixtures/rgba8.png differ diff --git a/packages/review/test/image/fixtures/rgba8.png.rgba b/packages/review/test/image/fixtures/rgba8.png.rgba new file mode 100644 index 00000000..97372b09 Binary files /dev/null and b/packages/review/test/image/fixtures/rgba8.png.rgba differ diff --git a/packages/review/test/image/fixtures/still.gif b/packages/review/test/image/fixtures/still.gif new file mode 100644 index 00000000..df2201b5 Binary files /dev/null and b/packages/review/test/image/fixtures/still.gif differ diff --git a/packages/review/test/image/fixtures/still.gif.rgba b/packages/review/test/image/fixtures/still.gif.rgba new file mode 100644 index 00000000..c9e7b0de --- /dev/null +++ b/packages/review/test/image/fixtures/still.gif.rgba @@ -0,0 +1 @@ +.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ \ No newline at end of file diff --git a/packages/review/test/image/fixtures/topdown.bmp b/packages/review/test/image/fixtures/topdown.bmp new file mode 100644 index 00000000..a6988771 Binary files /dev/null and b/packages/review/test/image/fixtures/topdown.bmp differ diff --git a/packages/review/test/image/pixels.test.ts b/packages/review/test/image/pixels.test.ts new file mode 100644 index 00000000..9f6984e6 --- /dev/null +++ b/packages/review/test/image/pixels.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { join } from "node:path" +import { percentText, pixelText, sizeText, summaryText } from "../../src/core/image/describe.ts" +import { decodeGif } from "../../src/core/image/gif.ts" +import { analyse, createLooks, MAX_PIXELS } from "../../src/core/image/looks.ts" +import { comparePixels, FADE, mix, shrink, toCells } from "../../src/core/image/pixels.ts" +import { decodePng } from "../../src/core/image/png.ts" +import { finish } from "../../src/core/image/steps.ts" +import type { BinaryChange, FileChange } from "../../src/core/model/review.ts" + +const bytes = (name: string) => new Uint8Array(readFileSync(join(import.meta.dir, "fixtures", name))) + +/** `after.png` is `before.png` with one rectangle repainted: x 40–49, y 4–9 (fixtures generator). */ +const RECTANGLE = { x: 40, y: 4, width: 10, height: 6 } + +describe("pixel diff", () => { + test("finds exactly the rectangle that changed", () => { + const diff = finish(comparePixels(decodePng(bytes("before.png")), decodePng(bytes("after.png")), 32, 20)) + expect(diff.total).toBe(64 * 40) + expect(diff.changed).toBe(60) + expect(diff.box).toEqual(RECTANGLE) + expect(pixelText(diff)).toBe("2.34% of pixels changed · 10×6 at 40,4") + }) + + test("marks the change on the mask, and only there", () => { + const diff = finish(comparePixels(decodePng(bytes("before.png")), decodePng(bytes("after.png")), 32, 20)) + const marked: number[] = [] + diff.mask?.data.forEach((on, index) => { + if (on) marked.push(index) + }) + /** Mask cells are 2×2 pixels: x 20–24, y 2–4. */ + expect(marked.length).toBe(15) + for (const index of marked) { + expect(index % 32).toBeGreaterThanOrEqual(20) + expect(index % 32).toBeLessThanOrEqual(24) + expect(Math.floor(index / 32)).toBeGreaterThanOrEqual(2) + expect(Math.floor(index / 32)).toBeLessThanOrEqual(4) + } + }) + + test("works on GIF too", () => { + const diff = finish(comparePixels(decodeGif(bytes("before.gif")), decodeGif(bytes("after.gif")), 32, 20)) + expect(diff.box).toEqual(RECTANGLE) + }) + + test("identical pixels: nothing changed, no box", () => { + const pixels = decodePng(bytes("before.png")) + const diff = finish(comparePixels(pixels, pixels, 8, 8)) + expect(diff).toEqual({ changed: 0, total: 2560 }) + expect(pixelText(diff)).toBe("Same pixels — only the encoding or metadata changed") + }) + + test("refuses two sizes rather than report a percentage that lies", () => { + expect(() => + finish(comparePixels(decodePng(bytes("before.png")), decodePng(bytes("resized.png")), 8, 8)), + ).toThrow() + }) +}) + +describe("cells", () => { + test("a picture fits its cells, two pixels to a cell, aspect kept", () => { + const thumb = finish(shrink(decodePng(bytes("before.png")))) + expect([thumb.width, thumb.height]).toEqual([64, 40]) + const cells = toCells(thumb, 32, 8, 0x1e1e2e) + expect([cells.cols, cells.rows]).toEqual([26, 8]) + expect(cells.top.length).toBe(26 * 8) + }) + + test("transparent pixels show the pane's colour", () => { + const thumb = finish(shrink(decodePng(bytes("rgba8.png")))) + const clear = { width: 1, height: 2, data: new Uint8Array([255, 0, 0, 0, 0, 255, 0, 0]) } + expect(toCells(clear, 1, 1, 0x123456).top[0]).toBe(0x123456) + expect(thumb.width).toBe(23) + }) + + test("with a mask, what did not change fades toward the pane", () => { + const before = decodePng(bytes("before.png")) + const after = decodePng(bytes("after.png")) + const thumb = finish(shrink(after)) + const diff = finish(comparePixels(before, after, thumb.width, thumb.height)) + const plain = toCells(thumb, 64, 20, 0) + const lit = toCells(thumb, 64, 20, 0, diff.mask) + /** Top-left is untouched: faded. Inside the rectangle: as it was. */ + expect(lit.top[0]).toBe(mix(plain.top[0] as number, 0, FADE)) + const inside = 3 * 64 + 44 + expect(lit.top[inside]).toBe(plain.top[inside] as number) + }) +}) + +describe("in words", () => { + const png = (width: number, height: number, size: number) => ({ + size, + image: { format: "png" as const, width, height }, + }) + const cases: [BinaryChange, string][] = [ + [ + { before: png(2880, 1800, 826_548), after: png(2880, 1800, 807_358) }, + "PNG 2880×1800 · 807 KB → 788 KB", + ], + [ + { before: png(2880, 1800, 826_548), after: png(1440, 900, 220_000) }, + "PNG 2880×1800 → 1440×900 · 807 KB → 215 KB", + ], + [{ after: png(96, 96, 12_904) }, "PNG 96×96 · 12.6 KB"], + [{ before: png(96, 96, 900) }, "PNG 96×96 · 900 B"], + [ + { + before: png(640, 400, 91_165), + after: { size: 40_000, image: { format: "jpeg", width: 640, height: 400 } }, + }, + "PNG 640×400 → JPEG 640×400 · 89.0 KB → 39.1 KB", + ], + [{ before: { size: 12_595 }, after: { size: 14_336 } }, "binary · 12.3 KB → 14.0 KB"], + [{ after: { size: 13_849_151 } }, "binary · 13.2 MB"], + ] + test.each(cases)("%#: %s", (binary, said) => expect(summaryText(binary)).toBe(said)) + + test("sizes", () => { + expect([0, 1023, 1024, 102_400, 1_048_576 * 13.5].map(sizeText)).toEqual([ + "0 B", + "1023 B", + "1.0 KB", + "100 KB", + "13.5 MB", + ]) + }) + + test("percentages never round to a lie", () => { + expect(percentText(1, 5_184_000)).toBe("<0.01%") + expect(percentText(5_183_999, 5_184_000)).toBe(">99.99%") + expect(percentText(10, 10)).toBe("100%") + expect(percentText(132_710, 5_184_000)).toBe("2.56%") + expect(percentText(1_000_000, 5_184_000)).toBe("19.3%") + }) +}) + +describe("looking at an image change", () => { + const change = (before?: string, after?: string): FileChange => ({ + path: "shot.png", + before: "", + after: "", + additions: 0, + deletions: 0, + binary: { + ...(before + ? { before: { size: 1, image: { format: "png", width: 64, height: 40 } }, revision: "HEAD" } + : {}), + ...(after ? { after: { size: 1, image: { format: "png", width: 64, height: 40 } } } : {}), + }, + }) + const reader = (names: { before?: string; after?: string }) => { + const reads: string[] = [] + return { + reads, + read: async (_file: FileChange, side: "before" | "after") => { + reads.push(side) + const name = names[side] + return name ? bytes(name) : undefined + }, + } + } + const now = async () => {} + + test("both sides: thumbs and the pixel diff", async () => { + const { read } = reader({ before: "before.png", after: "after.png" }) + const look = await analyse(change("b", "a"), read, now) + expect(look.diff?.box).toEqual(RECTANGLE) + expect(look.before?.width).toBe(64) + expect(look.after?.width).toBe(64) + expect(look.problem).toBeUndefined() + }) + + test("decoded once: the same bytes again come from the cache", async () => { + const { read } = reader({ before: "before.png", after: "after.png" }) + const first = await analyse(change("b", "a"), read, now) + const again = await analyse(change("b", "a"), read, now) + expect(again.after).toBe(first.after as NonNullable) + expect(again.diff).toBe(first.diff as NonNullable) + }) + + test("one side: a thumb and no diff", async () => { + const { read } = reader({ after: "after.png" }) + const look = await analyse(change(undefined, "a"), read, now) + expect(look.after).toBeDefined() + expect(look.before).toBeUndefined() + expect(look.diff).toBeUndefined() + }) + + test("too large to decode: said, and nothing read", async () => { + const { read, reads } = reader({ before: "before.png", after: "after.png" }) + const huge = change("b", "a") + const side = { size: 1, image: { format: "png" as const, width: 5000, height: 5000 } } + huge.binary = { before: side, after: side, revision: "HEAD" } + expect(5000 * 5000).toBeGreaterThan(MAX_PIXELS) + const look = await analyse(huge, read, now) + expect(look.problem).toContain("Too large to preview") + expect(reads).toEqual([]) + }) + + test("a damaged file: said, not thrown", async () => { + const look = await analyse(change(undefined, "a"), async () => bytes("before.png").subarray(0, 50), now) + expect(look.problem).toContain("could not") + expect(look.after).toBeUndefined() + }) + + test("the pane's looks: pending at once, done later, a paint asked for each time", async () => { + let paints = 0 + const { read } = reader({ before: "before.png", after: "after.png" }) + const looks = createLooks(read, () => paints++, now) + looks.sync([change("b", "a"), { path: "x.ts", before: "a", after: "b", additions: 1, deletions: 1 }]) + expect(looks.current().get("shot.png")?.pending).toBe(true) + expect(looks.current().has("x.ts")).toBe(false) + for (let turn = 0; turn < 50 && looks.current().get("shot.png")?.pending; turn++) await Bun.sleep(1) + expect(looks.current().get("shot.png")?.diff?.changed).toBe(60) + expect(paints).toBeGreaterThanOrEqual(2) + }) + + test("a later sync wins over one still working", async () => { + const { read } = reader({ before: "before.png", after: "after.png" }) + const looks = createLooks(read, () => {}, now) + looks.sync([change("b", "a")]) + looks.sync([]) + await Bun.sleep(20) + expect(looks.current().size).toBe(0) + }) +}) diff --git a/packages/review/test/image/sniff.test.ts b/packages/review/test/image/sniff.test.ts new file mode 100644 index 00000000..010b9d3b --- /dev/null +++ b/packages/review/test/image/sniff.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { join } from "node:path" +import { + decodable, + formatName, + looksBinary, + readBmp, + readGif, + readJpeg, + readPng, + readWebp, + sniff, +} from "../../src/core/image/sniff.ts" + +/** + * Fixtures are small files written by PIL and by hand (every PNG colour type and depth from a raw + * writer, so the reference RGBA is computed independently of any decoder); sizes checked with `sips`. + */ +const fixture = (name: string) => new Uint8Array(readFileSync(join(import.meta.dir, "fixtures", name))) + +describe("binary, by git's rule", () => { + test("a NUL in the first 8000 bytes is binary", () => { + expect(looksBinary(fixture("blob.bin"))).toBe(true) + expect(looksBinary(fixture("rgb8.png"))).toBe(true) + }) + + test("text, however odd, is not", () => { + expect(looksBinary(new TextEncoder().encode("héllo — ✓ \t\r\n\u001b[31m"))).toBe(false) + expect(looksBinary(new Uint8Array(0))).toBe(false) + }) + + test("a NUL past 8000 bytes does not count, as in git", () => { + const late = new Uint8Array(9000).fill(97) + late[8500] = 0 + expect(looksBinary(late)).toBe(false) + late[7999] = 0 + expect(looksBinary(late)).toBe(true) + }) +}) + +describe("PNG", () => { + test.each([ + "grey1.png", + "grey16.png", + "rgb8.png", + "rgb16.png", + "palette4.png", + "rgba16.png", + "rgb8-adam7.png", + ])("%s: size from IHDR", (name) => { + expect(readPng(fixture(name))).toEqual({ format: "png", width: 23, height: 13 }) + }) + + test("an acTL before the image data is an APNG", () => { + const info = readPng(fixture("anim.apng")) + expect(info).toEqual({ format: "png", width: 23, height: 13, animated: true }) + expect(formatName(info as NonNullable)).toBe("APNG") + }) + + test("a scene", () => expect(readPng(fixture("before.png"))).toMatchObject({ width: 64, height: 40 })) + test("not a PNG", () => expect(readPng(fixture("still.gif"))).toBeUndefined()) +}) + +describe("GIF", () => { + test.each(["still.gif", "interlaced.gif", "anim.gif"])("%s: the logical screen", (name) => { + expect(readGif(fixture(name))).toEqual({ format: "gif", width: 64, height: 40 }) + }) +}) + +describe("WebP", () => { + test("lossy (VP8)", () => + expect(readWebp(fixture("lossy.webp"))).toEqual({ format: "webp", width: 64, height: 40 })) + test("lossless (VP8L)", () => + expect(readWebp(fixture("lossless.webp"))).toEqual({ format: "webp", width: 64, height: 40 })) + test("extended (VP8X), with alpha", () => + expect(readWebp(fixture("alpha.webp"))).toEqual({ format: "webp", width: 64, height: 40 })) + test("extended, animated", () => + expect(readWebp(fixture("anim.webp"))).toEqual({ format: "webp", width: 64, height: 40, animated: true })) +}) + +describe("JPEG", () => { + test("baseline", () => + expect(readJpeg(fixture("baseline.jpg"))).toEqual({ format: "jpeg", width: 64, height: 40 })) + test("progressive", () => + expect(readJpeg(fixture("progressive.jpg"))).toEqual({ format: "jpeg", width: 64, height: 40 })) + test("behind a 30 KB EXIF block", () => { + const bytes = fixture("exif.jpg") + expect(bytes.length).toBeGreaterThan(30_000) + expect(readJpeg(bytes)).toEqual({ format: "jpeg", width: 64, height: 40 }) + }) + test("cut off before its size: nothing, not a guess", () => { + expect(readJpeg(fixture("exif.jpg").subarray(0, 4096))).toBeUndefined() + }) +}) + +describe("BMP", () => { + test("bottom-up", () => + expect(readBmp(fixture("bottomup.bmp"))).toEqual({ format: "bmp", width: 64, height: 40 })) + test("top-down: a negative height is still a height", () => + expect(readBmp(fixture("topdown.bmp"))).toEqual({ format: "bmp", width: 64, height: 40 })) + test("the OS/2 core header's 16-bit sizes", () => + expect(readBmp(fixture("core.bmp"))).toEqual({ format: "bmp", width: 7, height: 5 })) +}) + +describe("sniff", () => { + test("names each format by its bytes, not its extension", () => { + const names = ["rgb8.png", "anim.apng", "still.gif", "baseline.jpg", "lossy.webp", "bottomup.bmp"].map( + (name) => { + const info = sniff(fixture(name)) + return info ? formatName(info) : "?" + }, + ) + expect(names).toEqual(["PNG", "APNG", "GIF", "JPEG", "WebP", "BMP"]) + }) + + test("a binary that is not an image is not one", () => { + expect(sniff(fixture("blob.bin"))).toBeUndefined() + expect(sniff(new Uint8Array(4))).toBeUndefined() + }) + + test("only PNG and GIF decode; the rest are described", () => { + expect( + ["rgb8.png", "still.gif", "baseline.jpg", "lossy.webp", "bottomup.bmp"].map((name) => + decodable(sniff(fixture(name))), + ), + ).toEqual([true, true, false, false, false]) + }) +}) diff --git a/packages/review/test/image/viewer.test.ts b/packages/review/test/image/viewer.test.ts new file mode 100644 index 00000000..f1038e2b --- /dev/null +++ b/packages/review/test/image/viewer.test.ts @@ -0,0 +1,166 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, utimesSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import type { FileChange } from "../../src/core/model/review.ts" +import { createViewer, type Launched, oldName, type ViewerDeps } from "../../src/core/viewer.ts" + +/** + * `o`, with the system stubbed out: nothing here ever launches a real viewer. The opener is "found" + * by a fake `which`, "launched" by a fake `spawn` that records what it was asked to open. + */ + +const roots: string[] = [] +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +const OLD = new Uint8Array([137, 80, 78, 71, 1, 2, 3]) + +const setup = (overrides: Partial = {}) => { + const tmp = mkdtempSync(join(tmpdir(), "ck-viewer-test-")) + roots.push(tmp) + const launched: { command: string; args: string[]; env: Record }[] = [] + const listeners: ((error: Error) => void)[] = [] + const reported: string[] = [] + const looked: { command: string; PATH: string }[] = [] + const deps: ViewerDeps = { + platform: "darwin", + env: { PATH: "/shim:/usr/bin", HOME: "/home/x" }, + which: (command, options) => { + looked.push({ command, PATH: options.PATH }) + return `/shim/${command}` + }, + /** No `/usr/bin/open` on this fake machine: the opener comes off the PATH. */ + exists: () => false, + spawn: (command, args, env): Launched => { + launched.push({ command, args, env }) + return { + on: (_event, listener) => listeners.push(listener), + unref: () => {}, + } + }, + readOld: async () => OLD, + tmp, + report: (problem) => reported.push(problem), + ...overrides, + } + return { viewer: createViewer(deps), launched, listeners, reported, looked, tmp } +} + +const file = (binary: FileChange["binary"], path = "media/shot.png"): FileChange => ({ + path, + before: "", + after: "", + additions: 0, + deletions: 0, + ...(binary ? { binary } : {}), +}) +const both = file({ + before: { size: 7 }, + after: { size: 9 }, + revision: "0123456789abcdef0123456789abcdef01234567", +}) + +describe("o opens both versions", () => { + test("the old one from git into a named temp file, the new one where it is", async () => { + const { viewer, launched, tmp } = setup() + const result = await viewer.open("/repo", both) + expect(result).toEqual({ opened: 2 }) + expect(launched.map((each) => each.command)).toEqual(["/shim/open", "/shim/open"]) + const [old, now] = launched.map((each) => each.args.at(-1) as string) + expect(old?.startsWith(tmp)).toBe(true) + expect(old?.endsWith("shot@0123456.png")).toBe(true) + expect(new Uint8Array(readFileSync(old as string))).toEqual(OLD) + expect(now).toBe("/repo/media/shot.png") + }) + + test("the opener is found on the PATH the spawn is given, not the parent's", async () => { + const { viewer, looked, launched } = setup() + await viewer.open("/repo", both) + expect(looked[0]).toEqual({ command: "open", PATH: "/shim:/usr/bin" }) + expect(launched[0]?.env.PATH).toBe("/shim:/usr/bin") + }) + + test("an added file has only its new side, a deleted one only its old", async () => { + const added = setup() + await added.viewer.open("/repo", file({ after: { size: 9 } })) + expect(added.launched.map((each) => each.args.at(-1))).toEqual(["/repo/media/shot.png"]) + const deleted = setup() + await deleted.viewer.open("/repo", file({ before: { size: 7 }, revision: "HEAD" })) + expect(deleted.launched.map((each) => each.args.at(-1)?.endsWith("shot@HEAD.png"))).toEqual([true]) + }) + + test("no opener on this system: said, nothing launched", async () => { + const { viewer, launched } = setup({ platform: "linux", which: () => null }) + const result = await viewer.open("/repo", both) + expect(result.opened).toBe(0) + expect(result.problem).toContain("xdg-open is not on PATH") + expect(launched).toEqual([]) + }) + + test("the old side unreadable: said, nothing launched", async () => { + const { viewer, launched } = setup({ readOld: async () => undefined }) + const result = await viewer.open("/repo", both) + expect(result.problem).toContain("Could not read the old shot.png") + expect(launched).toEqual([]) + }) + + test("an opener that fails to start is reported, not swallowed", async () => { + const { viewer, listeners, reported } = setup() + await viewer.open("/repo", both) + expect(listeners).toHaveLength(2) + listeners[1]?.(new Error("spawn EACCES")) + expect(reported).toEqual(["Could not open shot.png: spawn EACCES"]) + }) + + test("closing the pane removes its temp copies", async () => { + const { viewer, launched } = setup() + await viewer.open("/repo", both) + const old = launched[0]?.args.at(-1) as string + expect(existsSync(old)).toBe(true) + await viewer.clean() + expect(existsSync(old)).toBe(false) + }) + + test("a crashed pane's leftovers are swept on the next open — only old ones", async () => { + const { viewer, tmp } = setup() + const stale = join(tmp, "cockpit-review-stale") + const live = join(tmp, "cockpit-review-live") + mkdirSync(stale) + mkdirSync(live) + const day = (Date.now() - 2 * 24 * 60 * 60 * 1000) / 1000 + utimesSync(stale, day, day) + await viewer.open("/repo", both) + const left = readdirSync(tmp) + expect(left).not.toContain("cockpit-review-stale") + expect(left).toContain("cockpit-review-live") + }) +}) + +describe("the opener for each platform (client's openerFor has the rest)", () => { + test("open, xdg-open, start", async () => { + const opened = async (platform: NodeJS.Platform) => { + const { viewer, launched } = setup({ platform }) + await viewer.open("/repo", file({ after: { size: 1 } })) + return launched.map(({ command, args }) => [command, ...args]) + } + expect(await opened("darwin")).toEqual([["/shim/open", "/repo/media/shot.png"]]) + expect(await opened("linux")).toEqual([["/shim/xdg-open", "/repo/media/shot.png"]]) + expect(await opened("win32")).toEqual([["/shim/cmd", "/c", "start", "", "/repo/media/shot.png"]]) + }) + + test("macOS's /usr/bin/open before PATH, and COCKPIT_OPENER over both", async () => { + const system = setup({ exists: (path) => path === "/usr/bin/open" }) + await system.viewer.open("/repo", file({ after: { size: 1 } })) + expect(system.launched[0]?.command).toBe("/usr/bin/open") + const stub = setup({ env: { PATH: "/shim", COCKPIT_OPENER: "/tmp/stub" } }) + await stub.viewer.open("/repo", file({ after: { size: 1 } })) + expect(stub.launched[0]).toMatchObject({ command: "/tmp/stub", args: ["/repo/media/shot.png"] }) + }) + + test("the temp name keeps the extension and says which side", () => { + expect(oldName({ ...both, from: "img/old-name.gif" })).toBe("old-name@0123456.gif") + expect(oldName(file({ before: { size: 1 }, revision: "HEAD" }, "Makefile"))).toBe("Makefile@HEAD") + }) +}) diff --git a/packages/review/test/panel/palette.test.ts b/packages/review/test/panel/palette.test.ts new file mode 100644 index 00000000..dc5728aa --- /dev/null +++ b/packages/review/test/panel/palette.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test" +import { matchesBinding, paletteBindings } from "../../src/core/palette.ts" + +/** The review steps aside for the host's palette: it has to recognise the palette's key exactly. */ +describe("the palette's key", () => { + test("ctrl+p unless the config says otherwise", () => { + expect(paletteBindings(undefined)).toEqual(["ctrl+p"]) + expect(paletteBindings({ keybinds: {} })).toEqual(["ctrl+p"]) + expect(paletteBindings({ keybinds: { command_list: "ctrl+k, F2" } })).toEqual(["ctrl+k", "f2"]) + expect(paletteBindings({ keybinds: { command_list: "none" } })).toEqual([]) + }) + + test("leader chords are left to the host", () => { + expect(paletteBindings({ keybinds: { command_list: "p,ctrl+p" } })).toEqual(["ctrl+p"]) + }) + + test("every modifier exactly", () => { + expect(matchesBinding({ name: "p", ctrl: true }, "ctrl+p")).toBe(true) + expect(matchesBinding({ name: "p" }, "ctrl+p")).toBe(false) + expect(matchesBinding({ name: "p", ctrl: true, shift: true }, "ctrl+p")).toBe(false) + expect(matchesBinding({ name: "k", ctrl: true, shift: true }, "ctrl+shift+k")).toBe(true) + expect(matchesBinding({ name: "f2" }, "f2")).toBe(true) + expect(matchesBinding({ name: "p", meta: true }, "alt+p")).toBe(true) + }) +}) diff --git a/packages/review/test/view/grid.test.ts b/packages/review/test/view/grid.test.ts index 701414ec..116611dd 100644 --- a/packages/review/test/view/grid.test.ts +++ b/packages/review/test/view/grid.test.ts @@ -38,13 +38,13 @@ const scenario = (name: string) => { }) review = open(review, { file: first }, "a whole-file note", "you", 3) review = toggleRead(review, first) - return { changes: fixture?.changes, review, file: second } + return { changes: fixture?.changes, review, file: second, looks: fixture?.looks } } describe("the grid holds", () => { for (const name of Object.keys(FIXTURES)) { test(`${name}: every row is exactly the width of the pane`, () => { - const { changes, review, file } = scenario(name) + const { changes, review, file, looks } = scenario(name) if (!changes || changes.files.length === 0) return for (const width of WIDTHS) { for (const pane of ["files", "diff"] as const) { @@ -52,7 +52,15 @@ describe("the grid holds", () => { const rows = layout( changes, review, - { context: 3, collapsed: new Set(), pane, file, line: 2, ...(thread ? { thread } : {}) }, + { + context: 3, + collapsed: new Set(), + pane, + file, + line: 2, + ...(thread ? { thread } : {}), + ...(looks ? { looks } : {}), + }, { width, height: 28 }, ) for (const row of rows) expect(rowWidth(row)).toBe(width - 2) diff --git a/packages/review/test/view/image.test.ts b/packages/review/test/view/image.test.ts new file mode 100644 index 00000000..9b36970f --- /dev/null +++ b/packages/review/test/view/image.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from "bun:test" +import { FIXTURES } from "../../src/core/fixtures.ts" +import { emptyReview } from "../../src/core/model/review.ts" +import { binaryRows } from "../../src/core/view/image.ts" +import { layout } from "../../src/core/view/layout.ts" +import { type Row, rowWidth } from "../../src/core/view/rows.ts" + +const images = FIXTURES.images +const files = images?.changes.files ?? [] +const looks = images?.looks ?? new Map() +const byPath = (path: string) => files.find((file) => file.path === path) +const text = (rows: readonly Row[]) => rows.map((row) => row.runs.map((run) => run.text).join("")) +const card = (path: string, width = 90) => { + const file = byPath(path) + if (!file) throw new Error(`no ${path} in the fixture`) + return binaryRows(file, looks.get(path), width) +} + +describe("a binary's card says what is true about it", () => { + test("changed, same size: metadata, how much and where, the key, both pictures", () => { + const said = text(card("media/dashboard.png")) + expect(said[1]?.trim()).toBe("PNG 288×180 · 788 KB → 771 KB") + expect(said[2]?.trim()).toMatch(/^\d+\.\d+% of pixels changed · \d+×\d+ at \d+,\d+$/) + expect(said[3]?.trim()).toBe("[o] Open Both") + expect(said.some((line) => line.includes("before") && line.includes("after · changes lit"))).toBe(true) + }) + + test("resized: says so, and no percentage", () => { + const said = text(card("media/thumbnail.png")).join("\n") + expect(said).toContain("PNG 288×180 → 144×90") + expect(said).toContain("Resized") + expect(said).not.toContain("% of pixels") + }) + + test("added and deleted: the one side there is", () => { + expect(text(card("media/logo.png")).join("\n")).toContain("after — new") + const deleted = text(card("media/old-banner.gif")).join("\n") + expect(deleted).toContain("GIF 288×180 · 561 KB") + expect(deleted).toContain("[o] Open Old Version") + expect(deleted).toContain("before — deleted") + }) + + test("a JPEG is described, and says why there is no picture", () => { + const said = text(card("media/photo.jpg")).join("\n") + expect(said).toContain("JPEG 4032×3024 · 360 KB → 333 KB") + expect(said).toContain("No preview for JPEG") + }) + + test("too large: described, with the way to see it", () => { + const said = text(card("media/poster.png")).join("\n") + expect(said).toContain("PNG 12000×9000 · 174 MB → 175 MB") + expect(said).toContain("Too large to preview") + expect(said).toContain("[o] Open Both") + }) + + test("not an image: size, and nothing it would have to guess", () => { + expect(text(card("assets/font.woff2")).map((line) => line.trim())).toEqual([ + "", + "binary · 12.3 KB → 14.0 KB", + "[o] Open Both", + ]) + }) + + test("still decoding: says so where the picture will be", () => { + const file = byPath("media/dashboard.png") + if (!file) throw new Error("fixture") + expect(text(binaryRows(file, { pending: true, stamp: 99 }, 90)).join("\n")).toContain("Comparing pixels…") + }) +}) + +describe("the pictures are rows like any other", () => { + test("every row exactly the width, at every width", () => { + for (const path of files.map((file) => file.path)) + for (const width of [30, 44, 60, 90, 140, 200]) { + const file = byPath(path) + if (!file) continue + for (const row of binaryRows(file, looks.get(path), width)) expect(rowWidth(row)).toBe(width) + } + }) + + test("measuring builds the same number of rows as drawing, without the cells", () => { + for (const file of files) { + const drawn = binaryRows(file, looks.get(file.path), 90) + const measured = binaryRows(file, looks.get(file.path), 90, undefined, false) + expect(measured.length).toBe(drawn.length) + expect(measured.flatMap((row) => row.runs).some((run) => run.background !== undefined)).toBe(false) + } + }) + + test("half blocks with an exact ink and background, identical cells merged into one run", () => { + const rows = card("media/dashboard.png", 140) + const picture = rows.filter((row) => row.runs.some((run) => run.text.includes("â–€"))) + expect(picture.length).toBeGreaterThan(5) + for (const row of picture) + for (const run of row.runs.filter((each) => each.text.includes("â–€"))) { + expect(typeof run.color).toBe("number") + expect(typeof run.background).toBe("number") + expect(run.text).toMatch(/^â–€+$/) + } + /** A flat UI screenshot is long runs, not a chunk per cell: the cost driver for the row pool. */ + const runs = picture.reduce((sum, row) => sum + row.runs.length, 0) + const cells = picture.reduce((sum, row) => sum + rowWidth(row), 0) + expect(runs).toBeLessThan(cells / 4) + }) + + test("too narrow for a picture: the words keep the room", () => { + const narrow = card("media/dashboard.png", 18) + expect(narrow.some((row) => row.runs.some((run) => run.background !== undefined))).toBe(false) + }) +}) + +describe("the review around it", () => { + const draw = (state: Parameters[2], height = 40) => + text( + layout(images?.changes ?? { source: "branch", files: [] }, emptyReview(), state, { + width: 160, + height, + }), + ) + + test("the footer offers [o] on a binary", () => { + expect(draw({ pane: "diff", file: "media/dashboard.png", looks }).at(-1)).toContain("[o] Open") + const turn = FIXTURES.turn?.changes + if (!turn) throw new Error("fixture") + const plain = layout( + turn, + emptyReview(), + { pane: "diff", file: turn.files[0]?.path }, + { width: 160, height: 30 }, + ) + expect(text(plain).at(-1)).not.toContain("[o]") + expect(text(plain).at(-1)).toContain("[?] Keys") + }) + + test("[?] Keys: every key in the body's place, and the way back", () => { + const shown = draw({ keys: true }, 40) + expect(shown.some((line) => line.includes("KEYS"))).toBe(true) + expect(shown.some((line) => line.includes("[o]") && line.includes("system's viewer"))).toBe(true) + expect(shown.at(-1)?.trim()).toBe("[esc] Hide Keys") + }) + + test("[?] Keys in a short pane says how many are below", () => { + const shown = draw({ keys: true }, 14) + expect(shown.some((line) => /↓ \d+ more keys/.test(line))).toBe(true) + }) +}) diff --git a/packages/review/test/view/settings.test.ts b/packages/review/test/view/settings.test.ts new file mode 100644 index 00000000..d6aaac1d --- /dev/null +++ b/packages/review/test/view/settings.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test" +import { emptyReview } from "../../src/core/model/review.ts" +import { settingsRow } from "../../src/core/view/chrome.ts" +import { HEADER_ROWS } from "../../src/core/view/geometry.ts" +import { layout } from "../../src/core/view/layout.ts" +import type { Row } from "../../src/core/view/rows.ts" + +/** + * Review has no sidebar block, so a setting it does not read was a toast gone in ten seconds. It is + * now the shared `!` row in the pane, in place of the header's rule — and the header stays two rows, + * so a click still lands on the row drawn under it. + */ + +const changes = { + source: "worktree" as const, + files: [{ path: "src/a.ts", before: "a\n", after: "a\nb\n", additions: 1, deletions: 0 }], +} +const OLD = 'settings: "review.sidebarOrder" is no longer read — run /cockpit-setup' +const text = (row: Row | undefined) => row?.runs.map((run) => run.text).join("") ?? "" +const draw = (settings?: string[]) => + layout( + changes, + emptyReview(), + { context: 3, ...(settings ? { settings } : {}) }, + { width: 120, height: 20 }, + ) + +describe("settings notices in the pane", () => { + test("one notice: `!` in the warning tone, then its sentence, under the header", () => { + const row = draw([OLD])[HEADER_ROWS - 1] + expect(text(row).trim()).toBe(`! ${OLD}`) + expect(row?.runs.find((run) => run.text.includes("!"))?.tone).toBe("warning") + }) + + test("none: the rule is back, and the pane is the same height either way", () => { + expect(text(draw()[HEADER_ROWS - 1])).toMatch(/^─+$/) + expect(draw([OLD])).toHaveLength(draw().length) + }) + + test("several: how many comes first, so a narrow pane still says there is more", () => { + const row = settingsRow([OLD, 'settings: "review.source" should be a string'], 50) + expect(text(row)).toStartWith("! settings: 2 to fix, run /cockpit-setup") + expect(text(row)).toHaveLength(50) + }) +}) diff --git a/packages/review/test/watch/stats.test.ts b/packages/review/test/watch/stats.test.ts index 3848b94d..8aeaa5bb 100644 --- a/packages/review/test/watch/stats.test.ts +++ b/packages/review/test/watch/stats.test.ts @@ -91,7 +91,7 @@ describe("the keys, at any width", () => { test("with nothing to review, only the keys that act", () => { const row = keys(100, {}, true) - expect(row.trim()).toBe("[b] Source [B] Base [esc] Close") + expect(row.trim()).toBe("[b] Source [B] Base [?] Keys [esc] Close") }) test("the action has one name: viewed", () => { diff --git a/packages/shell/README.md b/packages/shell/README.md index 8452f921..eec34de3 100644 --- a/packages/shell/README.md +++ b/packages/shell/README.md @@ -114,7 +114,7 @@ Things to ask: | `/shells` | Every shell in view, plus "New shell": pick one to open its console | | `ctrl+x o` · `/shells-dock` | Toggle the shells panel under the chat | | `s` (in the console) | This conversation only, or the whole project | -| `ctrl+x i` · `/shell` | Reopen the last shell's console | +| `ctrl+x j` · `/shell` | Reopen the last shell's console | | `/shell-new` | Start a shell yourself | | `/shells-clear` | Remove finished shells | | `/shells-stop` | Stop the shells in view — this conversation, or the project | @@ -167,26 +167,32 @@ both halves of the plugin — the agent's tools and the interface: ~/.config/opencode-cockpit/config.json → /.cockpit.json → plugin-entry options ``` -Later sources win key by key, so a project can override one setting without restating the rest. An -unreadable or invalid file is ignored rather than fatal: a typo should never stop shells from -working. (`XDG_CONFIG_HOME` is honoured for the global path.) +Shell's settings sit in the file's `shell` section. Later sources win key by key, so a project can +override one setting without restating the rest. Comments and trailing commas are fine; an +unreadable file is ignored rather than fatal — a typo should never stop shells from working. +(`XDG_CONFIG_HOME` is honoured for the global path.) -```json +```jsonc { - "kinds": { "e2e": "playwright|cypress", "infra": "^(terraform|pulumi)\\b" }, - "watch": { - "auto": false, - "presets": { "e2e": { "done": "\\d+ passed", "fail": "\\d+ failed", "ignoreCase": true } } - }, - "defaults": { "logFile": true, "timeoutSeconds": 900 }, - "notify": { "exit": true, "watch": true, "tailLines": 15 }, - "guidance": true, - "listRunningShells": 15, - "ui": { "dockHeight": 16, "dockOpen": true, "historyMinutes": 60, "colors": true } + "shell": { + "kinds": { "e2e": "playwright|cypress", "infra": "^(terraform|pulumi)\\b" }, + "watch": { + "auto": false, + "presets": { "e2e": { "done": "\\d+ passed", "fail": "\\d+ failed", "ignoreCase": true } } + }, + "defaults": { "logFile": true, "timeoutSeconds": 900 }, + "notify": { "exit": true, "watch": true, "tailLines": 15 }, + "guidance": true, + "listRunningShells": 15, + "dockHeight": 16, + "dockOpen": true, + "hideFinishedAfterMinutes": 60, + "colors": true + } } ``` -| Section | Meaning | +| Key | Meaning | |---|---| | `kinds` | Extra shell categories, or overrides, as name → regex matched against the command. Drives the badges in the sidebar, panel and `shell_list`, so you can group your own stack (`e2e`, `infra`, `worker`) instead of the built-ins. | | `watch.presets` | Your own watch rules, or replacements for built-ins, keyed by name. A rule is `{ done?, fail?, ok?, idleSeconds?, ignoreCase? }` — patterns are regular expressions matched against each output line. The agent can then ask for `watch: "e2e"`. | @@ -196,27 +202,30 @@ working. (`XDG_CONFIG_HOME` is honoured for the global path.) | `notify` | What may interrupt the agent — `exit`, `watch`, and `tailLines` (output lines included in an exit message). | | `guidance` | The system-prompt paragraph that teaches the agent when to use shells. `false` saves ~120 tokens per request, at the cost of a model that uses shells less well. | | `listRunningShells` | How many running shells are listed in the system prompt each turn (~20 tokens each). `0` disables it; the agent can still call `shell_list`. | -| `ui` | Interface only: `dockHeight`, `dockOpen` (set it and the panel always starts that way; leave it out and it starts as you last left it), `sidebarRows`, `historyMinutes`, `colors`, `defaultView` (`"screen"` or `"log"`), `keybinds`, `updateCheck`. | +| Interface | `dockHeight` (14), `dockOpen` (set it and the panel always starts that way; leave it out and it starts as you last left it), `sidebarRows` (5), `hideWhenEmpty` (`false`: with no shells the block says `none yet` under its heading; `true` draws nothing), `hideFinishedAfterMinutes` (30: how long a finished shell stays in the folded views), `colors`, `defaultView` (`"screen"` or `"log"`), `keybinds`, `enabled`. | + +Where the block sits is the top-level `"sidebar"` list's to say (`["status", "subagents", "shell", +"trail", "trust"]` by default). Before 0.9 these keys sat at the file's root, with the interface's +under `ui`; those places are no longer read — the Shells block shows a `!` row naming the new key +(`ui.historyMinutes` is now `hideFinishedAfterMinutes`, `ui.updateCheck` is `updater.updateCheck`), +and `/cockpit-setup` fixes it. -The same settings can go on the plugin entry instead, which is handy for one-offs and for machines -where you'd rather keep everything in `tui.json`/`opencode.json`: +The same keys can go on the plugin entry instead, which wins over both files — handy for one-offs: ```json { "plugin": [ ["@opencode-cockpit/shell", { - "ui": { "dockHeight": 16 }, - "keybinds": { "cockpit.shells.dock": "j", "cockpit.shells.console": "k" } + "dockHeight": 16, + "keybinds": { "cockpit.shells.console": "z" } }] ] } ``` -`ui` keys also work spelled flat at the top level (`{ "dockHeight": 16 }`), as they did before the -config file existed. Using `opencode-cockpit` instead of the standalone package? Put the same -object under `"shell"`: `["opencode-cockpit", { "shell": { "ui": { "dockHeight": 16 } } }]`. -Interface settings must be reachable from `tui.json`, agent settings from `opencode.json` — which -is exactly why the config file exists. +Using `opencode-cockpit` instead of the standalone package? Put the same object under `"shell"`: +`["opencode-cockpit", { "shell": { "dockHeight": 16 } }]`. The config file is still the better +place: interface settings on an entry must be in `tui.json`, agent settings in `opencode.json`. | Environment variable | Default | Purpose | |---|---|---| diff --git a/packages/shell/measure/agent.ts b/packages/shell/measure/agent.ts new file mode 100644 index 00000000..65cb7782 --- /dev/null +++ b/packages/shell/measure/agent.ts @@ -0,0 +1,103 @@ +#!/usr/bin/env bun +/** + * Shell's measurement: whether the guidance changes what a real agent does, not only what it is told. + * In a project whose `package.json` has a `dev` script, two conversations ask for the same thing — + * "start the dev server and check it responds" — and neither prompt says a word about shells: + * + * 1. the first must start it with shell_start, never with bash and "&"; + * 2. the second, with that server still running (`lifecycle.onExit: "keep"` lets it outlive the + * first run), must not start a second one. + * + * A run whose first turn never started the server at all measured nothing, and is tried again. + * + * bun packages/shell/measure/agent.ts OpenCode on PATH, this checkout + * OPENCODE=~/.opencode/bin/opencodeold bun packages/shell/measure/agent.ts + * bun packages/shell/measure/agent.ts --plugin --runs 3 --model --keep + * + * `--plugin` is the server half: a package directory, by default this checkout's `packages/shell`, + * built (`bun run build`). Exits 0 when every run passed, 1 otherwise. + */ + +import { mkdtempSync, rmSync } from "node:fs" +import { join, resolve } from "node:path" +import { + brief, + type Call, + flag, + measure, + openCode, + stopDaemon, + turn, + world, +} from "../../../scripts/measure-agent.ts" + +const oc = openCode() +const plugin = resolve(flag("--plugin") ?? join(import.meta.dir, "..")) +const runs = Number(flag("--runs")) || 1 +const model = flag("--model") ?? "opencode/space-bunny-free" +const keep = process.argv.includes("--keep") + +export const PROMPT = "Start the dev server and check it responds." + +/** The dev server being run, however it is spelled — and not `cat server.js`, which only reads it. */ +const DEV = /\b(npm|pnpm|yarn|bun)\s+(run\s+)?dev\b|\b(bun|node)\s+(run\s+)?server\.js/ +const commandOf = (call: Call) => String((call.input as { command?: unknown } | undefined)?.command ?? "") +/** OpenCode 1's built-in is `bash`, OpenCode 2's `shell`. */ +const viaBash = (calls: Call[]) => + calls.filter((c) => (c.tool === "bash" || c.tool === "shell") && DEV.test(commandOf(c))) +const viaShell = (calls: Call[]) => + calls.filter((c) => c.tool === "shell_start" && c.status === "completed" && DEV.test(commandOf(c))) +const listed = (calls: Call[]) => calls.some((c) => c.tool === "shell_list" && c.status === "completed") + +async function once(index: number) { + const work = mkdtempSync(`/tmp/ck-shell-${index}-`) + const port = 40_000 + Math.floor(Math.random() * 9_000) + const at = await world(oc, work, plugin, model) + try { + await Bun.write( + join(at.project, "package.json"), + JSON.stringify({ name: "web", private: true, scripts: { dev: "bun server.js" } }, null, 2), + ) + await Bun.write( + join(at.project, "server.js"), + `Bun.serve({ port: ${port}, fetch: () => new Response("ok") })\nconsole.log("Ready on http://localhost:${port}")\n`, + ) + /** Cockpit's own file, outside the project: the first run's shells outlive it, for the second. */ + await Bun.write( + join(work, "config", "opencode-cockpit", "config.json"), + JSON.stringify({ shell: { lifecycle: { onExit: "keep" } } }), + ) + + const first = turn(at, PROMPT) + const started = viaShell(first.calls) + const bashed = viaBash(first.calls) + const report = [ + `1st: shell_start ${started.length} ${started.map((c) => JSON.stringify(c.input)).join(" ")}`, + `1st: bash running it ${bashed.length} ${bashed.map((c) => commandOf(c)).join(" | ")}`, + `1st said: ${brief(first.said)}`, + ] + if (started.length === 0 && bashed.length === 0) return { ok: false, measured: false, report } + if (started.length === 0 || bashed.length > 0) return { ok: false, measured: true, report } + + /** A new conversation, the server still running. */ + const second = turn(at, PROMPT) + const again = viaShell(second.calls) + const againBash = viaBash(second.calls) + report.push( + `2nd: shell_list ${listed(second.calls)} shell_start ${again.length} bash running it ${againBash.length} ${againBash.map((c) => commandOf(c)).join(" | ")}`, + `2nd said: ${brief(second.said)}`, + ) + if (second.calls.length === 0) return { ok: false, measured: false, report } + return { ok: again.length === 0 && againBash.length === 0, measured: true, report } + } finally { + stopDaemon(at.env.COCKPIT_HOME as string) + if (!keep) rmSync(work, { recursive: true, force: true }) + else console.log(`kept ${work}`) + } +} + +await measure( + `Shell measurement: ${oc.version} (${oc.bin}), plugin ${plugin}, ${model}, ${runs} run(s)`, + runs, + once, +) diff --git a/packages/shell/package.json b/packages/shell/package.json index 8a070510..4ddf0656 100644 --- a/packages/shell/package.json +++ b/packages/shell/package.json @@ -56,7 +56,7 @@ "@opencode-cockpit/client": "workspace:*", "@opencode-cockpit/daemon": "workspace:*", "@opencode-cockpit/protocol": "workspace:*", - "@opencode-ai/plugin": "1.18.31" + "@opencode-ai/plugin": "1.18.33" }, "devDependencies": { "@opentui/core": "0.4.5", diff --git a/packages/shell/src/agent/plugin.ts b/packages/shell/src/agent/plugin.ts index 8ad3f801..bfd32434 100644 --- a/packages/shell/src/agent/plugin.ts +++ b/packages/shell/src/agent/plugin.ts @@ -1,6 +1,7 @@ import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client" import { dualServer, + openText, type ServerHost, type ServerParts, type ServerStart, @@ -24,11 +25,20 @@ import { } from "../core/notice.ts" import { createTools } from "./tools/index.ts" -const GUIDANCE = `## Background shells (opencode-cockpit) -Long-running or interactive commands (dev servers, watchers, slow builds/tests, REPLs) go in shell_start, not bash with "&". -Block with shell_wait (pattern, port, idle, exit) instead of sleeping; follow output with shell_read(after=cursor). -You are messaged when a shell you started exits, and — once that subagent finishes — when a shell one of your subagents started failed; shell_list says which subagent started each shell. -For processes that never exit (tsc --watch, vitest --watch, dev servers), shell_watch reports only when their health changes — use it instead of re-reading their logs.` +/** + * What the agent is told about shells, before every request. Tools are named as the model calls + * them: directly on OpenCode 1, `tools.shell_start` on OpenCode 2, whose Code Mode lists plugin tools + * in a catalog that cuts each description at ~115 characters (docs/opencode/trail-server.md). + */ +function guidance(version: 1 | 2): string { + const t = (name: string) => (version === 2 ? `tools.${name}` : name) + return `## Background shells (opencode-cockpit) +Dev servers, watchers, slow builds/tests and REPLs go in ${t("shell_start")}, never bash with "&". Before starting one, call ${t("shell_list")}: if this project's dev server or watcher is already running, use it — never start a second. +Give each shell a short name the user knows (description: "dev", "test", "build"); they see shells in the sidebar and dock, so tell them what you started. +Block with ${t("shell_wait")} (pattern, port, idle, exit) instead of sleeping; follow output with ${t("shell_read")}(after=cursor). +You are messaged when a shell you started exits, and — once that subagent finishes — when a shell one of your subagents started failed; ${t("shell_list")} says which subagent started each shell. +For processes that never exit (tsc --watch, vitest --watch, dev servers), ${t("shell_watch")} reports only when their health changes — use it instead of re-reading their logs.` +} /** * What a subagent is told on OpenCode 1, where it is never messaged while it works (core/notice.ts: @@ -39,7 +49,7 @@ const SUBAGENT_V1 = /** The guidance for one session: a subagent on OpenCode 1 is told it hears nothing while it works. */ export function shellGuidance(version: 1 | 2, subagent: boolean): string { - return version === 1 && subagent ? `${GUIDANCE}\n${SUBAGENT_V1}` : GUIDANCE + return version === 1 && subagent ? `${guidance(1)}\n${SUBAGENT_V1}` : guidance(version) } export const SHELL_PACKAGE = "@opencode-cockpit/shell" @@ -72,6 +82,11 @@ async function shellParts(host: ServerHost, options?: unknown): Promise /** Settings that shape defaults, kinds and watch presets. */ - config?: CockpitConfig + config?: ShellConfig /** Human title of an OpenCode session, for telling agents which session started a shell. */ sessionTitle?(sessionID: string): Promise /** @@ -37,7 +37,7 @@ export interface ToolDeps { export interface ToolKit { deps: ToolDeps /** Settings from ~/.config/opencode-cockpit/config.json, .cockpit.json and plugin options. */ - config: CockpitConfig + config: ShellConfig client: CockpitClient peek(info: ShellInfo, tail?: number): Promise sessionLabel(shell: ShellInfo, ctx: ToolContext): Promise diff --git a/packages/shell/src/agent/tools/start.ts b/packages/shell/src/agent/tools/start.ts index 6fb072d5..e50689f9 100644 --- a/packages/shell/src/agent/tools/start.ts +++ b/packages/shell/src/agent/tools/start.ts @@ -41,7 +41,10 @@ export function shellStart(kit: ToolKit): ToolDefinition { description: START, args: { command: z.string().min(1).describe("Command line, run by your shell (pipes, && and env vars work)"), - description: z.string().min(3).describe("What this shell is for, 3-8 words, e.g. 'Next.js dev server'"), + description: z + .string() + .min(3) + .describe("The shell's name in the user's sidebar and dock: short, e.g. 'dev', 'test', 'build'"), workdir: z.string().optional().describe("Working directory; defaults to the project directory"), env: z.record(z.string(), z.string()).optional().describe("Extra environment variables"), waitFor: z diff --git a/packages/shell/src/agent/tools/watch-args.ts b/packages/shell/src/agent/tools/watch-args.ts index e3358a77..1249892f 100644 --- a/packages/shell/src/agent/tools/watch-args.ts +++ b/packages/shell/src/agent/tools/watch-args.ts @@ -1,5 +1,5 @@ import type { WatchRule } from "@opencode-cockpit/protocol/shell" -import type { CockpitConfig } from "../../core/config.ts" +import type { ShellConfig } from "../../core/config.ts" export type WatchRequest = { preset?: string; rule?: WatchRule } @@ -35,7 +35,7 @@ const RULE_KEYS = new Set(["done", "fail", "ok", "ignoreCase", "idleSeconds"]) * parsed the way the schema says) that a string which looks like an object is parsed rather than * handed on as a preset name nobody has. */ -export function watchArgs(watch: unknown, config: CockpitConfig): WatchRequest { +export function watchArgs(watch: unknown, config: ShellConfig): WatchRequest { const rule = asWatchRule(watch) if (rule) return { rule } const preset = watch === true || watch == null ? "auto" : String(watch) diff --git a/packages/shell/src/cli/preview.ts b/packages/shell/src/cli/preview.ts index d590cad9..2b8afd05 100644 --- a/packages/shell/src/cli/preview.ts +++ b/packages/shell/src/cli/preview.ts @@ -12,10 +12,18 @@ */ import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui" -import { HEADING_GAP, type State as Shared, summaryRuns } from "@opencode-cockpit/client/design" +import { + emptyBlock, + FEWER_TEXT, + HEADING_GAP, + moreText, + type State as Shared, + summaryRuns, + warnRows, +} from "@opencode-cockpit/client/design" import type { ScreenResult, ShellInfo } from "@opencode-cockpit/protocol/shell" import { type ConsoleInput, consoleRows, type Row, type Run } from "../tui/lib/console.ts" -import { sidebarRow } from "../tui/lib/sidebar.ts" +import { fold, sidebarRow } from "../tui/lib/sidebar.ts" import { badgeText, displayCommand, @@ -94,7 +102,50 @@ function fitRow(row: Row, width: number): Row { // --- the sidebar --------------------------------------------------------------------------------- -function sidebar(list: readonly ShellInfo[], width: number): Row[] { +/** A settings notice as the block draws it (client/settings `noticeText`), for `--notice`. */ +const NOTICE = 'settings: "ui.historyMinutes" is no longer read — run /cockpit-setup' + +interface SidebarState { + /** Expanded by a click on `+ N more`. */ + showAll?: boolean + hideWhenEmpty?: boolean + notices?: readonly string[] +} + +function sidebar(list: readonly ShellInfo[], width: number, state: SidebarState = {}): Row[] { + /** As `components/sidebar.tsx` draws them: the design module's rows for an empty block and a notice. */ + const warnings = (state.notices ?? []).flatMap((text) => warnRows(text, width).map((row) => row as Row)) + if (list.length === 0) + return [ + ...emptyBlock("Shells", width, state.hideWhenEmpty === true && warnings.length === 0).map( + (row) => row as Row, + ), + ...warnings, + ] + const folding = fold({ + all: list, + folded: list.filter((shell) => kindOf(shell) === "run" || kindOf(shell) === "fail"), + showAll: state.showAll === true, + rows: 5, + expandedRows: 12, + }) + const toggle: Row[] = !folding.toggle + ? [] + : [ + fitRow( + [ + { + text: ` ${folding.toggle === "fewer" ? FEWER_TEXT : moreText(folding.more)}`, + tone: "muted", + }, + ], + width, + ), + ] + return [...blockRows(folding.shown, list, width), ...toggle, ...warnings] +} + +function blockRows(shown: readonly ShellInfo[], list: readonly ShellInfo[], width: number): Row[] { /** As `components/sidebar.tsx` draws it: the name left, every count flush right, no row of air. */ const tally: Partial> = {} for (const shell of list) tally[STATE[kindOf(shell)]] = (tally[STATE[kindOf(shell)]] ?? 0) + 1 @@ -108,7 +159,7 @@ function sidebar(list: readonly ShellInfo[], width: number): Row[] { return [ fitRow(heading, width), ...Array.from({ length: HEADING_GAP }, () => fitRow([], width)), - ...list.map((shell): Row => { + ...shown.map((shell): Row => { const row = sidebarRow(shell, SAMPLE_NOW, 2, width) const kind = hex(kindColor(theme, kindOf(shell))) return [ @@ -258,6 +309,7 @@ if (args.includes("--help") || args.includes("-h")) { "", " --part one surface (default: all three)", " --width the sidebar's width (default 38, about what OpenCode gives it)", + " --notice the sidebar with a settings notice in it", " --columns the dock's and the console's width (default: this terminal, 60 to 140)", " --state the console in one state (default: every one):", ...Object.entries(STATES).map(([name, about]) => ` ${name.padEnd(9)}${about}`), @@ -288,8 +340,21 @@ const section = (title: string, rows: Row[]) => { out.push("", title, "") for (const row of rows) out.push(paint(row)) } -if (!part || part === "sidebar") - section(`Sidebar — ${sidebarWidth} columns`, sidebar(SAMPLE_LIST, sidebarWidth)) +if (!part || part === "sidebar") { + const notices = args.includes("--notice") ? [NOTICE] : [] + section(`Sidebar — ${sidebarWidth} columns`, sidebar(SAMPLE_LIST, sidebarWidth, { notices })) + section("Sidebar — expanded", sidebar(SAMPLE_LIST, sidebarWidth, { showAll: true, notices })) + /** Expanded over six, down to one: nothing to fold, so no `− fewer`. */ + section( + "Sidebar — one shell, after it was expanded", + sidebar(SAMPLE_LIST.slice(0, 1), sidebarWidth, { showAll: true, notices }), + ) + section("Sidebar — no shells yet", sidebar([], sidebarWidth, { notices })) + section( + "Sidebar — no shells yet, hideWhenEmpty", + sidebar([], sidebarWidth, { hideWhenEmpty: true, notices }), + ) +} if (!part || part === "dock") section(`Dock — ${columns} columns`, dock(SAMPLE_LIST, SHELLS.running, DEV_SCREEN, columns)) if (!part || part === "console") diff --git a/packages/shell/src/core/config.ts b/packages/shell/src/core/config.ts index d0abd5ab..2931b8a8 100644 --- a/packages/shell/src/core/config.ts +++ b/packages/shell/src/core/config.ts @@ -1,18 +1,21 @@ -import { existsSync, readFileSync } from "node:fs" -import { homedir } from "node:os" -import { join } from "node:path" +import { baySettings, type SettingsNotice } from "@opencode-cockpit/client/settings" import type { WatchRule } from "@opencode-cockpit/protocol/shell" /** - * Settings, read from one file so they are written once instead of twice (OpenCode keeps agent and - * TUI plugins in separate configs). Precedence, lowest first: + * Shell's settings, through the loader every bay shares (`@opencode-cockpit/client/settings`), read + * by both halves so they are written once instead of twice (OpenCode keeps agent and TUI plugins in + * separate configs). Precedence, lowest first: * * ~/.config/opencode-cockpit/config.json → /.cockpit.json → plugin-entry options * - * Everything is optional, and an unreadable or invalid file is ignored rather than fatal: a typo in - * a config should never stop shells from working. + * Only the `shell` section of a file is read. Shell's keys used to sit at the file's root, with the + * interface's under `ui`; those are no longer read, and each one found is a notice naming the new + * place (drawn in the Shells block, and by doctor). Everything is optional, and an unreadable or + * invalid file is ignored rather than fatal: a typo in a config should never stop shells from working. */ -export interface CockpitConfig { +export interface ShellConfig { + /** Off switch for this bay, both halves, wherever it is written. `features.shell: false` too. */ + enabled?: boolean /** Health watching (see shell_watch). */ watch?: { /** Attach a matching preset to every new shell without being asked. Off by default. */ @@ -57,96 +60,68 @@ export interface CockpitConfig { guidance?: boolean /** Running shells listed in the system prompt each turn; 0 disables (~20 tokens each). */ listRunningShells?: number - /** Interface options; also settable on the tui.json plugin entry. */ - ui?: { - dockHeight?: number - dockOpen?: boolean - sidebarRows?: number - /** - * Where this bay's block sits among the others in a shared surface. Lower draws first. - * - * Two bays in one sidebar draw in the order they registered, and until now that order was a - * constant nobody could reach: shells above the statusline, whatever you would rather see. - */ - sidebarOrder?: number - historyMinutes?: number - colors?: boolean - defaultView?: "screen" | "log" - keybinds?: Record - updateCheck?: boolean - } + /** The panel at the foot of the window: its height in rows (at most 45% of the window). */ + dockHeight?: number + /** Whether the panel starts open; unset, it starts as you last left it. */ + dockOpen?: boolean + /** Draw the Shells block in the sidebar; the dock and console stay either way. */ + sidebar?: boolean + /** Shells in the sidebar before the rest fold into `+ N more`. */ + sidebarRows?: number + /** Draw no Shells block at all while there are none. Default false: the heading and `none yet`. */ + hideWhenEmpty?: boolean + /** Minutes a finished shell stays in the folded views after it ends. Was `ui.historyMinutes`. */ + hideFinishedAfterMinutes?: number + /** Paint the colours programs print. */ + colors?: boolean + /** What the console opens on: the program's screen, or its clean log. */ + defaultView?: "screen" | "log" + keybinds?: Record } -export const CONFIG_FILE = "config.json" -export const PROJECT_FILE = ".cockpit.json" +/** What every source left unset becomes. Their kinds are what a written value is checked against. */ +export const DEFAULTS = { + guidance: true, + listRunningShells: 15, + dockHeight: 14, + hideFinishedAfterMinutes: 30, + colors: true, + defaultView: "screen" as "screen" | "log", + watch: {} as NonNullable, + kinds: {} as Record, + defaults: {} as NonNullable, + lifecycle: {} as NonNullable, + notify: {} as NonNullable, +} -export function globalConfigPath(env: Record = process.env): string { - const base = env.XDG_CONFIG_HOME ?? join(env.HOME ?? homedir(), ".config") - return join(base, "opencode-cockpit", CONFIG_FILE) +export interface LoadedShell { + /** Every source merged over the defaults. */ + config: ShellConfig + /** The block's place, from the top-level `sidebar` list. */ + order: number + /** Settings to fix, for a `!` row in the block. */ + notices: SettingsNotice[] } -/** Reads and merges every source. `options` is the plugin entry's own options object. */ -export function loadConfig( +/** Reads and merges every source. `options` is the plugin entry's own options object. Never throws. */ +export function loadShell( directory: string, options?: unknown, env: Record = process.env, -): CockpitConfig { - return mergeConfig( - mergeConfig(readConfigFile(globalConfigPath(env)), readConfigFile(join(directory, PROJECT_FILE))), - asConfig(options), - ) -} - -export function readConfigFile(path: string): CockpitConfig { - if (!existsSync(path)) return {} - try { - return asConfig(JSON.parse(readFileSync(path, "utf8"))) - } catch { - return {} // a broken config must not take shells down with it - } -} - -/** Section-wise merge: later sources win key by key, and never lose a whole section. */ -export function mergeConfig(base: CockpitConfig, over: CockpitConfig): CockpitConfig { - return { - ...base, - ...over, - watch: { ...base.watch, ...over.watch, presets: { ...base.watch?.presets, ...over.watch?.presets } }, - kinds: { ...base.kinds, ...over.kinds }, - defaults: { ...base.defaults, ...over.defaults }, - lifecycle: { ...base.lifecycle, ...over.lifecycle }, - notify: { ...base.notify, ...over.notify }, - ui: { ...base.ui, ...over.ui, keybinds: { ...base.ui?.keybinds, ...over.ui?.keybinds } }, - } +): LoadedShell { + const loaded = baySettings("shell", DEFAULTS, { options, where: { directory, env } }) + const config = loaded.config as ShellConfig & typeof loaded.config + /** A value outside the two the console knows is the default, not a third view. */ + if (config.defaultView !== "screen" && config.defaultView !== "log") config.defaultView = "screen" + if (config.dockOpen !== undefined && typeof config.dockOpen !== "boolean") delete config.dockOpen + return { config, order: loaded.order, notices: loaded.notices } } -/** - * Plugin-entry options were flat before the config file existed (`{ dockHeight: 16 }`), so those - * keys still work and are read as `ui`. - */ -function asConfig(input: unknown): CockpitConfig { - if (!input || typeof input !== "object") return {} - const raw = input as Record - const config: CockpitConfig = {} - for (const key of ["watch", "kinds", "defaults", "lifecycle", "notify", "ui"] as const) { - const value = raw[key] - if (value && typeof value === "object") Object.assign(config, { [key]: value }) - } - if (typeof raw.guidance === "boolean") config.guidance = raw.guidance - if (typeof raw.listRunningShells === "number") config.listRunningShells = raw.listRunningShells - - const legacy: CockpitConfig["ui"] = {} - for (const key of [ - "dockHeight", - "dockOpen", - "sidebarRows", - "sidebarOrder", - "historyMinutes", - "updateCheck", - ] as const) { - if (raw[key] !== undefined) Object.assign(legacy, { [key]: raw[key] }) - } - if (raw.keybinds && typeof raw.keybinds === "object") - legacy.keybinds = raw.keybinds as Record - return Object.keys(legacy).length > 0 ? mergeConfig(config, { ui: legacy }) : config +/** Every source merged over the defaults: what the agent's half reads. */ +export function loadConfig( + directory: string, + options?: unknown, + env: Record = process.env, +): ShellConfig { + return loadShell(directory, options, env).config } diff --git a/packages/shell/src/tui/components/dock.tsx b/packages/shell/src/tui/components/dock.tsx index 216f8f60..43cd78ca 100644 --- a/packages/shell/src/tui/components/dock.tsx +++ b/packages/shell/src/tui/components/dock.tsx @@ -10,6 +10,7 @@ import { import type { Host } from "@opencode-cockpit/client/host" import { useTerminalDimensions } from "@opentui/solid" import { createMemo, For, Show } from "solid-js" +import { fold } from "../lib/sidebar.ts" import { displayCommand, kindColor, @@ -44,10 +45,17 @@ export function Dock(props: DockProps) { const bodyRows = () => Math.max(2, props.height - 3) // Tabs share one row: keep them to what fits, the rest lives behind the "N more" chip. const tabLimit = () => Math.max(1, Math.floor((dims().width - 26) / 30)) - const tabs = createMemo(() => - (props.store.showAll() ? props.store.shells() : props.store.visible()).slice(0, tabLimit()), + /** The sidebar's fold, at the dock's width: the toggle only while folding hides something. */ + const folding = createMemo(() => + fold({ + all: props.store.shells(), + folded: props.store.folded(), + showAll: props.store.showAll(), + rows: tabLimit(), + expandedRows: tabLimit(), + }), ) - const overflow = createMemo(() => props.store.shells().length - tabs().length) + const tabs = () => folding().shown const bodyCols = () => Math.max(10, dims().width - 4) const body = createMemo(() => tailLines(screen()?.text, bodyRows(), bodyCols())) const bodyRuns = createMemo(() => @@ -106,15 +114,17 @@ export function Dock(props: DockProps) { ) }} - 0 || props.store.showAll()}> - props.store.toggleAll()} - > - {props.store.showAll() ? FEWER_TEXT : moreText(overflow())} - + + {(toggle) => ( + props.store.toggleAll()} + > + {toggle() === "more" ? moreText(folding().more) : FEWER_TEXT} + + )} diff --git a/packages/shell/src/tui/components/sidebar.tsx b/packages/shell/src/tui/components/sidebar.tsx index 75a35199..70e99c2e 100644 --- a/packages/shell/src/tui/components/sidebar.tsx +++ b/packages/shell/src/tui/components/sidebar.tsx @@ -1,9 +1,18 @@ /** @jsxImportSource @opentui/solid */ -import { FEWER_TEXT, HEADING_GAP, moreText, type State, summaryRuns } from "@opencode-cockpit/client/design" +import { + emptyBlock, + FEWER_TEXT, + HEADING_GAP, + moreText, + type State, + summaryRuns, + type ToneRun, + warnRows, +} from "@opencode-cockpit/client/design" import type { Host } from "@opencode-cockpit/client/host" import type { BoxRenderable } from "@opentui/core" -import { createMemo, createSignal, For, Show } from "solid-js" -import { sidebarRow } from "../lib/sidebar.ts" +import { createEffect, createMemo, createSignal, For, Show } from "solid-js" +import { fold, sidebarRow } from "../lib/sidebar.ts" import { kindColor, kindOf, STATE, toneColor, watchColor } from "../lib/view.ts" import type { ShellStore } from "../state/store.ts" @@ -14,13 +23,19 @@ export interface SidebarProps { rows?: number /** Rows shown while expanded, so a hundred shells can never push the sidebar over. */ expandedRows?: number + /** Draw nothing at all while there are no shells (`hideWhenEmpty`); else the heading and `none yet`. */ + hideWhenEmpty?: boolean + /** Settings to fix, one `!` row each (client/settings `noticeText`); they show even when empty and hidden. */ + notices?: readonly string[] onOpen: (id: string) => void consoleShortcut: () => string } +/** Rows a settings notice may wrap to: its last words are the fix, and a narrow column needs four. */ +const NOTICE_ROWS = 5 + export function SidebarShells(props: SidebarProps) { const theme = () => props.api.theme.current - const limit = () => Math.max(1, props.store.showAll() ? (props.expandedRows ?? 12) : (props.rows ?? 5)) /** * Every count, in the words and tones the Subagents heading uses beside it (client/design): it read @@ -36,10 +51,20 @@ export function SidebarShells(props: SidebarProps) { return out }) - // Running shells and recent failures first; everything else only when expanded. - const candidates = createMemo(() => (props.store.showAll() ? props.store.shells() : props.store.visible())) - const shown = createMemo(() => candidates().slice(0, limit())) - const overflow = createMemo(() => props.store.shells().length - shown().length) + /** Running shells and recent failures first; everything else only when expanded (`fold`). */ + const folding = createMemo(() => + fold({ + all: props.store.shells(), + folded: props.store.folded(), + showAll: props.store.showAll(), + rows: props.rows ?? 5, + expandedRows: props.expandedRows ?? 12, + }), + ) + /** Expanded for a list that fits folded now: fold back, so the next time it overflows it starts folded. */ + createEffect(() => { + if (folding().stale) props.store.foldAll() + }) /** * The width the sidebar gives the block, so each row can be exactly that wide and its facts flush @@ -63,61 +88,103 @@ export function SidebarShells(props: SidebarProps) { }) const counts = createMemo(() => summaryRuns(tally(), Math.max(8, width() - "Shells ".length))) + const warnings = createMemo(() => + (props.notices ?? []).flatMap((text) => warnRows(text, width(), NOTICE_ROWS)), + ) + const empty = () => props.store.shells().length === 0 + /** + * With no shells the block still says it exists — the heading, and `none yet` in the row the first + * shell will take — unless asked for silence; a notice always speaks. + */ + const emptyRows = createMemo(() => + emptyBlock("Shells", width(), props.hideWhenEmpty === true && warnings().length === 0), + ) + + /** Rows the shared design module drew, coloured here: the empty block, and the notices. */ + const toneRows = (rows: () => readonly ToneRun[][]) => ( + + {(row) => ( + + + {(run) => ( + + {run.bold ? {run.text} : run.text} + + )} + + + )} + + ) return ( - 0}> + 0}> { block = el }} onSizeChange={() => setResized((n) => n + 1)} > - {/* - * The title on the left and what needs your eye flush right, as the Subagents heading draws - * it; then the row of air every block has under its heading. - */} - - - Shells - - - - - {(part) => {part.text}} - - - - - {(shell) => { - const row = () => sidebarRow(shell, props.store.now(), props.store.frame(), width()) - const colour = () => kindColor(theme(), kindOf(shell)) - return ( - // Mouse-up, not mouse-down: the host dialog closes on the mouse-up that follows, so - // opening the console on press would need the button held down. - props.onOpen(shell.id)}> - {row().rule} - - {row().label} - - {row().title} - {row().watch} - {row().detail} + + {toneRows(emptyRows)} + {toneRows(warnings)} + + } + > + {/* + * The title on the left and what needs your eye flush right, as the Subagents heading draws + * it; then the row of air every block has under its heading. + */} + + + Shells + + + + + {(part) => {part.text}} + + + + + {(shell) => { + const row = () => sidebarRow(shell, props.store.now(), props.store.frame(), width()) + const colour = () => kindColor(theme(), kindOf(shell)) + return ( + // Mouse-up, not mouse-down: the host dialog closes on the mouse-up that follows, so + // opening the console on press would need the button held down. + props.onOpen(shell.id)}> + {row().rule} + + {row().label} + + {row().title} + {row().watch} + {row().detail} + + ) + }} + + {/* + * Said as the Subagents block says it (`+ 3 more`), under the names rather than under the + * marks; a click still unfolds it here, and folds it back. Only while folding hides + * something: one shell has nothing to fold. + */} + + {(toggle) => ( + props.store.toggleAll()}> + {toggle() === "more" + ? ` ${moreText(folding().more)}` + : toggle() === "both" + ? ` ${moreText(folding().more)} · ${props.consoleShortcut()} console` + : ` ${FEWER_TEXT}`} - ) - }} - - {/* - * Said as the Subagents block says it (`+ 3 more`), under the names rather than under the - * marks; a click still unfolds it here, and folds it back. - */} - 0 || props.store.showAll()}> - props.store.toggleAll()}> - {props.store.showAll() - ? overflow() > 0 - ? ` ${moreText(overflow())} · ${props.consoleShortcut()} console` - : ` ${FEWER_TEXT}` - : ` ${moreText(overflow())}`} - + )} + + {toneRows(warnings)} diff --git a/packages/shell/src/tui/index.tsx b/packages/shell/src/tui/index.tsx index 7b26604b..3eb84720 100644 --- a/packages/shell/src/tui/index.tsx +++ b/packages/shell/src/tui/index.tsx @@ -1,12 +1,13 @@ /** @jsxImportSource @opentui/solid */ import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client" +import { defaultKeys } from "@opencode-cockpit/client/catalog" import { bindingLookup, dualTui, type Host, onPaste } from "@opencode-cockpit/client/host" -import { sidebarOrder } from "@opencode-cockpit/client/sidebar" +import { noticeText } from "@opencode-cockpit/client/settings" import type { BoxRenderable } from "@opentui/core" import { createSignal } from "solid-js" import { createClient } from "../connect.ts" -import { type CockpitConfig, loadConfig } from "../core/config.ts" +import { loadShell, type ShellConfig } from "../core/config.ts" import { Console } from "./components/console.tsx" import { Dock } from "./components/dock.tsx" import { SidebarShells } from "./components/sidebar.tsx" @@ -22,13 +23,10 @@ import { createShellStore } from "./state/store.ts" import { Overlay } from "./view/overlay.tsx" import { createRowPool, type RowPool } from "./view/pool.ts" -const DEFAULT_KEYS = { - "cockpit.shells.dock": "o", - "cockpit.shells.console": "i", -} +const DEFAULT_KEYS = defaultKeys("shell") -/** Interface settings; the `ui` section of the config file (see core/config.ts). */ -export type ShellTuiOptions = NonNullable +/** Shell's settings: the `shell` section of the config files, then the plugin entry (core/config.ts). */ +export type ShellTuiOptions = ShellConfig const SHELL_PACKAGE = "@opencode-cockpit/shell" @@ -52,15 +50,19 @@ export function createShellTui({ source = SHELL_PACKAGE }: { source?: string } = } const shellTui = async (api: Host, rawOptions?: unknown) => { - // Settings come from the shared config file; plugin-entry options still win, flat or under "ui". + // Settings come from the shared config files' `shell` section; plugin-entry options win. const log = api.log.child("shell") - const config = loadConfig(api.state.path.directory, rawOptions) - const options: ShellTuiOptions = config.ui ?? {} + const { config: options, order, notices } = loadShell(api.state.path.directory, rawOptions) + for (const notice of notices) log.warn("settings", { file: notice.file, notice: notice.text }) + if (options.enabled === false) { + log.info("off in the settings") + return + } const client = createClient("opencode-cockpit/tui") - const store = createShellStore(api, client, { historyMinutes: options.historyMinutes }) + const store = createShellStore(api, client, { historyMinutes: options.hideFinishedAfterMinutes }) const keys = bindingLookup({ ...DEFAULT_KEYS, ...options.keybinds }) - // An explicit `ui.dockOpen` says how the panel should start; without one, whatever you last left + // An explicit `dockOpen` says how the panel should start; without one, whatever you last left // it as. Remembered state that overrides a written setting is a setting that appears to do nothing. const [dockOpen, setDockOpen] = createSignal( options.dockOpen ?? api.kv.get("cockpit.dock.open", false), @@ -423,10 +425,10 @@ const shellTui = async (api: Host, rawOptions?: unknown) => { api.slots.register({ /** - * The sidebar on its own: its place there (statusline, subagents, then shells — `"sidebar"` in - * Cockpit's config moves it) is not the dock's place at the foot of the window. + * The sidebar on its own: its place there (status, subagents, then shells — the top-level + * `"sidebar"` list in Cockpit's config moves it) is not the dock's place at the foot of the window. */ - order: sidebarOrder("shell", 170, options.sidebarOrder, { directory: api.state.path.directory }), + order, slots: { sidebar_content() { return ( @@ -434,6 +436,8 @@ const shellTui = async (api: Host, rawOptions?: unknown) => { api={api} store={store} rows={options.sidebarRows} + hideWhenEmpty={options.hideWhenEmpty === true} + notices={notices.map(noticeText)} onOpen={(id) => openConsole(id)} consoleShortcut={() => shortcut("cockpit.shells.console")} /> diff --git a/packages/shell/src/tui/lib/sidebar.ts b/packages/shell/src/tui/lib/sidebar.ts index 7822d073..763c2096 100644 --- a/packages/shell/src/tui/lib/sidebar.ts +++ b/packages/shell/src/tui/lib/sidebar.ts @@ -49,6 +49,50 @@ export function sidebarCounts(list: readonly ShellInfo[]): string { .join(" · ") } +export interface FoldInput { + /** Every shell the block is about, in order. */ + all: readonly T[] + /** The ones the folded view keeps — running, recent failures, the selection — in order. */ + folded: readonly T[] + /** Expanded by a click on `+ N more`. */ + showAll: boolean + /** Rows folded, and rows expanded. */ + rows: number + expandedRows: number +} + +export interface Fold { + shown: T[] + /** Shells not shown: what `+ N more` counts. */ + more: number + /** + * The row under the shells: `more` offers to unfold, `fewer` to fold back, `both` is expanded and + * still cut by the expanded limit. None when folding would hide nothing. + */ + toggle?: "more" | "fewer" | "both" + /** + * Expanded, but the folded view would show every shell now: the expansion should reset itself. It + * outlived the shells it was for — expanded at six, down to one, the block still offered + * `− fewer` for a list nothing could fold. + */ + stale: boolean +} + +/** + * What the block shows, and whether it offers to fold or unfold. The toggle is there only while + * folding hides something; expanded with nothing left to hide, the expansion is stale. + */ +export function fold(input: FoldInput): Fold { + const rows = Math.max(1, input.rows) + const folded = input.folded.slice(0, rows) + const folds = input.all.length > folded.length + const expanded = input.showAll && folds + const shown = expanded ? input.all.slice(0, Math.max(rows, input.expandedRows)) : folded + const more = input.all.length - shown.length + const toggle = !folds ? undefined : !expanded ? "more" : more > 0 ? "both" : "fewer" + return { shown, more, ...(toggle ? { toggle } : {}), stale: input.showAll && !folds } +} + /** Every part's text, in the order the row draws them. */ export const sidebarRowText = (row: SidebarRow): string => `${row.rule}${row.label}${row.title}${row.watch}${row.detail}` diff --git a/packages/shell/src/tui/state/store.ts b/packages/shell/src/tui/state/store.ts index 520c3efe..10a88afe 100644 --- a/packages/shell/src/tui/state/store.ts +++ b/packages/shell/src/tui/state/store.ts @@ -16,8 +16,12 @@ export interface ShellStore { /** Ordered and folded for display: running, recent failures, plus the selection. */ visible: Accessor hidden: Accessor + /** What the folded view keeps, whether or not it is expanded now: running, recent failures, the selection. */ + folded: Accessor showAll: Accessor toggleAll(): void + /** Folded again, because nothing is left to unfold (`fold` in lib/sidebar.ts says when). */ + foldAll(): void /** Which shells the panel is about: the conversation you are in, or the whole project. */ scope: Accessor toggleScope(): void @@ -52,7 +56,11 @@ export function createShellStore(api: Host, client: CockpitClient, options: Stor const [now, setNow] = createSignal(Date.now()) const [frame, setFrame] = createSignal(0) const [selectedId, setSelectedId] = createSignal() - const [showAll, setShowAll] = createSignal(api.kv.get("cockpit.shells.showAll", false)) + /** + * Expanded or folded, for this window only. It used to be kept across restarts, so a list + * expanded for six shells came back expanded over one, offering `− fewer` for nothing. + */ + const [showAll, setShowAll] = createSignal(false) const [scope, setScope] = createSignal( api.kv.get("cockpit.shells.scope", options.scope ?? "session"), ) @@ -113,6 +121,9 @@ export function createShellStore(api: Host, client: CockpitClient, options: Stor const folded = createMemo(() => partition(inScope(), { showAll: showAll(), historyMs, now: now(), keep: pick()?.id }), ) + const foldedOnly = createMemo( + () => partition(inScope(), { showAll: false, historyMs, now: now(), keep: pick()?.id }).visible, + ) return { client, @@ -128,12 +139,10 @@ export function createShellStore(api: Host, client: CockpitClient, options: Stor all: () => state.list, visible: () => folded().visible, hidden: () => folded().hidden, + folded: foldedOnly, showAll, - toggleAll() { - const next = !showAll() - setShowAll(next) - api.kv.set("cockpit.shells.showAll", next) - }, + toggleAll: () => setShowAll((all) => !all), + foldAll: () => setShowAll(false), connected, now, frame, diff --git a/packages/shell/test/config-effects.test.ts b/packages/shell/test/config-effects.test.ts index 3938e0d9..fecc06b4 100644 --- a/packages/shell/test/config-effects.test.ts +++ b/packages/shell/test/config-effects.test.ts @@ -2,13 +2,13 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test" import type { ToolContext } from "@opencode-ai/plugin" import { startDaemon } from "../../client/test/helpers.ts" import { createTools } from "../src/agent/tools/index.ts" -import type { CockpitConfig } from "../src/core/config.ts" +import type { ShellConfig } from "../src/core/config.ts" /** * Settings are only worth having if they change what the agent's tools do, so these drive the real * tools against a real daemon with a config in place, and assert the observable difference. */ -const CONFIG: CockpitConfig = { +const CONFIG: ShellConfig = { kinds: { e2e: "playwright|cypress" }, watch: { presets: { e2e: { done: "\\d+ (passed|failed)", fail: "\\d+ failed" } } }, defaults: { logFile: true, notifyOnExit: false, timeoutSeconds: 30 }, diff --git a/packages/shell/test/config.test.ts b/packages/shell/test/config.test.ts index 34a2ffd9..b1f3c7a2 100644 --- a/packages/shell/test/config.test.ts +++ b/packages/shell/test/config.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, test } from "bun:test" import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" -import { dirname, join } from "node:path" +import { join } from "node:path" import { watchArgs } from "../src/agent/tools/watch-args.ts" -import { globalConfigPath, loadConfig, mergeConfig, readConfigFile } from "../src/core/config.ts" +import { loadConfig, loadShell } from "../src/core/config.ts" const dirs: string[] = [] const temp = () => { @@ -14,47 +14,121 @@ afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) }) +/** A global file and a project file, as written; the env points the loader at the global one. */ +function files(global: string | object | undefined, project?: string | object) { + const home = temp() + const directory = temp() + const text = (value: string | object) => (typeof value === "string" ? value : JSON.stringify(value)) + if (global !== undefined) { + mkdirSync(join(home, "opencode-cockpit"), { recursive: true }) + writeFileSync(join(home, "opencode-cockpit", "config.json"), text(global)) + } + if (project !== undefined) writeFileSync(join(directory, ".cockpit.json"), text(project)) + return { directory, env: { XDG_CONFIG_HOME: home } } +} + describe("config", () => { test("project settings override global ones, and plugin options override both", () => { - const home = temp() - const project = temp() - const globalFile = globalConfigPath({ XDG_CONFIG_HOME: home }) - mkdirSync(dirname(globalFile), { recursive: true }) - writeFileSync(globalFile, JSON.stringify({ guidance: false, ui: { dockHeight: 10, sidebarRows: 3 } })) - writeFileSync(join(project, ".cockpit.json"), JSON.stringify({ ui: { dockHeight: 20 } })) - - const config = loadConfig(project, { ui: { sidebarRows: 9 } }, { XDG_CONFIG_HOME: home }) + const { directory, env } = files( + { shell: { guidance: false, dockHeight: 10, sidebarRows: 3 } }, + { shell: { dockHeight: 20 } }, + ) + const config = loadConfig(directory, { sidebarRows: 9 }, env) expect(config.guidance).toBe(false) // only the global file set it - expect(config.ui?.dockHeight).toBe(20) // project wins over global - expect(config.ui?.sidebarRows).toBe(9) // options win over both + expect(config.dockHeight).toBe(20) // project wins over global + expect(config.sidebarRows).toBe(9) // options win over both }) - test("old flat plugin options still work", () => { - const config = loadConfig( - temp(), - { dockHeight: 16, keybinds: { "cockpit.shells.dock": "j" } }, - {}, - ) - expect(config.ui?.dockHeight).toBe(16) - expect(config.ui?.keybinds).toEqual({ "cockpit.shells.dock": "j" }) + test("the bundle's `shell` section and a standalone entry's own keys read the same", () => { + const { directory, env } = files(undefined) + const keys = { "cockpit.shells.dock": "j" } + for (const options of [ + { shell: { dockHeight: 16, keybinds: keys } }, + { dockHeight: 16, keybinds: keys }, + ]) { + const config = loadConfig(directory, options, env) + expect(config.dockHeight).toBe(16) + expect(config.keybinds).toEqual(keys) + } }) test("sections merge key by key instead of replacing each other", () => { - const merged = mergeConfig( - { watch: { auto: true, presets: { a: { done: "1" } } }, kinds: { db: "psql" } }, - { watch: { presets: { b: { done: "2" } } } }, + const { directory, env } = files( + { shell: { watch: { auto: true, presets: { a: { done: "1" } } }, kinds: { db: "psql" } } }, + { shell: { watch: { presets: { b: { done: "2" } } } } }, + ) + const config = loadConfig(directory, undefined, env) + expect(config.watch?.auto).toBe(true) + expect(Object.keys(config.watch?.presets ?? {})).toEqual(["a", "b"]) + expect(config.kinds).toEqual({ db: "psql" }) + }) + + test("defaults: present when empty, finished shells stay 30 minutes, the screen view", () => { + const { directory, env } = files(undefined) + const config = loadConfig(directory, undefined, env) + expect(config).toMatchObject({ + sidebarRows: 5, + hideWhenEmpty: false, + hideFinishedAfterMinutes: 30, + dockHeight: 14, + colors: true, + defaultView: "screen", + guidance: true, + listRunningShells: 15, + }) + expect(config.dockOpen).toBeUndefined() + }) + + test("comments and trailing commas no longer drop the file", () => { + const { directory, env } = files(`{ + // mine + "shell": { "dockHeight": 22, }, + }`) + expect(loadConfig(directory, undefined, env).dockHeight).toBe(22) + }) + + test("a broken file is ignored with a notice, never fatal", () => { + const { directory, env } = files(undefined, "{ not json") + const { config } = loadShell(directory, undefined, env) + expect(config.dockHeight).toBe(14) + }) + + test("the old places are not read: root keys, `ui`, `ui.historyMinutes` — each a notice", () => { + const { directory, env } = files({ + guidance: false, + ui: { dockHeight: 30, historyMinutes: 5, sidebarOrder: 1 }, + }) + const { config, notices } = loadShell(directory, { ui: { sidebarRows: 2 } }, env) + expect(config.guidance).toBe(true) + expect(config.dockHeight).toBe(14) + expect(config.hideFinishedAfterMinutes).toBe(30) + expect(config.sidebarRows).toBe(5) + expect(notices.map((notice) => [notice.old, notice.new])).toEqual( + expect.arrayContaining([ + ["guidance", "shell.guidance"], + ["ui.dockHeight", "shell.dockHeight"], + ["ui.historyMinutes", "shell.hideFinishedAfterMinutes"], + ["ui.sidebarOrder", "sidebar"], + ["ui.sidebarRows", "shell.sidebarRows"], + ]), ) - expect(merged.watch?.auto).toBe(true) - expect(Object.keys(merged.watch?.presets ?? {})).toEqual(["a", "b"]) - expect(merged.kinds).toEqual({ db: "psql" }) + for (const notice of notices) expect(notice.text).toContain("/cockpit-setup") + }) + + test("a value of the wrong kind is the default, and an unknown view is the screen", () => { + const { directory, env } = files({ shell: { dockHeight: "16", defaultView: "tv", dockOpen: "yes" } }) + const { config, notices } = loadShell(directory, undefined, env) + expect(config.dockHeight).toBe(14) + expect(config.defaultView).toBe("screen") + expect(config.dockOpen).toBeUndefined() + expect(notices.some((notice) => notice.old === "shell.dockHeight")).toBe(true) }) - test("a broken or missing file is ignored, never fatal", () => { - const dir = temp() - writeFileSync(join(dir, ".cockpit.json"), "{ not json") - expect(readConfigFile(join(dir, ".cockpit.json"))).toEqual({}) - expect(readConfigFile(join(dir, "nope.json"))).toEqual({}) - expect(loadConfig(dir, undefined, {})).toBeDefined() + test("the block's place is the `sidebar` list's; `enabled` and `features.shell` switch it off", () => { + const { directory, env } = files({ sidebar: ["shell"], features: { shell: false } }) + const { order, config } = loadShell(directory, undefined, env) + expect(order).toBe(110) + expect(config.enabled).toBe(false) }) }) diff --git a/packages/shell/test/notice.test.ts b/packages/shell/test/notice.test.ts index 9f5d6d8e..3e297d9e 100644 --- a/packages/shell/test/notice.test.ts +++ b/packages/shell/test/notice.test.ts @@ -292,7 +292,22 @@ describe("the guidance says who is messaged", () => { }) test("the conversation, and anyone on OpenCode 2, get the guidance as it was", () => { - expect(shellGuidance(1, false)).toBe(shellGuidance(2, true)) + expect(shellGuidance(2, true)).toBe(shellGuidance(2, false)) + expect(shellGuidance(1, false)).not.toContain("not messaged") expect(shellGuidance(2, false)).not.toContain("not messaged") }) + + test("tools are named as each OpenCode calls them: tools.x in OpenCode 2's Code Mode", () => { + expect(shellGuidance(1, false)).toContain("go in shell_start,") + expect(shellGuidance(1, false)).not.toContain("tools.") + expect(shellGuidance(2, false)).toContain("go in tools.shell_start,") + expect(shellGuidance(2, false)).toContain("call tools.shell_list") + }) + + test("it says to reuse a running dev server, name shells short, and say what was started", () => { + const text = shellGuidance(2, false) + expect(text).toContain("never start a second") + expect(text).toContain('"dev", "test", "build"') + expect(text).toContain("tell them what you started") + }) }) diff --git a/packages/shell/test/tui-lib.test.ts b/packages/shell/test/tui-lib.test.ts index 3103de79..1791c51e 100644 --- a/packages/shell/test/tui-lib.test.ts +++ b/packages/shell/test/tui-lib.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import type { ScreenRun, ShellInfo } from "@opencode-cockpit/protocol/shell" import { friendlyError, splitMatches } from "../src/tui/lib/search.ts" +import { fold } from "../src/tui/lib/sidebar.ts" import { BADGE_LABEL, badgeText, @@ -264,3 +265,31 @@ describe("errors are rewritten for humans", () => { expect(friendlyError("plain string")).toBe("plain string") }) }) + +describe("folding the sidebar", () => { + const list = (n: number) => Array.from({ length: n }, (_, at) => `sh${at}`) + const at = (all: string[], folded: string[], showAll: boolean) => + fold({ all, folded, showAll, rows: 5, expandedRows: 12 }) + + test("one shell: nothing to fold, so no toggle — expanded or not", () => { + expect(at(list(1), list(1), false)).toEqual({ shown: ["sh0"], more: 0, stale: false }) + /** Expanded at six, down to one: no `− fewer`, and the expansion is stale. */ + expect(at(list(1), list(1), true)).toEqual({ shown: ["sh0"], more: 0, stale: true }) + }) + + test("more than the folded limit: `+ N more`, and `− fewer` once expanded", () => { + expect(at(list(7), list(7), false)).toMatchObject({ more: 2, toggle: "more", stale: false }) + expect(at(list(7), list(7), true)).toMatchObject({ more: 0, toggle: "fewer", stale: false }) + expect(at(list(7), list(7), true).shown).toHaveLength(7) + }) + + test("finished shells the folded view leaves out count too, though under the limit", () => { + /** Three shells, one running: folded shows one and offers the other two. */ + expect(at(list(3), ["sh0"], false)).toMatchObject({ shown: ["sh0"], more: 2, toggle: "more" }) + expect(at(list(3), ["sh0"], true)).toMatchObject({ more: 0, toggle: "fewer", stale: false }) + }) + + test("expanded past the expanded limit: what is left, and the console", () => { + expect(at(list(20), list(20), true)).toMatchObject({ more: 8, toggle: "both" }) + }) +}) diff --git a/packages/shell/test/tui-store.test.ts b/packages/shell/test/tui-store.test.ts index 04b94f5a..ce3c9db0 100644 --- a/packages/shell/test/tui-store.test.ts +++ b/packages/shell/test/tui-store.test.ts @@ -3,6 +3,7 @@ import type { TuiPluginApi } from "@opencode-ai/plugin/tui" import type { CockpitClient } from "@opencode-cockpit/client" import type { ShellInfo } from "@opencode-cockpit/protocol/shell" import { createMemo, createRoot, createSignal } from "solid-js" +import { fold } from "../src/tui/lib/sidebar.ts" import { createShellStore, type ShellStore, useScreen } from "../src/tui/state/store.ts" /** @@ -232,7 +233,46 @@ describe("folding", () => { expect(live.store.showAll()).toBe(true) expect(live.store.visible()).toHaveLength(2) expect(live.store.hidden()).toHaveLength(0) - expect(live.kv.get("cockpit.shells.showAll")).toBe(true) + /** What folding keeps is known while expanded too: it is what says whether there is anything to fold. */ + expect(live.store.folded().map((s) => s.id)).toEqual([running.id]) + /** For this window only: a list expanded for six shells came back expanded over one. */ + expect(live.kv.has("cockpit.shells.showAll")).toBe(false) + live.store.foldAll() + expect(live.store.showAll()).toBe(false) + }) + + /** + * The roadmap's bug: expanded at six, down to one, the block still offered `− fewer` for a list + * nothing could fold. The toggle is there only while folding hides something, and the expansion + * is stale — the sidebar folds it back — once it does not. + */ + test("one shell offers no toggle, and an expansion it outlived is stale", async () => { + const six = Array.from({ length: 6 }, (_, at) => shell({ session: "ses_a", startedAt: 100 + at })) + live = harness([], { historyMinutes: 1 }) + await live.setList(six) + const folding = () => + fold({ + all: live?.store.shells() ?? [], + folded: live?.store.folded() ?? [], + showAll: live?.store.showAll() ?? false, + rows: 5, + expandedRows: 12, + }) + expect(folding()).toMatchObject({ more: 1, toggle: "more", stale: false }) + live.store.toggleAll() + expect(folding()).toMatchObject({ more: 0, toggle: "fewer", stale: false }) + expect(folding().shown).toHaveLength(6) + + await live.setList(six.slice(0, 1)) + expect(folding().toggle).toBeUndefined() + expect(folding().stale).toBe(true) + live.store.foldAll() + expect(folding()).toMatchObject({ more: 0, stale: false }) + expect(folding().toggle).toBeUndefined() + + /** Back to six: it starts folded again, not expanded from before. */ + await live.setList(six) + expect(folding()).toMatchObject({ more: 1, toggle: "more" }) }) // Folding is a display convenience; it must never hide the shell the console is showing. diff --git a/packages/status/README.md b/packages/status/README.md index e257d2f5..55167293 100644 --- a/packages/status/README.md +++ b/packages/status/README.md @@ -12,20 +12,58 @@ get it with every other bay through the `opencode-cockpit` bundle. ## Install -```jsonc -// ~/.config/opencode/tui.json -{ "plugin": ["@opencode-cockpit/status"] } +```sh +opencode plugin @opencode-cockpit/status@0.8.0 --global --force # OpenCode 1 +opencode plugin add @opencode-cockpit/status@0.8.0 # OpenCode 2 ``` -That's enough. Without any configuration you get a line under the conversation carrying what -OpenCode does not already tell you. +On OpenCode 1 that writes both entries — the table in `tui.json`, and in `opencode.json` the agent +side that `/status-setup` needs. On OpenCode 2, run `opencode service restart` after installing or +updating: its background service loads plugins only when it starts. + +That's enough. Without any configuration you get a table at the top of the sidebar carrying what +OpenCode's own Context block says, better. `/status-setup` has the agent change it with you. ## What it shows by default, and why -OpenCode's own furniture already carries a lot: its footer has the path, the branch and the token -count; its sidebar has the context percentage and the spend; its prompt has the agent and the model. +``` +Status +████████████████ +tokens 85.2k · 43% +in 265 · 0% +out 60 · 0% +cache 84.9k · 100% +────────────── +spend $26.24 +avail $173.76 · 87% left +────────────── +git 5f +312 -48 +``` + +The `sidebar` preset, and the default since 0.9: how full the window is as one solid bar, the tokens +broken into named rows with their share of it, a proxy's budget, and what is uncommitted — the work +not saved anywhere yet (`"against": "branch"` counts the whole branch instead). A retry shows under +the bar; the turn's own clock does not, OpenCode already shows one. Every number gets a word, in a fixed column so the +figures line up; colour is a level (calm, then the warning, then the error), never a label. -The default line repeats one of those on purpose — the token count and the percentage — because it +A row with nothing to say is not drawn: `write` with no cache writes, `spend` and `avail` with no +proxy writing a budget (see [Proxies](#proxies-litellm-and-friends)), `diagnostics` while every MCP +and language server is healthy (`! name` in red when one breaks), and a hairline with nothing on one +side of it. The rows are built-ins — `title`, `context` with `"style": "solid"`, `session.status`, +`diagnostics`, `tokens` with `"style": "row"`, `in`, `out`, `cache`, `write`, `sep`, `spend`, +`avail`, `git` — so nothing is installed beside it. + +It sits beside OpenCode's own Context block; to keep only one, see +[Replacing OpenCode's own sidebar blocks](#replacing-opencodes-own-sidebar-blocks). + +### At the bottom instead + +`{ "status": { "sidebar": false } }` — or `"surface": "bottom"` — draws the `default` line under +the conversation. OpenCode's own furniture already carries a lot: its footer has the path, the +branch and the token count; its sidebar has the context percentage and the spend; its prompt has the +agent and the model. + +The bottom line repeats one of those on purpose — the token count and the percentage — because it says them better: a bar you read without looking, with the total's parts beside it, is a different instrument from `78.5K (39%)` in a corner. What stays out are the facts a second copy adds nothing to: the path, the branch, the model, the spend. @@ -49,25 +87,90 @@ in both places. ## A whole line by name -Composing fourteen segments is a design exercise; most people want a good line. A preset is +Composing twenty-three segments is a design exercise; most people want a good line. A preset is built-ins only — nothing to install, nothing to write: ```jsonc -{ "statusline": { "preset": "default" } } +{ "status": { "preset": "default" } } ``` -`minimal` · `default` · `detailed` · `sidebar`. Anything you write beside one wins, so it is a -starting point and not a mode. See [`examples/`](./examples) for the modules to reach for when a -preset is not enough. +| Preset | Surface | What you get | +| --- | --- | --- | +| `sidebar` | sidebar | the table above — the default | +| `minimal` | bottom | how full the context is, and what changed | +| `default` | bottom | the bar, where the tokens went, what changed, how long | +| `detailed` | bottom | everything the built-ins know, for a wide window | + +Anything you write beside one wins, so it is a starting point and not a mode. A name that is not a +preset draws the surface's own line, with a `!` row above it naming the presets there are. See +[`examples/`](./examples) for the modules to reach for when a preset is not enough. + +### Changing a row or two + +`override` changes the preset's segments by name and keeps every other row, so the line goes on +following the preset. `false` drops a segment, a name swaps it in the same place, an object merges +into its settings: + +```jsonc +{ + "status": { + "override": { + "git": { "against": "branch" }, // the branch's whole diff, not what is uncommitted + "write": false, // no cache-write row + "session.status": { "working": true } // the turn's clock as well as a retry + } + } +} +``` + +A change applies to every segment of that name (`"sep": false` drops every hairline). `segments` is +still the whole list, replacing the preset's — write it only to build a different line; with both, +the override applies to `segments`. A line in `lines` takes its own `override`. A name that matches no +segment is a `!` row: `override "gti" matches no segment in the sidebar preset — did you mean "git"?` + +## `/status-setup` + +Type it, or just ask ("put the statusline at the bottom"), and the agent sets the line up with you +through the `status-setup` skill shipped in this package (`skills/status-setup/`): it reads what is +written now with `cockpit_settings`, fixes old names first, offers a preset to start from, asks what +you want, writes only what differs from the defaults, and checks the line in the preview that came +with your install — `cockpit_settings` names it under Previews — never `bunx`, which fetches another +release. `/statusline`, its name until 0.9, still works for one release and says the new +name. Both come from Status's agent side (`@opencode-cockpit/status/server`), which the bundle +includes and the package's install line adds. + +## Looking at it before a restart + +```sh +bunx @opencode-cockpit/status preview --config ./status.json # this file, read as OpenCode reads it +bunx @opencode-cockpit/status preview --surface sidebar # draw there, whatever the file says +bunx @opencode-cockpit/status preview --debug --state fresh # ✓name drew · ✗name drew nothing · ?name no such segment +``` + +`--config` reads the file through the same loader as the plugin — `preset`, `sidebarRows`, +`override`, the `!` rows and all — in place of your global config. `--config -` reads a candidate on +stdin as the file it is meant to become (`--as global`, the default, or `--as project`), with the +other file read beside it — how the `status-setup` skill looks at a change before writing it, +without a temporary file: + +```sh +cat <<'EOF' | bunx @opencode-cockpit/status preview --config - --debug +{ "status": { "override": { "write": false } } } +EOF +``` + +The first line says what was read: `config: (stdin, as ~/.config/opencode-cockpit/config.json)`. The +sidebar is drawn 34 columns wide unless `--width` says otherwise; `--watch` redraws on every save. ## Configuration -`~/.config/opencode-cockpit/config.json` for every project, `/.cockpit.json` for one, and -the plugin entry itself beats both. +The `status` section of `~/.config/opencode-cockpit/config.json` for every project, of +`/.cockpit.json` for one, and the plugin entry itself beats both. Comments and trailing +commas are fine. ```jsonc { - "statusline": { + "status": { "surface": "bottom", "separator": " │ ", "segments": [ @@ -83,20 +186,32 @@ the plugin entry itself beats both. A segment is a built-in's name, or that name with settings. Unknown names are skipped rather than fatal, so a config written against a newer version costs you a segment and not the line. +The keys every bay shares work here too: `enabled`, `sidebar` (`false` draws at the bottom), +`sidebarRows` (rows the column draws before the lowest-priority ones give way; the table's own is +14, any other column's 8). Where the block sits among the others is the top-level `sidebar` list — +`["status", "subagents", "shell", "trail", "trust"]` — and nowhere else. + +**What is not read says so.** `"statusline"` (the section's name before 0.9), Status's keys at the +file's root, a bay-level `maxRows` (now `sidebarRows`) and `sidebarOrder` are no longer read; each is +a `!` row at the top of the column, `! settings: "statusline" is no longer read — run +/cockpit-setup`, until the file is fixed. So are a file that is not valid JSON, a top-level name +nothing reads, an entry in the `sidebar` list that is not a bay, a value of the wrong kind, and a +module that would not load. + ### Surfaces Two, each with a job. | `surface` | Where | Good for | | --- | --- | --- | +| `sidebar` | the sidebar, stacked vertically — the default | a table: every figure with its word | | `bottom` | full-width line under the conversation | everything, when no sidebar is open | -| `sidebar` | the sidebar, stacked vertically by default | the time dimension: trends, composition | Use `lines` for more than one at once: ```jsonc { - "statusline": { + "status": { "lines": [ { "surface": "bottom", "segments": ["git.diff", "todo", "session.time"] }, { "surface": "sidebar", "segments": ["context", "cost"] } @@ -113,7 +228,8 @@ surface up with OpenCode's own content. Segments carry a priority, and a line too wide for its surface drops the lowest-priority ones until it fits. How full the context is survives a 60-column window; the version string does not. Set -`priority` on any segment to change what goes first. A vertical line drops by `maxRows` instead. +`priority` on any segment to change what goes first. A column drops by `sidebarRows` (a line's own +`maxRows`) instead, and says how many with a `↳ N more` row. ## Built-in segments @@ -123,13 +239,18 @@ it fits. How full the context is survives a 60-column window; the version string | `git.branch` | current branch, dimmed on the default branch | | | `git.diff` | `+150 / -30` — what is uncommitted: `git diff --shortstat HEAD` | | | `model` | `claude-opus-5` | `full` | -| `context` | how full the window is | `style`: `percent` \| `bar` \| `gradient` \| `split`, `width`, `warnAt`, `dangerAt` | -| `tokens` | `78.5k tok` | | +| `context` | how full the window is | `style`: `percent` \| `bar` \| `solid` \| `gradient` \| `split`, `width`, `warnAt`, `dangerAt` | +| `tokens` | `78.5k tok`; `tokens 85.2k · 43%` as a table row | `format`, `style`: `parts` \| `row` | +| `title` | `Status`, bold: a column's heading | `text` | +| `in` · `out` · `cache` · `write` | `cache 84.9k · 100%` — one part of the window and its share; nothing when zero | | +| `sep` | a hairline between groups, drawn only with a row on either side | `width` | +| `spend` · `avail` | `spend $26.24`, `avail $173.76 · 87% left` — a proxy's budget; nothing without one | `file` | +| `git` | `git 5f +312 -48` — what is uncommitted; `"against": "branch"` counts the branch against where it forked (`… vs main`) | | | `cost` | session spend | `currency`, `showZero` | | `todo` | `3/7 todo` | `showComplete` | -| `session.status` | `working 1m02s` since the prompt, or a retry and its countdown | | +| `session.status` | `working 1m02s` since the prompt, or a retry and its countdown; `"working": false` (the sidebar preset) keeps only the retry | | | `session.time` | the session's age, or with `of: "turn"` how long the last answer took | `of`: `session` \| `turn`, `coarse` | -| `diagnostics` | unhealthy LSP and MCP servers | | +| `diagnostics` | `! name` for an unhealthy MCP or language server (OpenCode 2: MCP only); nothing while all are healthy | | | `version` | this bay's version | | | `text` | literal text | `value` | | `command` | the output of a shell command | `name`, `row` | @@ -169,20 +290,29 @@ behind a proxy — see [Proxies](#proxies-litellm-and-friends). ## Replacing OpenCode's own sidebar blocks -Each block of OpenCode's sidebar is an internal plugin, and `tui.json` can switch one off: +Each block of OpenCode's sidebar is an internal plugin that its config can switch off. The names +differ by version: ```jsonc -// ~/.config/opencode/tui.json +// OpenCode 1 — ~/.config/opencode/tui.json { "plugin": ["@opencode-cockpit/status"], "plugin_enabled": { "internal:sidebar-context": false } } ``` -That removes the host's own `Context / tokens / % used / spent` block, leaving the space to a -`sidebar` line of your own — the honest way to avoid reading the same figure twice. The same works -for `internal:sidebar-files`, `-todo`, `-lsp`, `-mcp`, `-footer`, and the home screen's -`internal:home-footer` and `internal:home-tips`. +```jsonc +// OpenCode 2 — ~/.config/opencode/cli.json +{ "plugins": ["@opencode-cockpit/status", "-opencode.sidebar.context"] } +``` + +That removes the host's own `Context / tokens / % used / spent` block, leaving the space to the +table — the honest way to avoid reading the same figure twice. `/cockpit-setup` offers it when Status +draws in the sidebar. On OpenCode 1 the same works for `internal:sidebar-files`, `-lsp`, `-mcp`, +`-footer`, `-todo`, and the home screen's `internal:home-footer` and `internal:home-tips`. OpenCode 2's +sidebar has three blocks of its own — `opencode.sidebar.context`, `opencode.sidebar.mcp`, +`opencode.sidebar.footer` — and no LSP or Todo block; an `internal:` id there does nothing. Leave the +Todo block on: nothing in Cockpit replaces it. ## What you can draw @@ -224,7 +354,7 @@ export default { ```jsonc { - "statusline": { + "status": { "modules": ["~/.config/opencode-cockpit/statusline.ts"], "segments": ["burn", "git.diff"] } @@ -244,13 +374,12 @@ custom segment exactly as testable as a built-in. It is loaded once and its segm every repaint, so it can keep history — which is how a sparkline or a rate is possible at all. Returning `undefined` hides the segment. A segment that throws loses only its own place on the line. -A module that will not load raises a toast naming the file, rather than silently dropping segments. +A module that will not load raises a toast naming the file and keeps a `!` row above the line, +rather than silently dropping segments. **Worked examples** live in [`examples/`](./examples): `bottom.ts` is a complete line for a window -with no sidebar; `sidebar.ts` is a quiet column beside OpenCode's own Context block; -`sidebar-full.ts` replaces that block; `sidebar-budget.ts` is that column drawn as a table, with a -budget from a proxy and the branch's diff; `gallery.ts` draws every technique at once. All of them -are loaded and asserted by the test suite, so none of them can rot. +with no sidebar; `gallery.ts` draws every technique at once. Both are loaded and asserted by the +test suite, so neither can rot. The sidebar examples became the `sidebar` preset in 0.9. ## Your Claude Code statusline @@ -258,7 +387,7 @@ A shell command, fed the same JSON on stdin that Claude Code's `statusLine` hook ```jsonc { - "statusline": { + "status": { "commands": { "mine": { "run": "~/.claude/statusline.sh", "intervalMs": 2000 } }, "segments": [{ "type": "command", "name": "mine" }] } @@ -303,8 +432,15 @@ config: ``` Without them the `cost` and `context` segments stay silent instead of reporting `$0.00` and `0%`. -If your proxy knows the real spend — LiteLLM's `/spend` endpoints do — a `command` segment can read -it, which is better than any locally multiplied estimate. +If your proxy knows the real spend, that is better than any locally multiplied estimate. The table's +`spend` and `avail` rows read it from a file: LiteLLM's IAP plugin writes `{ "baseline", "delta", +"cap" }` to `~/.cache/opencode-litellm-iap/spend.json`, and anything that writes the same shape will +do (`{ "type": "spend", "file": "~/elsewhere.json" }` points them at it). With no file they draw +nothing. A `command` segment can read anything else — LiteLLM's `/spend` endpoints, say. + +```sh +bunx @opencode-cockpit/status preview --proxy ~/.cache/opencode-litellm-iap/spend.json +``` ## Troubleshooting diff --git a/packages/status/examples/README.md b/packages/status/examples/README.md index ab231e7f..98298f6d 100644 --- a/packages/status/examples/README.md +++ b/packages/status/examples/README.md @@ -4,20 +4,20 @@ Start with a **preset** — a whole line by name, built-ins only, nothing to ins ```jsonc // ~/.config/opencode-cockpit/config.json -{ "statusline": { "preset": "default" } } +{ "status": { "preset": "default" } } ``` | Preset | Surface | What you get | | --- | --- | --- | +| `sidebar` | sidebar | a table: the window, where the tokens went, a proxy's budget, the branch's diff — the default | | `minimal` | bottom | how full the context is, and what changed | | `default` | bottom | the bar, where the tokens went, what changed, how long | | `detailed` | bottom | everything the built-ins know, for a wide window | -| `sidebar` | sidebar | a quiet column beside OpenCode's own blocks | Anything you write beside a preset wins, so it is a starting point rather than a mode: ```jsonc -{ "statusline": { "preset": "default", "separator": " " } } +{ "status": { "preset": "default", "separator": " " } } ``` ## When a preset is not enough @@ -28,14 +28,11 @@ name. Every one is loaded and asserted by the test suite, so none of them can ro | File | For | Segments | | --- | --- | --- | | [`bottom.ts`](./bottom.ts) | the whole statusline on one line, no sidebar open | `bar` `filling` `rate` `cached` `tokens` | -| [`sidebar.ts`](./sidebar.ts) | a small column **beside** OpenCode's Context block | `bar` `split` `changes` | -| [`sidebar-full.ts`](./sidebar-full.ts) | a column that **replaces** that block — turn it off with `plugin_enabled` | `bar` `window` `cached` `spend` `elapsed` `changes` `todo` | -| [`sidebar-budget.ts`](./sidebar-budget.ts) | a column as a **table**: fixed label gutter, one bar, a proxy's budget, the branch diff | `title` `bar` `tokens` `in` `out` `cache` `write` `sep` `spend` `avail` `git` | | [`gallery.ts`](./gallery.ts) | not a statusline — every technique the renderer can draw, labelled | all of them | ```jsonc { - "statusline": { + "status": { "modules": ["~/.config/opencode-cockpit/modules/bottom.ts"], "surface": "bottom", "segments": ["bar", "filling", "rate", "cached", "session.diff", "session.time"] @@ -43,18 +40,24 @@ name. Every one is loaded and asserted by the test suite, so none of them can ro } ``` +The sidebar examples (`sidebar.ts`, `sidebar-full.ts`, `sidebar-budget.ts`) became the `sidebar` +preset in 0.9; its rows — `title`, `in`, `out`, `cache`, `write`, `sep`, `spend`, `avail`, `git` — are +built-ins. A config still pointing at one in this folder gets a `!` row saying so. A copy you made +elsewhere keeps working. + ## Look at it before you ship it ```sh bunx @opencode-cockpit/status preview --watch # redraws on every save bunx @opencode-cockpit/status preview --state fresh # before the first reply bunx @opencode-cockpit/status preview --debug # mark segments that drew nothing +bunx @opencode-cockpit/status preview --proxy none # as someone without a proxy sees it bunx @opencode-cockpit/status preview --module examples/gallery.ts ``` -The six sample sessions are the states a design gets wrong: `fresh` (no model, no tokens — most -designs render a wall of zeroes), `working`, `full`, `unpriced` (behind a proxy, nothing declared), -`retrying`, and `empty`. +The sample sessions are the states a design gets wrong: `fresh` (no model, no tokens — most designs +render a wall of zeroes), `working`, `busy`, `uncached`, `full`, `unpriced` (behind a proxy, nothing +declared), `retrying`, and `empty`. -The taste rules this bay learned the expensive way live in -[`../skills/statusline-design/`](../skills/statusline-design/SKILL.md). +The taste rules this bay learned the expensive way live in the `status-setup` skill, at +[`../skills/status-setup/references/design.md`](../skills/status-setup/references/design.md). diff --git a/packages/status/examples/bottom.ts b/packages/status/examples/bottom.ts index 27fa3053..a4d30133 100644 --- a/packages/status/examples/bottom.ts +++ b/packages/status/examples/bottom.ts @@ -12,7 +12,7 @@ * densest of the examples on purpose -- the bottom line is the only surface with real width. * * { - * "statusline": { + * "status": { * "modules": [""], * "lines": [ * { "surface": "bottom", "separator": " │ ", diff --git a/packages/status/examples/sidebar-budget.ts b/packages/status/examples/sidebar-budget.ts deleted file mode 100644 index 4f373039..00000000 --- a/packages/status/examples/sidebar-budget.ts +++ /dev/null @@ -1,255 +0,0 @@ -/** - * A sidebar built as a table: a context header, the window as one solid bar, the tokens broken - * into named rows, a budget read from a proxy, and the branch's whole diff. - * - * This is the layout a user arrived at after five rejected iterations, kept here because the - * reasons are worth more than the rows: every number gets a word, the labels are a fixed column - * so the values line up, the bar is solid rather than dashed, and the groups are separated by - * hairlines rather than headings — a heading cannot know whether the rows under it will draw. - * - * And, from seeing it on a real session: the word is the label, so there is no coloured square - * beside it saying the same thing in a colour nobody can decode; a row whose figure is zero is not - * drawn (`write 0 · 0%` said nothing, at length); and colour is a level — the bar, the percentage - * and the budget are calm until a threshold, then the warning, then the error — never a category. - * - * { - * "statusline": { - * "modules": [""], - * "lines": [{ - * "surface": "sidebar", - * "maxRows": 13, - * "segments": ["title", "bar", "tokens", "in", "out", "cache", "write", - * "sep", "spend", "avail", "sep", "git"] - * }] - * } - * } - * - * It replaces OpenCode's own Context block, so turn that off: - * { "plugin_enabled": { "internal:sidebar-context": false } } - * - * The budget rows read a file a proxy writes. Without one they stay silent, which is right in a - * statusline and unhelpful while you are designing — `"demo": true` on either segment fills in - * sample figures so the layout can be looked at. - */ - -import { execFile } from "node:child_process" -import { readFileSync } from "node:fs" -import { homedir } from "node:os" -import { join } from "node:path" -import type { CustomModule, Piece, Run, SegmentConfig, StatusContext } from "@opencode-cockpit/status/segment" -import { compact, contextRatio, contextUsed, GAUGE, gaugeTone } from "@opencode-cockpit/status/segment" - -/** - * The label column, padded so every value starts in the same place. The word is the label. Seven: - * the longest label is `tokens`, and at six it ran into its own figure (`tokens167.8k`). - */ -const LABEL = 7 -function row(label: string, value: Run[]): { runs: Run[] } { - return { runs: [{ text: label.padEnd(LABEL), tone: "muted" }, ...value] } -} - -const BAR = 16 - -/** - * A level's tone: nothing until it is worth a colour, then the gauge rule's warning or error. Calm - * is left to the bar, so a column of figures is not a column of greens. - */ -const level = (ratio: number): Run["tone"] => (ratio >= GAUGE.warnAt ? gaugeTone(ratio) : "text") - -/** What each token row needs: its own figure, and its share of the window. Zero is not a row. */ -function share(ctx: StatusContext, value: number) { - const total = contextUsed(ctx.session?.tokens) - if (total === 0 || value === 0) return undefined - return [ - { text: compact(value), tone: "text" as const }, - { text: ` · ${Math.round((value / total) * 100)}%`, tone: "muted" as const }, - ] -} - -/** - * The spend a proxy reports. LiteLLM's IAP plugin writes this; anything that writes - * `{ baseline, delta, cap }` will do. Re-read at most every five seconds — the file is tiny, but - * a statusline redraws every second and this is not worth a syscall each time. - */ -interface Spend { - baseline?: number - delta?: number - cap?: number -} -let cached: { at: number; spend: Spend | undefined } | undefined -function spendState(config: SegmentConfig): { total: number; cap: number } | undefined { - if (config.demo === true) return { total: 26.24, cap: 200 } - const now = Date.now() - if (!cached || now - cached.at > 5000) { - const file = - typeof config.file === "string" - ? config.file - : join(homedir(), ".cache", "opencode-litellm-iap", "spend.json") - try { - cached = { at: now, spend: JSON.parse(readFileSync(file, "utf8")) as Spend } - } catch { - cached = { at: now, spend: undefined } - } - } - const { baseline, delta, cap } = cached.spend ?? {} - if (typeof baseline !== "number" || typeof cap !== "number" || cap <= 0) return undefined - return { total: baseline + (typeof delta === "number" ? delta : 0), cap } -} - -/** - * The branch's whole diff against where it forked: every commit on the branch plus uncommitted - * edits — what a reviewer would read, rather than what this session happened to touch. Git runs - * away from the draw path and the row shows whatever the last finished reading produced. - */ -interface BranchDiff { - files: number - added: number - removed: number -} -let branch: BranchDiff | undefined -let asking = false -let askedAt = 0 -function refreshBranch(ctx: StatusContext): void { - if (asking || Date.now() - askedAt < 10_000) return - asking = true - const base = ctx.defaultBranch ?? "main" - const run = execFile as unknown as ( - cmd: string, - args: string[], - opts: { timeout: number; cwd: string }, - cb: (err: Error | null, out: string) => void, - ) => void - const opts = { timeout: 3000, cwd: ctx.worktree } - run("git", ["merge-base", base, "HEAD"], opts, (err, fork) => { - if (err || !fork.trim()) { - asking = false - askedAt = Date.now() - return - } - run("git", ["diff", "--shortstat", fork.trim()], opts, (err2, out) => { - asking = false - askedAt = Date.now() - if (err2) return - const found = /(\d+) files? changed(?:, (\d+) insertions?)?(?:, (\d+) deletions?)?/.exec(out) - if (!found) return - branch = { - files: Number(found[1]), - added: Number(found[2] ?? 0), - removed: Number(found[3] ?? 0), - } - }) - }) -} - -export default { - segments: { - /** The column's subject, so the table below it has one. */ - title() { - return { runs: [{ text: "Context", tone: "text", bold: true }] } - }, - - /** - * One solid bar: filled cells in the gauge rule's tone, empty cells a solid dark track. Not `â–‘`, - * which reads as floating gaps, and not `─`, which reads as a row of dashes. One tone for the - * whole fill, not a gradient: green at 13% and olive at 12% was a colour nobody could read. - * - * No end caps. `â–•` and `â–�` are eighth-blocks whose ink sits against one edge of the cell, so an - * opening cap indents the row by most of a column and the bar stops lining up with the labels - * above and below it. The dark track already shows how far the bar could go. - * - * No figure beside it either: the `tokens` row directly below already reads the percentage out, - * and a number printed twice in a column of ten rows is the thing the eye catches on. - */ - bar(ctx: StatusContext) { - const ratio = contextRatio(ctx.session) - if (ratio === undefined) return undefined - const filled = Math.round(ratio * BAR) - return { - runs: [ - { text: "â–ˆ".repeat(filled), tone: gaugeTone(ratio) }, - { text: "â–ˆ".repeat(BAR - filled), tone: "border" }, - ], - } - }, - - /** The whole window, and how full it is. */ - tokens(ctx: StatusContext) { - const total = contextUsed(ctx.session?.tokens) - if (total === 0) return undefined - const ratio = contextRatio(ctx.session) - return row("tokens", [ - { text: compact(total), tone: "text" }, - ...(ratio === undefined ? [] : [{ text: ` · ${Math.round(ratio * 100)}%`, tone: level(ratio) }]), - ]) - }, - - /** Fresh prompt tokens: neither cached nor generated. */ - in(ctx: StatusContext) { - const value = share(ctx, ctx.session?.tokens?.input ?? 0) - return value && row("in", value) - }, - - /** What the model wrote, reasoning included. */ - out(ctx: StatusContext) { - const tokens = ctx.session?.tokens - const value = share(ctx, (tokens?.output ?? 0) + (tokens?.reasoning ?? 0)) - return value && row("out", value) - }, - - /** Served from the prompt cache — cheap, and usually most of the window. */ - cache(ctx: StatusContext) { - const value = share(ctx, ctx.session?.tokens?.cache.read ?? 0) - return value && row("cache", value) - }, - - /** Written into the cache this session — a one-time premium each. Not the same as "out". */ - write(ctx: StatusContext) { - const value = share(ctx, ctx.session?.tokens?.cache.write ?? 0) - return value && row("write", value) - }, - - /** A hairline, to group without a heading that might strand itself. */ - sep() { - return { runs: [{ text: "─".repeat(14), tone: "border", dim: true }] } - }, - - /** Spent so far against the cap: a figure, coloured only once the budget is a level to watch. */ - spend(_ctx: StatusContext, config: SegmentConfig) { - const state = spendState(config) - if (!state) return undefined - return row("spend", [{ text: `$${state.total.toFixed(2)}`, tone: level(state.total / state.cap) }]) - }, - - /** What is left, and the share of the cap that decides whether it earns a colour. */ - avail(_ctx: StatusContext, config: SegmentConfig) { - const state = spendState(config) - if (!state) return undefined - const left = Math.max(0, state.cap - state.total) - const tone = level(state.total / state.cap) - return row("avail", [ - { text: `$${left.toFixed(2)}`, tone }, - { text: ` · ${Math.round((left / state.cap) * 100)}% left`, tone: tone === "text" ? "muted" : tone }, - ]) - }, - - /** The branch as a reviewer will see it, not as this session left it. */ - git(ctx: StatusContext, config: SegmentConfig): Piece | undefined { - if (config.demo === true) { - return row("git", [ - { text: "3f", tone: "muted" }, - { text: " +42", tone: "success" }, - { text: " -7", tone: "error" }, - { text: ` vs ${ctx.defaultBranch ?? "main"}`, tone: "muted", dim: true }, - ]) - } - refreshBranch(ctx) - if (!branch || branch.files === 0) return undefined - return row("git", [ - { text: `${branch.files}f`, tone: "muted" }, - { text: ` +${compact(branch.added)}`, tone: "success" }, - { text: ` -${compact(branch.removed)}`, tone: "error" }, - { text: ` vs ${ctx.defaultBranch ?? "main"}`, tone: "muted", dim: true }, - ]) - }, - }, -} satisfies CustomModule diff --git a/packages/status/examples/sidebar-full.ts b/packages/status/examples/sidebar-full.ts deleted file mode 100644 index a4056f68..00000000 --- a/packages/status/examples/sidebar-full.ts +++ /dev/null @@ -1,160 +0,0 @@ -/** - * The sidebar as the whole instrument panel, for people who would rather not read a line under - * the prompt at all. - * - * This one is meant to *replace* OpenCode's own Context block rather than sit beside it, so it - * carries the figures that block carried — tokens, percentage, spend — and then the ones it never - * did. Turn the host's block off and give the column to this: - * - * // ~/.config/opencode/tui.json - * { "plugin": ["opencode-cockpit"], "plugin_enabled": { "internal:sidebar-context": false } } - * - * // ~/.config/opencode-cockpit/config.json - * { - * "statusline": { - * "modules": [""], - * "surface": "sidebar", - * "segments": [ - * { "type": "text", "value": "CONTEXT", "color": "border" }, - * "gauge", "window", "cache", - * { "type": "text", "value": "SPEND", "color": "border" }, - * "spend", - * { "type": "text", "value": "SESSION", "color": "border" }, - * "elapsed", "work", "tasks" - * ], - * "maxRows": 14 - * } - * } - * - * Every row is self-labelled and built to read at about thirty characters, and there are - * deliberately no section headings: a heading cannot know whether the rows under it will draw - * anything, so on an unpriced model a "SPEND" title strands itself above nothing. - * - * `tasks` is here but left out of the config above, because OpenCode's own Todo list carries the - * task names and this is only a count. Add it if you switch that list off too - * (`internal:sidebar-todo`). - */ - -import type { CustomModule, Run, StatusContext } from "@opencode-cockpit/status/segment" -import { - compact, - contextRatio, - contextUsed, - gradient, - money, - preciseDuration, -} from "@opencode-cockpit/status/segment" - -/** Spend samples, so a rate can be shown beside the total. */ -const samples: { at: number; cost: number }[] = [] - -/** A row of label and value, so a column of them lines up as a table would. */ -function row(label: string, value: Run[]): { runs: Run[] } { - return { runs: [{ text: `${label} `, tone: "muted", dim: true }, ...value] } -} - -export default { - segments: { - /** The headline figure, and the one the host's block led with. */ - bar(ctx: StatusContext, config) { - const ratio = contextRatio(ctx.session) - if (ratio === undefined) return undefined - const width = typeof config.width === "number" ? config.width : 16 - const filled = Math.round(ratio * width) - const runs: Run[] = [] - for (let cell = 0; cell < width; cell++) { - runs.push( - cell < filled - ? { text: "â–ˆ", color: gradient((cell + 1) / width) } - : { text: "â–‘", tone: "border" as const }, - ) - } - runs.push({ - text: ` ${Math.round(ratio * 100)}%`, - color: gradient(ratio), - bold: ratio >= 0.85, - }) - return { runs } - }, - - /** What the percentage is a percentage of — the denominator the bar hides. */ - window(ctx: StatusContext) { - const used = contextUsed(ctx.session?.tokens) - const limit = ctx.session?.model?.contextLimit - if (used === 0) return undefined - return limit - ? row("of", [ - { text: compact(used), tone: "text" }, - { text: ` / ${compact(limit)}`, tone: "muted" }, - ]) - : row("used", [{ text: `${compact(used)} tok`, tone: "text" }]) - }, - - /** - * What was replayed from cache against what had to be sent fresh — the two figures side by - * side rather than a share of them. A share reads as "100%" for most of a cached session, - * which looks like a bug even when it is arithmetic. - */ - cached(ctx: StatusContext) { - const tokens = ctx.session?.tokens - if (!tokens || contextUsed(tokens) === 0) return undefined - const fresh = tokens.input + tokens.output + tokens.reasoning - return row("cache", [ - { text: compact(tokens.cache.read), tone: "success" }, - { text: " · fresh ", tone: "muted", dim: true }, - { text: compact(fresh), tone: "info" }, - ]) - }, - - /** What the session has cost, and what it is costing. Silent where nobody declared prices. */ - spend(ctx: StatusContext) { - const session = ctx.session - if (!session?.priced) return undefined - const last = samples[samples.length - 1] - if (!last || ctx.now - last.at >= 1000) samples.push({ at: ctx.now, cost: session.cost }) - if (samples.length > 60) samples.shift() - - const first = samples[0] - const latest = samples[samples.length - 1] - const runs: Run[] = [{ text: money(session.cost), tone: "warning" }] - if (first && latest && latest.at > first.at) { - const perMinute = ((latest.cost - first.cost) / (latest.at - first.at)) * 60_000 - if (perMinute >= 0.005) { - runs.push({ text: ` · $${perMinute.toFixed(2)}/min`, tone: "muted", dim: true }) - } - } - return { runs } - }, - - /** How long this has been going, in a unit a person reads without converting. */ - elapsed(ctx: StatusContext) { - const started = ctx.session?.startedAt - if (started === undefined) return undefined - return row("for", [{ text: preciseDuration(ctx.now - started), tone: "muted" }]) - }, - - /** What the session has done to the tree. */ - changes(ctx: StatusContext) { - const diff = ctx.session?.diff - if (!diff || diff.files === 0) return undefined - return row("diff", [ - { text: `+${compact(diff.additions)}`, tone: "success" }, - { text: ` -${compact(diff.deletions)}`, tone: "error" }, - { text: ` · ${diff.files}f`, tone: "muted", dim: true }, - ]) - }, - - /** - * Work outstanding, for a sidebar where OpenCode's own Todo list is switched off. With that - * list on, this is a worse copy of it — the list carries the task names. - */ - todo(ctx: StatusContext) { - const todo = ctx.session?.todo - if (!todo || todo.total === 0 || todo.completed === todo.total) return undefined - return row("todo", [ - { text: `${todo.completed}/${todo.total}`, tone: "text" }, - { text: ` · ${todo.total - todo.completed} left`, tone: "muted", dim: true }, - ]) - }, - }, -} satisfies CustomModule diff --git a/packages/status/examples/sidebar.ts b/packages/status/examples/sidebar.ts deleted file mode 100644 index 7b554507..00000000 --- a/packages/status/examples/sidebar.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * A small, quiet sidebar: a colored context bar and the two figures behind it. - * - * The sidebar sits beside OpenCode's own Context block, which already gives you the token count, - * the percentage and the spend. So this one does not repeat them -- it draws the bar those numbers - * describe, and adds the two things the host leaves out: how the window is being used, and what - * the session has changed. - * - * { - * "statusline": { - * "modules": [""], - * "surface": "sidebar", - * "segments": ["bar", "split", "changes"] - * } - * } - * - * `stack` defaults to vertical here, so it does not need to be written. - */ - -import type { CustomModule, Run, StatusContext } from "@opencode-cockpit/status/segment" -import { compact, contextRatio, contextUsed, gradient } from "@opencode-cockpit/status/segment" - -/** A row with a quiet label, so a column of them lines up as a table would. */ -function row(label: string, value: Run[]): { runs: Run[] } { - return { runs: [{ text: `${label} `, tone: "muted", dim: true }, ...value] } -} - -export default { - segments: { - /** - * The context window, coloured cell by cell. The figure beside it is the host's own, so this - * is the one place the bay repeats something -- a bar with no number is hard to read at a - * glance, and the percentage is two characters. - */ - bar(ctx: StatusContext, config) { - const ratio = contextRatio(ctx.session) - if (ratio === undefined) return undefined - const width = typeof config.width === "number" ? config.width : 14 - const filled = Math.round(ratio * width) - const runs: Run[] = [] - for (let cell = 0; cell < width; cell++) { - runs.push( - cell < filled - ? { text: "â–ˆ", color: gradient((cell + 1) / width) } - : { text: "â–‘", tone: "border" as const }, - ) - } - runs.push({ text: ` ${Math.round(ratio * 100)}%`, color: gradient(ratio) }) - return { runs } - }, - - /** - * What the window is made of: cache, fresh input, output. A coloured rule per part rather - * than a filled chip, so a share of nothing is a mark rather than an empty box. - */ - split(ctx: StatusContext) { - const tokens = ctx.session?.tokens - const total = contextUsed(tokens) - if (!tokens || total === 0) return undefined - const share = (n: number) => `${Math.round((n / total) * 100)}%` - return row("split", [ - { text: "â–Œ", tone: "success" }, - { text: share(tokens.cache.read + tokens.cache.write), tone: "muted" }, - { text: " â–Œ", tone: "info" }, - { text: share(tokens.input), tone: "muted" }, - { text: " â–Œ", tone: "accent" }, - { text: share(tokens.output + tokens.reasoning), tone: "muted" }, - ]) - }, - - /** What the session has done to the working tree, which the host never mentions. */ - changes(ctx: StatusContext) { - const diff = ctx.session?.diff - if (!diff || diff.files === 0) return undefined - return row("diff", [ - { text: `${diff.files}f`, tone: "muted" }, - { text: ` +${compact(diff.additions)}`, tone: "success" }, - { text: ` -${compact(diff.deletions)}`, tone: "error" }, - ]) - }, - }, -} satisfies CustomModule diff --git a/packages/status/package.json b/packages/status/package.json index becd5b20..d15718dc 100644 --- a/packages/status/package.json +++ b/packages/status/package.json @@ -24,6 +24,10 @@ "prompt" ], "exports": { + "./server": { + "types": "./types/server.d.ts", + "default": "./dist/server.js" + }, "./tui": { "types": "./types/tui/index.d.ts", "default": "./dist/tui/index.js" @@ -46,6 +50,7 @@ "bun": ">=1.3.5" }, "files": [ + "server.js", "tui.js", "dist", "types", @@ -59,7 +64,7 @@ }, "dependencies": { "@opencode-cockpit/client": "workspace:*", - "@opencode-ai/plugin": "1.18.31" + "@opencode-ai/plugin": "1.18.33" }, "devDependencies": { "@opentui/core": "0.4.5", diff --git a/packages/status/server.js b/packages/status/server.js new file mode 100644 index 00000000..10f25fa6 --- /dev/null +++ b/packages/status/server.js @@ -0,0 +1,6 @@ +/** + * OpenCode 2 finds a plugin configured by *path* by the files at its root — `/tui`, + * `/server` — rather than through `exports` (docs/opencode/v2.md). A package installed by + * name resolves through `exports` as before; this file is only the door for the path case. + */ +export { default } from "./dist/server.js" diff --git a/packages/status/skills/status-setup/SKILL.md b/packages/status/skills/status-setup/SKILL.md new file mode 100644 index 00000000..dacb498c --- /dev/null +++ b/packages/status/skills/status-setup/SKILL.md @@ -0,0 +1,166 @@ +--- +name: status-setup +description: Set up or design the Status bay of opencode-cockpit (its statusline) with the user - the table in the sidebar or a line under the prompt, which segments it shows, a preset, a shell command or Claude Code statusline script as a segment, or a TypeScript segment module. Use it whenever the user runs /status-setup or /statusline, or asks to change what the statusline or the Status table shows, e.g. "put the statusline at the bottom", "show the model and cost", "use my Claude Code statusline", "make the status table shorter", "show git against the branch", "hide the write row", or edits the "status" section of config.json or .cockpit.json. For which Cockpit blocks show and in what order, use the cockpit-setup skill. +--- + +# Setting up the Status bay + +Status is Cockpit's statusline. By default it is a **table in the sidebar** (context bar, tokens, +spend, git); it can instead be **one line under the prompt**. It is set in the `"status"` section of +Cockpit's settings file. Most people want a good line, not a design exercise: start from a preset, +change only what they ask for, and look at the result before calling it done. + +## 1. Read the live state first + +Call **`cockpit_settings`**. Its `status` entry says whether Status is on, where it draws, every +`status` value and where it came from, and the file paths; its notices say what is not read. Do not +guess any of that from memory. + +## 2. Fix the notices first + +Old names are **not read**, so their values do nothing today: `"statusline"` → `"status"`, a +bay-level `maxRows` → `sidebarRows`, Status keys at the file's root → under `"status"`, a +`sidebarOrder` → removed (the order is the top-level `"sidebar"` list). Fix them in the file each +notice names, keep everything else, and say what changed in one line each. + +## 3. Offer a starting point + +Pre-select the one closest to what is written now (nothing written: the table). Each is the exact +`"status"` section to merge into the file: + +**The table** — the default: the sidebar table. Nothing to write: + +```json +{} +``` + +**One line** — under the prompt: the capacity bar, where the tokens went, what changed, how long: + +```json +{ "status": { "sidebar": false } } +``` + +**Minimal line** — under the prompt, only how full the context is and what changed: + +```json +{ "status": { "sidebar": false, "preset": "minimal" } } +``` + +**Detailed line** — under the prompt, everything the built-ins know, for a wide window: + +```json +{ "status": { "sidebar": false, "preset": "detailed" } } +``` + +**My Claude Code statusline** — the script they already have, unchanged, as a line under the prompt +(ask for its path and put it in `run`): + +```json +{ + "status": { + "sidebar": false, + "segments": [{ "type": "command", "name": "claude" }], + "commands": { "claude": { "run": "~/.claude/statusline.sh" } } + } +} +``` + +## 4. Ask only what matters + +One question at a time, the current value pre-selected. If you have a `question` tool, use it for +multiple choice; otherwise ask in plain text with numbered options. + +- Sidebar table or a line under the prompt (`"sidebar": false` puts it at the bottom). +- What to add or drop, in their words ("show the model", "no git") — then map it to segments. +- In the sidebar: how many rows before the rest fold (`sidebarRows`; 14 with the table). +- Icons on or off (`icons`), only if their terminal shows boxes or gaps. + +### A row or two: `override`, never a copy of the list + +To change, drop or swap a few rows, write **`override`**, keyed by segment name — every other row +keeps following the preset. **Do not copy the preset's list into `segments` to change one row**: +`segments` replaces the whole list, and the copy stops following the preset. `false` drops a +segment, a name swaps it in place, an object merges into its settings: + +| They say | Write | +| --- | --- | +| "show git against the branch, not uncommitted" | `{ "status": { "override": { "git": { "against": "branch" } } } }` | +| "hide the write row" | `{ "status": { "override": { "write": false } } }` | +| "show the working clock" | `{ "status": { "override": { "session.status": { "working": true } } } }` | +| "cost instead of spend" | `{ "status": { "override": { "spend": "cost" } } }` | +| "no hairlines" | `{ "status": { "override": { "sep": false } } }` | + +Merge it into what is written: an existing `override` keeps its other keys, and `preset` stays as +it is. Write `segments` only to build a **different** line — a new order, rows the preset does not +have; with both written, `override` applies to `segments`. A name that matches no segment is a `!` +row naming the closest one. + +Keys, every built-in segment, the presets, commands and modules are in +[references/settings.md](references/settings.md). Before you compose segments yourself, or write a +module, read [references/design.md](references/design.md): it holds the rules a good line follows, +learned the hard way, and they are not obvious. + +## 5. Look at it before you write it + +A statusline is judged in a terminal, not from a sentence. Use the preview that came with this +install: `cockpit_settings` prints its exact command under **Previews** (`bun "/…/dist/cli/preview.js"`) +— call it `` below. **Never run `bunx` or `npx @opencode-cockpit/status`**: they download the +newest published release, which may be older or newer than this install and read settings +differently (0.8 draws a bottom line at full width for a 0.9 sidebar config). If the tool lists no +preview, Status's agent side is not loaded: say so rather than reaching for `bunx`. + +**Before writing**, pipe the whole file as it will be after your change — every key already in it, +plus the change — into the preview with `--config -`. **Do not write a temporary file**: one outside +the project asks the user for permission, and one inside it is a stray file in their repo. The +preview reads stdin through the same loader and resolution OpenCode uses (`preset`, `sidebarRows`, +`override`, the `!` rows), as the file it will become — `--as global` (the default) or `--as project` +— with the other file read beside it, as OpenCode will. Run it from the project folder, and show the +user the exact command you ran and what it drew: + +```sh +cat <<'EOF' | --config - --state busy --debug +{ "status": { "preset": "sidebar", "override": { "git": { "against": "branch" } } } } +EOF +``` + +Then the same with `--state fresh`; `--as project` for a `.cockpit.json`; `--surface sidebar` (or +`bottom`) to force a surface. + +- The first line names what it read: `config: (stdin, as ~/.config/opencode-cockpit/config.json)`. + A `!` row is a notice to fix before writing. +- `--debug` names every row: `✓git` drew, `✗spend` ran and drew nothing (no data in that state), + `?gti` is no segment at all (a typo). +- The sidebar is drawn 34 columns wide, as in OpenCode; `--width` changes it. +- An unknown flag or a file it cannot read stops it with an error: fix the command, do not guess. + +Check `fresh` and `empty` (what a new session shows) and `full` (the widest numbers). For anything +custom, paste an ASCII mock and ask before writing it. + +## 6. OpenCode's own sidebar blocks + +These are OpenCode's settings in OpenCode's files: **ask first**, and keep everything else in the +file. `cockpit_settings` lists every block this version has, by its exact id, with its state now and +the exact edit — use those, never guess an id (`"-internal:sidebar-context"` does nothing on OpenCode 2). + +| Block | OpenCode 1 (`tui.json` → `"plugin_enabled": { "": false }`) | OpenCode 2 (`cli.json` → `"-"` in `"plugins"`) | What to say | +| --- | --- | --- | --- | +| Context | `internal:sidebar-context` | `opencode.sidebar.context` | with the table in the sidebar it says the same thing above it: suggest turning it off | +| MCP | `internal:sidebar-mcp` | `opencode.sidebar.mcp` | optional, neutral: the table's `diagnostics` row already warns when a server fails; `opencode mcp list` shows them all | +| Footer | `internal:sidebar-footer` | `opencode.sidebar.footer` | optional, neutral: the project's path and git branch at the bottom of the sidebar | +| LSP | `internal:sidebar-lsp` | — | optional, neutral | +| Files | `internal:sidebar-files` | — | optional, neutral | +| Todo | `internal:sidebar-todo` | — | **never suggest turning it off**: nothing in Cockpit replaces it | + +## 7. Write, verify, close + +- Write JSONC into the `"status"` section of the file they chose: the global + `~/.config/opencode-cockpit/config.json` by default, `/.cockpit.json` for this project only. + Only keys that differ from the defaults; keep comments and every other key. Write what you + previewed, nothing else. +- Call `cockpit_settings` again: it must say `Notices: none` (a key Status does not read shows up + there). A preset that does not exist, or an `override` name that matches no segment, is Status's own + `!` row, and a segment name that does not exist draws nothing: `preview --config -` shows the one + and `--debug` the other (`?name`), before a restart. +- Tell them what changed, that **it applies after restarting OpenCode**, and how to undo (remove + those keys and restart; `/status-setup` again any time). For which blocks show and in what order, + `/cockpit-setup`. diff --git a/packages/status/skills/status-setup/references/design.md b/packages/status/skills/status-setup/references/design.md new file mode 100644 index 00000000..7d174990 --- /dev/null +++ b/packages/status/skills/status-setup/references/design.md @@ -0,0 +1,135 @@ +# Designing a statusline + +A statusline is a **visual artifact judged in a terminal**. The failure mode these rules exist to +prevent is designing it blind: editing TypeScript, restarting OpenCode, and scoring the result from +a sentence. One sidebar took about twenty restarts and five rejected iterations that way, and three +of the rejections were glyph choices that read completely differently on screen than they do in +prose. + +## Look at it before you ship it + +```sh + --watch # redraws on every save + --state full # one state +echo '{"status":{"override":{"write":false}}}' | --config - # a candidate on stdin, read as OpenCode will read it + --debug # name every row: ✓ drew, ✗ drew nothing, ? no such segment + --module mine.ts # that module alone, every segment it has +``` + +`` is the command `cockpit_settings` prints under **Previews** — this install's own copy. +Never `bunx`/`npx` it: that downloads another release. + +The preview draws the real segments against sample sessions, in this terminal, with no OpenCode +involved. **Use it after every change.** If a design decision cannot be checked in the preview, it +has not been checked. + +Before writing code for anything visual, **paste an ASCII mock and ask**. A mock costs a line; a +wrong reading of one ambiguous word costs two iterations. "Make it taller" once meant "make the bar +look solid", and the two interpretations share no code. + +## Rules with reasons + +| Rule | Why | +| --- | --- | +| **Never repeat what OpenCode already shows.** Its footer has path, branch, token total, spend; its prompt has agent and model. | Configured carelessly the same percentage lands on screen five times. The exception: a *better instrument* for the same fact — a bar you read without looking is not a second copy of `78.5K (39%)`. | +| **A segment with nothing to say says nothing.** | `cost` hides where no prices are declared rather than printing `$0.00`; `context` hides with no declared window rather than inventing a denominator. A confident wrong number is worse than an absent one. | +| **No walls of zeroes on a fresh session.** Check the `fresh` and `empty` fixtures. | Most designs look right mid-session and read as broken before the first reply. | +| **Every number gets a word.** Colour may repeat the meaning, never carry it alone. | A row distinguished only by colour is unreadable: "I read `mix` and I don't understand the colours." | +| **Labels in a fixed-width column, values after.** | Alignment is what makes a column read as designed rather than as output. | +| **Bars are solid.** Filled cells `â–ˆ` coloured by level; the empty track is `â–ˆ` in **`border`** tone. | `â–‘` reads as floating gaps and `─` reads as `-----`. Both were rejected on sight. Swapping one rejected glyph for another is not iteration. `panel` is the colour of the panel the bar sits on, so a track drawn in it is invisible. | +| **Single-width glyphs only.** | An emoji is two cells in most terminals and one in a few — exactly what shears a fixed-width line. | +| **Prefer a coloured rule `â–Œ` to a filled pill.** | A filled block must be as wide as its text, so a short label leaves a slab of colour and an empty one leaves an empty box. | +| **Prefer a figure to a moving picture.** | A sparkline redraws its shape every second; movement in peripheral vision is the one thing a statusline must not do. `+1.2%/min · 48m left` changes digits and nothing else. | +| **No section headings above optional rows.** | A heading cannot know whether the rows under it will draw, so `SPEND` strands itself above nothing on an unpriced model. Self-label the rows instead. | +| **Emphasis is a bonus, never the meaning.** Bold, italic and underline are ``, ``, `` markup — and a terminal with no bold face draws bold identically to plain. | Colour and background always render; weight may not. There is no strikethrough or inverse at all. | +| **Colours come from tones, not hexes.** `text muted accent success warning error info background panel border` | A literal ignores the user's theme, which is the first thing that makes a plugin look bolted on. Use a hex only where the exact colour *is* the meaning. | +| **No end caps on a bar in a column.** `â–•` and `â–�` are eighth-blocks whose ink sits hard against one edge of the cell. | An opening cap indents the row by most of a column, and the bar stops lining up with the labels above and below it. Caps are fine on a line, where nothing has to align. | +| **Print a number once.** A bar and the figure beside it are one row; the same percentage on the row below is a second copy. | In a column of ten rows the repeated figure is the thing the eye catches on. Let the bar be cells and let the labelled row carry the number. | +| **Say what a number means in the word, not the docs.** `cache` is cache reads, `write` is cache writes, `in` is fresh prompt tokens, `out` is output plus reasoning. | Read and write are not in and out; a reader who has to learn your mapping will misread it. | + +## The six states, and what each one catches + +A design is judged mid-session and ships broken everywhere else. `--state ` draws one; with +no flag the preview draws all six. Every one of these has caught something real: + +| State | What it is | What it catches | +| --- | --- | --- | +| `fresh` | a session before the first reply: no model, no tokens, no cost | the wall of zeroes, and `0%` against a window nobody has declared | +| `working` | a few turns in, most of the window served from cache | the ordinary case — and that `cache` dwarfs `in` and `out`, which a layout has to survive | +| `full` | nearly out of room, a long session | the widest every number gets: `191.6k`, `100%`, four-figure spend. Column widths that only fit `85.2k` shear here | +| `unpriced` | behind a proxy, nothing declared in the catalogue | segments that invent `$0.00` rather than staying silent | +| `retrying` | a stalled turn, retry pending | a status that reads "busy" forever, and rows that vanish mid-turn because a streaming message reports zeroes | +| `empty` | no session at all — what a window shows at start-up | the half of the line that is mounted before anything exists | + +Two rules fall out of them: **check `fresh` and `empty` before you call anything done**, because +they are what a new user sees first; and **size every column against `full`**, not against the +state you happen to be looking at. + +## The renderer's contract + +- One segment draws **one row**, unless it returns an **array** of pieces — then each element is a + row of its own. (Arrays used to be silently dropped; they work now.) +- A segment returns a string, `{ text, tone, color }`, `{ runs: [...] }`, an array of those, or + `undefined` to say nothing. +- A run takes `tone`, `color`, `bg`, `bgTone`, `bold`, `dim`, `italic`, `underline`. +- **A track drawn in `panel` tone is invisible** on most themes — it is the panel's own colour. + Use `border`. +- A **column** keeps at most `sidebarRows` rows (a line in `lines` says `maxRows`; 14 for the + `sidebar` preset, 8 for any other column) — **count your rows and raise it**, or the extras + vanish. The preview prints `↳ N dropped` when this happens. +- A `sep` hairline draws only with a row on either side of it, so a group that says nothing does + not strand one. +- A **line** drops the lowest-priority segments until it fits the width. +- A segment that throws loses only its own row. A module that fails to load raises a toast naming + the file. + +## Checking what actually reached the terminal + +`preview` paints with its own ANSI and the smoke harness serialises the screen as text, so both are +blind to colour and emphasis. When a design looks wrong and the code looks right: + +```sh +bun run capture --sidebar --find "40%" --find bold +``` + +It drives a real OpenCode and reports the escape codes written around the text you name. That is +how three separate "is this even working" questions were settled in minutes rather than rounds: +bold *was* being emitted and the font had no bold face; italic *was* being emitted and the word was +truncated; a track *was* being drawn and `panel` was the panel's own colour. + +## Where everything lives + +| What | Where | +| --- | --- | +| Settings, every project | the `"status"` section of `~/.config/opencode-cockpit/config.json` | +| Settings, one project | the `"status"` section of `/.cockpit.json` | +| Old names | `"statusline"`, Status keys at the file's root, a bay-level `maxRows`, `sidebarOrder`: not read; each is a `!` row. Write `"status"`, `sidebarRows`, and the top-level `"sidebar"` list | +| Modules | anywhere — `~/.config/opencode-cockpit/modules/` needs no `node_modules` beside it | +| Which plugins load | `~/.config/opencode/tui.json` | + +## Turning OpenCode's own blocks off + +Each block of the host's sidebar is an internal plugin that its config disables. OpenCode 1, in +`tui.json`: + +```jsonc +{ "plugin": ["opencode-cockpit"], "plugin_enabled": { "internal:sidebar-context": false } } +``` + +OpenCode 2, in `cli.json`: `{ "plugins": ["opencode-cockpit", "-opencode.sidebar.context"] }` +(`-internal:sidebar-context` does nothing there, and it has no LSP or Todo block). + +OpenCode 1 names: `internal:sidebar-{context,files,todo,lsp,mcp,footer}`, `internal:home-{footer,tips}`, +`internal:notifications`. Never suggest turning the Todo block off: nothing in Cockpit replaces it. **A sidebar meant to replace the Context block must carry what that block +carried** — percentage, token total, spend — or the user ends up with less than before. + +The footer under the prompt is core UI: it cannot be hidden. Design around it. + +## Start simple + +Most people want a good line, not a composition exercise. The default is the `sidebar` preset — the +table this skill's rules were learned on: `title`, a `solid` context bar, a `tokens` row, `in` `out` +`cache` `write`, `sep`, `spend` `avail` (a proxy's budget file; silent without one), `sep`, `git`. +Begin with the presets and the built-ins and a `format` string; reach for a module only when the answer needs the session read, decided on, or remembered +across ticks — a rate, a trend, a budget from a file. Reach for a shell `command` for anything a CLI +already prints; do not reimplement the shell as a segment. diff --git a/packages/status/skills/status-setup/references/settings.md b/packages/status/skills/status-setup/references/settings.md new file mode 100644 index 00000000..abdb2181 --- /dev/null +++ b/packages/status/skills/status-setup/references/settings.md @@ -0,0 +1,112 @@ + + +# Status settings reference + +Everything goes in the `"status"` section of `~/.config/opencode-cockpit/config.json` (every project) or +`/.cockpit.json` (this one). JSONC. Read when OpenCode starts: a change applies after a restart. +`cockpit_settings` gives the exact paths and what is written now. + +## Keys + +| Key | Type | Default | What it does | +| --- | --- | --- | --- | +| `enabled` | boolean | `true` | the bay runs at all, both halves. `false` turns it off entirely: no block, no commands, no tools | +| `sidebar` | boolean | true (the table in the sidebar) | draw the bay's block in the sidebar. Only the block: with `false` the bay still runs, its commands and tools still work | +| `sidebarRows` | number | 14 with the `sidebar` preset, else 8 | rows the block lists before the rest fold into `+ N more` | +| `surface` | "sidebar" \| "bottom" | "sidebar" | where the line draws; `"sidebar": false` says `"bottom"` too | +| `preset` | string | the surface's own: `sidebar` in the sidebar, `default` at the bottom | a whole line by name; anything written beside it wins. The `status-setup` skill has them all | +| `segments` | list | the preset's | the line's parts, built-ins or your own — the whole list, replacing the preset's. To change a row or two, `override` | +| `override` | object | none | changes to the preset's segments by name, the rest kept: `false` drops one, a name swaps it, an object merges into its settings — `{ "git": { "against": "branch" } }` | +| `lines` | list | one line | more than one line, each with its own `surface`, `segments`, `maxRows`… | +| `separator` | string | `" │ "` across, nothing down | drawn between segments | +| `stack` | "horizontal" \| "vertical" | vertical in the sidebar | segments across or down | +| `icons` | boolean | `true` | built-in icons; off for a terminal missing the glyphs | +| `debug` | boolean | `false` | draw a placeholder where a segment said nothing | +| `paddingLeft` | number | 3 at the bottom, 0 in the sidebar | columns of space left of the line | +| `paddingRight` | number | 2 at the bottom, 0 in the sidebar | columns of space right of the line | +| `paddingTop` | number | 0 | rows of space above the line | +| `paddingBottom` | number | 1 at the bottom, 0 in the sidebar | rows of space below the line | +| `commands` | object | none | shell commands usable as segments — a Claude Code statusline script works unchanged | +| `modules` | string[] | none | your own segments in TypeScript; a project's add to the global ones | + +A line in `"lines": [ … ]` takes `preset`, `surface`, `segments`, `override`, `separator`, `stack`, `icons`, +`debug`, `maxRows` (its own row cap) and the paddings; anything a line leaves out comes from the section. + +## Changing a row or two: `override` + +`override` changes the preset's segments by name and keeps the rest, so the line still follows the +preset. `segments` is the whole list instead: write it only to build a different line. + +```jsonc +"override": { + "git": { "against": "branch" }, // an object merges into that segment's settings + "write": false, // false drops it + "session.status": { "working": true }, // the working clock as well as a stall + "spend": "cost" // a name swaps it, in the same place +} +``` + +A change applies to every segment of that name (`"sep": false` drops every hairline). With `segments` +written too, the changes apply to those. A name that matches no segment in the line is a `!` row that +names the closest one. + +## Presets + +A preset fills in what is not written; anything written beside it wins. + +| Preset | Surface | What it shows | Segments | +| --- | --- | --- | --- | +| `minimal` | bottom | how full the context is, and what changed | `context` `git.diff` `session.status` `diagnostics` | +| `default` | bottom | the capacity bar, where the tokens went, what changed, how long | `context` `tokens` `tokens` `tokens` `tokens` `git.diff` `session.time` `todo` `session.status` `diagnostics` | +| `detailed` | bottom | everything the built-ins know, for a wide window | `context` `tokens` `model` `cost` `git.diff` `todo` `session.time` `session.status` `diagnostics` | +| `sidebar` | sidebar (the default) | a table: the window, where the tokens went, a proxy's budget, the branch's diff | `title` `context` `session.status` `diagnostics` `tokens` `in` `out` `cache` `write` `sep` `spend` `avail` `sep` `git` | + +## Built-in segments + +Write a name (`"cwd"`), or the name with settings (`{ "type": "context", "style": "bar" }`). Every +segment also takes `prefix`, `suffix`, `priority` (higher survives a narrow line), `color` (a tone: +`text muted accent success warning error info`, or `#rrggbb`) and `icon`. + +| Segment | What it says | +| --- | --- | +| `cwd` | the folder, shortened from the left (`maxWidth`, default 28) | +| `git.branch` | the branch; muted on the default branch | +| `git.diff` | what is uncommitted: `+added / -removed` (`format` with `{files}`, `{added}`, `{removed}`) | +| `model` | the model, short (`full: true` for its whole id) | +| `context` | how full the window is: `style` `"percent"` (default), `"bar"`, `"solid"` (the table's), `"split"` (cache, input, output in one bar); `width`, `warnAt` 0.75, `dangerAt` 0.9 | +| `tokens` | the token total; `format` with `{total}`, `{input}`, `{output}`, `{cacheRead}`, `{cacheWrite}`; `style` `"row"` (the table's) or `"parts"` | +| `cost` | what the session cost, hidden when nothing is priced (`showZero`, `currency`) | +| `todo` | todo progress, `3/7`; hidden when all are done (`showComplete`) | +| `session.status` | why a turn stalled, `retry 2 in 5s`; silent otherwise | +| `session.time` | how long: the session, or the last answer with `of: "turn"` (`coarse` for minutes) | +| `diagnostics` | errors and warnings from the language servers | +| `version` | the Cockpit version | +| `text` | a fixed `value`: `{ "type": "text", "value": "hi" }` | +| `command` | a shell command's output: `{ "type": "command", "name": "" }` (`row` for one line of many) | +| `title` | the table's heading, "Status" (`text` to rename it) | +| `in` | table row: fresh prompt tokens, with their share | +| `out` | table row: output and reasoning tokens, with their share | +| `cache` | table row: tokens read from cache, with their share | +| `write` | table row: tokens written to cache, with their share | +| `sep` | a hairline between groups; drawn only with a row on both sides | +| `spend` | table row: a proxy's spend, from its budget file; silent without one | +| `avail` | table row: what is left of a proxy's budget; silent without one | +| `git` | table row: the branch's whole diff against its base | + +## Commands + +Any CLI's output as a segment — an existing Claude Code statusline script runs unchanged: + +```jsonc +"commands": { "budget": { "run": "~/bin/budget.sh", "intervalMs": 2000, "timeoutMs": 1000 } }, +"segments": ["context", { "type": "command", "name": "budget" }] +``` + +`claudeCodeCompat` (default true) feeds the command Claude Code's statusline JSON on stdin. + +## Modules + +For what needs the session read, a decision, or memory across ticks (a rate, a trend): a TypeScript +module listed in `"modules"`, exporting `{ segments: { name(ctx, config) { return { runs: [...] } } } }` +against `@opencode-cockpit/status/segment`. It is handed a snapshot, not OpenCode's api, and called on +every repaint; returning `undefined` hides the segment. Its segments are used by name like built-ins. diff --git a/packages/status/skills/statusline-design/SKILL.md b/packages/status/skills/statusline-design/SKILL.md index 10985c7a..c5b08053 100644 --- a/packages/status/skills/statusline-design/SKILL.md +++ b/packages/status/skills/statusline-design/SKILL.md @@ -1,11 +1,14 @@ --- name: statusline-design -description: Designing or editing an opencode-cockpit statusline — a bottom line or a sidebar column, its config, or a TypeScript segment module. Use when a request mentions the statusline, a segment, .cockpit.json's statusline section, or a module importing @opencode-cockpit/status/segment. +description: The design rules for an opencode-cockpit statusline (the Status bay), kept for 0.9 for anyone who copied this folder. The shipped status-setup skill replaces it and carries the same rules; prefer status-setup when it is available. Removed in 0.10. --- +> Kept for 0.9 only. `/status-setup` loads the **status-setup** skill shipped with this package, +> which carries these rules in `status-setup/references/design.md`. This copy is removed in 0.10. + # Designing a statusline -A statusline is a **visual artifact judged in a terminal**. The failure mode this skill exists to +A statusline is a **visual artifact judged in a terminal**. The failure mode these rules exist to prevent is designing it blind: editing TypeScript, restarting OpenCode, and scoring the result from a sentence. One sidebar took about twenty restarts and five rejected iterations that way, and three of the rejections were glyph choices that read completely differently on screen than they do in @@ -14,12 +17,16 @@ prose. ## Look at it before you ship it ```sh -bunx @opencode-cockpit/status preview --watch # redraws on every save -bunx @opencode-cockpit/status preview --state full # one state -bunx @opencode-cockpit/status preview --debug # mark segments that drew nothing -bunx @opencode-cockpit/status preview --module mine.ts # that module alone, every segment it has + --watch # redraws on every save + --state full # one state +echo '{"status":{"override":{"write":false}}}' | --config - # a candidate on stdin, read as OpenCode will read it + --debug # name every row: ✓ drew, ✗ drew nothing, ? no such segment + --module mine.ts # that module alone, every segment it has ``` +`` is the command `cockpit_settings` prints under **Previews** — this install's own copy. +Never `bunx`/`npx` it: that downloads another release. + The preview draws the real segments against sample sessions, in this terminal, with no OpenCode involved. **Use it after every change.** If a design decision cannot be checked in the preview, it has not been checked. @@ -75,8 +82,11 @@ state you happen to be looking at. - A run takes `tone`, `color`, `bg`, `bgTone`, `bold`, `dim`, `italic`, `underline`. - **A track drawn in `panel` tone is invisible** on most themes — it is the panel's own colour. Use `border`. -- A **column** keeps at most `maxRows` rows (default 8) — **count your rows and raise it**, or the - extras vanish. The preview prints `↳ N dropped` when this happens. +- A **column** keeps at most `sidebarRows` rows (a line in `lines` says `maxRows`; 14 for the + `sidebar` preset, 8 for any other column) — **count your rows and raise it**, or the extras + vanish. The preview prints `↳ N dropped` when this happens. +- A `sep` hairline draws only with a row on either side of it, so a group that says nothing does + not strand one. - A **line** drops the lowest-priority segments until it fits the width. - A segment that throws loses only its own row. A module that fails to load raises a toast naming the file. @@ -99,28 +109,35 @@ truncated; a track *was* being drawn and `panel` was the panel's own colour. | What | Where | | --- | --- | -| Settings, every project | `~/.config/opencode-cockpit/config.json` | -| Settings, one project | `/.cockpit.json` | +| Settings, every project | the `"status"` section of `~/.config/opencode-cockpit/config.json` | +| Settings, one project | the `"status"` section of `/.cockpit.json` | +| Old names | `"statusline"`, Status keys at the file's root, a bay-level `maxRows`, `sidebarOrder`: not read; each is a `!` row. Write `"status"`, `sidebarRows`, and the top-level `"sidebar"` list | | Modules | anywhere — `~/.config/opencode-cockpit/modules/` needs no `node_modules` beside it | | Which plugins load | `~/.config/opencode/tui.json` | ## Turning OpenCode's own blocks off -Each block of the host's sidebar is an internal plugin, and `tui.json` disables any of them: +Each block of the host's sidebar is an internal plugin that its config disables. OpenCode 1, in +`tui.json`: ```jsonc { "plugin": ["opencode-cockpit"], "plugin_enabled": { "internal:sidebar-context": false } } ``` -`internal:sidebar-{context,files,todo,lsp,mcp,footer}`, `internal:home-{footer,tips}`, -`internal:notifications`. **A sidebar meant to replace the Context block must carry what that block +OpenCode 2, in `cli.json`: `{ "plugins": ["opencode-cockpit", "-opencode.sidebar.context"] }` +(`-internal:sidebar-context` does nothing there, and it has no LSP or Todo block). + +OpenCode 1 names: `internal:sidebar-{context,files,todo,lsp,mcp,footer}`, `internal:home-{footer,tips}`, +`internal:notifications`. Never suggest turning the Todo block off: nothing in Cockpit replaces it. **A sidebar meant to replace the Context block must carry what that block carried** — percentage, token total, spend — or the user ends up with less than before. The footer under the prompt is core UI: it cannot be hidden. Design around it. ## Start simple -Most people want a good line, not a composition exercise. Begin with the built-ins and a `format` -string; reach for a module only when the answer needs the session read, decided on, or remembered +Most people want a good line, not a composition exercise. The default is the `sidebar` preset — the +table this skill's rules were learned on: `title`, a `solid` context bar, a `tokens` row, `in` `out` +`cache` `write`, `sep`, `spend` `avail` (a proxy's budget file; silent without one), `sep`, `git`. +Begin with the presets and the built-ins and a `format` string; reach for a module only when the answer needs the session read, decided on, or remembered across ticks — a rate, a trend, a budget from a file. Reach for a shell `command` for anything a CLI already prints; do not reimplement the shell as a segment. diff --git a/packages/status/src/cli/preview.ts b/packages/status/src/cli/preview.ts index 921a278f..3c05c001 100644 --- a/packages/status/src/cli/preview.ts +++ b/packages/status/src/cli/preview.ts @@ -5,6 +5,7 @@ * bunx @opencode-cockpit/status preview * bunx @opencode-cockpit/status preview --config ~/.config/opencode-cockpit/config.json * bunx @opencode-cockpit/status preview --state full --width 60 + * bunx @opencode-cockpit/status preview --proxy ~/.cache/opencode-litellm-iap/spend.json * * Why this exists: a statusline is a visual thing, and editing TypeScript, restarting OpenCode and * squinting is a loop measured in minutes. One sidebar took about twenty restarts to design, and @@ -12,41 +13,89 @@ * sentence. Nothing here can tell you a design is good; it can tell you what it looks like. */ -import { watch } from "node:fs" -import { asSegmentConfig, loadStatusConfig, type ResolvedLine, resolveLines } from "../core/config.ts" +import { readFileSync, watch } from "node:fs" +import { homedir } from "node:os" +import { budgetFile, readBudget } from "../core/budget.ts" +import { type ResolvedLine, resolveLines, type Surface } from "../core/config.ts" import { loadCustomSegments, resolveModulePath } from "../core/custom.ts" import { FIXTURES, type FixtureName } from "../core/fixtures.ts" -import { fit, fitColumn } from "../core/render.ts" -import { buildSegments, type SegmentDef, segmentWidth } from "../core/segments.ts" +import { moduleNoticeText } from "../core/notices.ts" +import { + type ConfigAs, + drawState, + parseArgs, + plainRuns, + previewSettings, + SIDEBAR_WIDTH, +} from "../core/preview.ts" +import type { SegmentDef } from "../core/segments.ts" import { paintRuns as paintColour } from "./ansi.ts" -const args = process.argv.slice(2).filter((arg) => arg !== "preview") -const flag = (name: string): string | undefined => { - const at = args.indexOf(`--${name}`) - return at === -1 ? undefined : args[at + 1] -} -const has = (name: string) => args.includes(`--${name}`) +const args = parseArgs(process.argv.slice(2)) +const flag = (name: keyof typeof args.values) => args.values[name] +const has = (name: Parameters[0]) => args.switches.has(name) if (has("help")) { console.log(` - preview — draw your statusline here, against sample sessions + preview — draw your statusline here, against sample sessions, as OpenCode will - --config a config file (default: your global + project config) + --config this file in place of your global config, with no project file beside it + (default: your global config, then this folder's .cockpit.json) + --config - a config on stdin, as the file it is meant to become, the other read beside it: + cat <<'EOF' | preview --config - --debug + { "status": { "override": { "git": { "against": "branch" } } } } + EOF + --as global | project: the file --config stands in for (stdin: global by default) + --surface sidebar | bottom: draw there, whatever the settings say + --proxy the budget file a proxy writes, for spend and avail (none: draw without one) --module draw this module's segments, on their own --with-config ...and the config's modules and segments as well --state ${Object.keys(FIXTURES).join(" | ")} (default: every one) - --width columns available to the line (default: the surface's own) - --debug mark segments that drew nothing, so silence and typos look different + --width columns for the line (default: ${SIDEBAR_WIDTH} in the sidebar, the terminal's at the bottom) + --debug name every row: ✓name drew, ✗name drew nothing, ?name is no segment at all --watch redraw whenever the config or a module changes `) process.exit(0) } +/** A flag the preview cannot read stops it: ignored, it drew some other settings than the ones meant. */ +if (args.errors.length > 0) { + for (const error of args.errors) console.error(` ${error}`) + process.exit(2) +} const directory = process.cwd() -const configPath = flag("config") -const config = configPath - ? ((await Bun.file(configPath).json()).statusline ?? {}) - : loadStatusConfig(directory) +const expand = (path: string) => (path === "~" || path.startsWith("~/") ? homedir() + path.slice(1) : path) +const configFlag = flag("config") +/** + * `--config -`: the candidate on stdin, as the file it is meant to become (`--as`, global by default), + * with the other file read beside it as OpenCode will. An agent previews what it is about to write + * without writing it anywhere first — a temporary file outside the project is a permission prompt on + * OpenCode 1, and one inside it is a stray file in the user's repo. + */ +const fromStdin = configFlag === "-" +const configPath = configFlag && !fromStdin ? expand(configFlag) : undefined +const as = (flag("as") ?? (fromStdin ? "global" : undefined)) as ConfigAs | undefined +const stdinText = fromStdin ? await Bun.stdin.text() : undefined +const surface = flag("surface") as Surface | undefined + +/** + * Through the loader and the resolution the bay itself uses (`previewSettings`), so the preview reads + * a file exactly as OpenCode will — its `status` section, comments, `preset`, `sidebarRows` and + * `override` — and draws the same `!` rows for what it will not read. A file that cannot be read + * stops the preview: the defaults drawn in its place would look like a file that changed nothing. + */ +function settings() { + if (stdinText !== undefined) return previewSettings({ directory, configText: stdinText, as, surface }) + if (!configPath) return previewSettings({ directory, surface }) + let configText: string + try { + configText = readFileSync(configPath, "utf8") + } catch (error) { + console.error(` cannot read --config ${configPath}: ${(error as Error).message}`) + process.exit(2) + } + return previewSettings({ directory, configText, surface, ...(as ? { as } : {}) }) +} /** * `--module` draws that module and nothing else. @@ -58,22 +107,32 @@ const config = configPath */ const only = flag("module") const isolate = only !== undefined && !has("with-config") -const modules = [...(isolate ? [] : (config.modules ?? [])), ...(only ? [only] : [])] -let custom: ReadonlyMap = new Map() -if (modules.length > 0) { - const loaded = await loadCustomSegments(modules, directory) - custom = loaded.segments - for (const error of loaded.errors) console.error(` module failed: ${error}`) -} -/** - * On its own, a module draws every segment it declares, in the order it declares them, with room - * for all of them — a column capped at the default eight silently hides the rest of a gallery. - */ -const lines = resolveLines( - isolate - ? { - surface: config.surface, +/** Everything a draw needs, read again on every redraw so `--watch` follows the config too. */ +async function prepare(fresh = false) { + const { loaded, lines: configured, target } = settings() + const config = loaded.config + const modules = [...(isolate ? [] : (config.modules ?? [])), ...(only ? [only] : [])] + let custom: ReadonlyMap = new Map() + const moduleErrors: string[] = [] + if (modules.length > 0) { + const imported = await loadCustomSegments( + modules, + directory, + // Bun caches modules by specifier: without a fresh one an edit would never show. + fresh ? (path) => import(`${path}?v=${Date.now()}`) : undefined, + ) + custom = imported.segments + moduleErrors.push(...imported.errors) + for (const error of imported.errors) console.error(` module failed: ${error}`) + } + /** + * On its own, a module draws every segment it declares, in the order it declares them, with room + * for all of them — a column capped at the default eight silently hides the rest of a gallery. + */ + const lines: ResolvedLine[] = isolate + ? resolveLines({ + surface: surface ?? config.surface, separator: config.separator, stack: config.stack, icons: config.icons, @@ -81,27 +140,45 @@ const lines = resolveLines( paddingLeft: config.paddingLeft, paddingRight: config.paddingRight, segments: [...custom.keys()], - maxRows: Math.max(config.maxRows ?? 0, custom.size), - } - : config, -) -const states = flag("state") ? [flag("state") as FixtureName] : (Object.keys(FIXTURES) as FixtureName[]) -const debug = has("debug") || config.debug === true + sidebarRows: Math.max(config.sidebarRows ?? 0, custom.size), + }) + : configured + /** The `!` rows the bay would draw above its first line. */ + const troubles = [...loaded.notices, ...moduleErrors.map(moduleNoticeText)] + return { lines, modules, custom, troubles, target } +} -/** The room each surface actually has in OpenCode, so a preview is not wider than the real thing. */ -const roomFor = (line: ResolvedLine, terminal: number) => - line.surface === "sidebar" ? 34 : terminal - line.paddingLeft - line.paddingRight +/** + * A proxy's budget, as the bay reads it: the file the lines name, or `--proxy`. `--proxy none` draws + * the line as someone without a proxy sees it. + */ +const proxy = flag("proxy") +const budgetFor = (lines: readonly ResolvedLine[]) => { + const at = proxy === "none" ? undefined : proxy ? resolveModulePath(proxy, directory) : budgetFile(lines) + return at ? readBudget(at) : undefined +} +const states = flag("state") ? [flag("state") as FixtureName] : (Object.keys(FIXTURES) as FixtureName[]) const width = Number(flag("width") ?? 0) || 0 /** Colour for a terminal, plain text under NO_COLOR or into a pipe — as Subagents and the Updater do. */ const color = process.stdout.isTTY === true && !process.env.NO_COLOR -const paintRuns: typeof paintColour = (runs) => - color ? paintColour(runs) : runs.map((run) => run.text).join("") +const paint = color ? paintColour : plainRuns const dim = (text: string) => color ? `${String.fromCharCode(27)}[38;2;110;120;132m${text}${String.fromCharCode(27)}[0m` : text -async function draw(): Promise { - const watched: string[] = [] +/** Which settings these are, so a preview of one file cannot pass for a preview of another. */ +const sourceOf = (target: string | undefined) => + fromStdin + ? `(stdin, as ${target})` + : configPath + ? `${configPath}${target ? `, as ${target}` : ""}` + : "your global config, then this folder's .cockpit.json" + +async function draw(fresh = false): Promise { + const { lines, modules, custom, troubles, target } = await prepare(fresh) + const budget = budgetFor(lines) + const debug = has("debug") || lines.some((line) => line.debug) + console.log(`\n ${dim(`config: ${sourceOf(target)}${surface ? ` · --surface ${surface}` : ""}`)}`) for (const state of states) { const fixture = FIXTURES[state] if (!fixture) { @@ -109,51 +186,27 @@ async function draw(): Promise { process.exit(1) } console.log(`\n${dim(`── ${state} — ${fixture.about}`)}`) - - for (const line of lines) { - const room = width || roomFor(line, process.stdout.columns || 120) - const ctx = { ...fixture.ctx, width: room } - const built = buildSegments(ctx, line.segments.map(asSegmentConfig), { - custom, - icons: line.icons, - debug, - }) - const fitted = - line.stack === "vertical" ? fitColumn(built, room, line.maxRows) : fit(built, room, line.separator) - - if (fitted.segments.length === 0) { - console.log(` ${dim(`(${line.surface}: nothing to draw)`)}`) - continue - } - console.log(` ${dim(`${line.surface}, ${room} cols`)}`) - if (line.stack === "vertical") { - for (const segment of fitted.segments) console.log(` ${paintRuns(segment.runs)}`) - } else { - const parts = fitted.segments.map((segment) => paintRuns(segment.runs)) - console.log(` ${parts.join(dim(line.separator))}`) - } - /** Rows a real sidebar would have dropped in silence. */ - if (fitted.dropped > 0) { - const over = line.stack === "vertical" ? `maxRows is ${line.maxRows}` : `${room} columns` - console.log(` ${dim(`↳ ${fitted.dropped} dropped — ${over}`)}`) - } - const widest = Math.max(0, ...fitted.segments.map(segmentWidth)) - if (line.stack === "vertical" && widest > room) { - console.log(` ${dim(`↳ widest row is ${widest} cols, the column has ${room}`)}`) - } - } + const rows = drawState({ + lines, + troubles, + fixture, + terminal: process.stdout.columns || 120, + width, + debug, + custom, + ...(budget ? { budget } : {}), + paint, + dim, + }) + for (const row of rows) console.log(row) } console.log() - return watched + return modules } -await draw() +const modules = await draw() -/** - * Redraw on change, because the point of a preview is the loop and not the picture. A module is - * re-imported under a fresh query string: Bun caches modules by specifier, so without it an edit - * would show the version from the first run forever. - */ +/** Redraw on change, because the point of a preview is the loop and not the picture. */ if (has("watch")) { const files = [...modules.map((m) => resolveModulePath(m, directory)), ...(configPath ? [configPath] : [])] console.log(dim(` watching ${files.length} file${files.length === 1 ? "" : "s"} — ctrl+c to stop\n`)) @@ -164,19 +217,8 @@ if (has("watch")) { clearTimeout(pending) // Editors save in bursts; redraw once the burst is over. pending = setTimeout(() => { - void (async () => { - if (modules.length > 0) { - const again = await loadCustomSegments( - modules, - directory, - (path) => import(`${path}?v=${Date.now()}`), - ) - custom = again.segments - for (const error of again.errors) console.error(` module failed: ${error}`) - } - console.clear() - await draw() - })() + console.clear() + void draw(true) }, 120) }) } catch { diff --git a/packages/status/src/cli/reference.ts b/packages/status/src/cli/reference.ts new file mode 100644 index 00000000..3b50d4df --- /dev/null +++ b/packages/status/src/cli/reference.ts @@ -0,0 +1,17 @@ +#!/usr/bin/env bun + +/** + * Writes the `status-setup` skill's reference from the code, so it cannot drift from what Status + * reads. `test/reference.test.ts` fails when the file on disk is not what this writes. + * + * bun packages/status/src/cli/reference.ts + */ + +import { writeFileSync } from "node:fs" +import { join } from "node:path" +import { statusReference } from "../core/reference.ts" +import { SETUP_SKILL_DIR } from "../core/setup.ts" + +const file = join(SETUP_SKILL_DIR, "references", "settings.md") +writeFileSync(file, statusReference()) +console.log(`wrote ${file}`) diff --git a/packages/status/src/core/budget.ts b/packages/status/src/core/budget.ts new file mode 100644 index 00000000..63b44680 --- /dev/null +++ b/packages/status/src/core/budget.ts @@ -0,0 +1,72 @@ +/** + * A budget a proxy keeps: what has been spent against a cap. + * + * OpenCode knows what a model costs only when a provider declares prices, and behind a proxy nobody + * does — the proxy is the one that knows. LiteLLM's IAP plugin writes `{ baseline, delta, cap }` to a + * small file; anything that writes the same shape will do. No file, no budget, and the rows that draw + * it say nothing: a made-up figure would be worse than none. + */ + +import { readFileSync } from "node:fs" +import { homedir } from "node:os" +import { join, resolve } from "node:path" + +export interface Budget { + /** Spent so far. */ + spent: number + /** The most that may be spent; always above zero. */ + cap: number +} + +/** Where LiteLLM's IAP plugin writes it. A `file` on the `spend` or `avail` segment points elsewhere. */ +export const budgetPath = (home = homedir()): string => + join(home, ".cache", "opencode-litellm-iap", "spend.json") + +/** How often the file is read again: it is tiny, but the line repaints every second. */ +export const BUDGET_EVERY_MS = 5_000 + +/** The file's text as a budget, or undefined when it is not one. */ +export function parseBudget(text: string | undefined): Budget | undefined { + if (text === undefined) return undefined + let raw: unknown + try { + raw = JSON.parse(text) + } catch { + return undefined + } + const { baseline, delta, cap } = (raw ?? {}) as { baseline?: unknown; delta?: unknown; cap?: unknown } + if (typeof baseline !== "number" || typeof cap !== "number" || !(cap > 0)) return undefined + return { spent: baseline + (typeof delta === "number" ? delta : 0), cap } +} + +export function readBudget(path: string): Budget | undefined { + try { + return parseBudget(readFileSync(path, "utf8")) + } catch { + return undefined + } +} + +const BUDGET_SEGMENTS = new Set(["spend", "avail"]) + +/** + * The file the lines want read, or undefined when no line draws a budget — nothing should touch the + * disk for a figure nobody is going to draw. The first `file` written on a budget segment wins. + */ +export function budgetFile( + lines: ReadonlyArray<{ segments: ReadonlyArray }>, + home = homedir(), +): string | undefined { + let wanted = false + for (const line of lines) { + for (const segment of line.segments) { + const name = typeof segment === "string" ? segment : segment.type + if (!name || !BUDGET_SEGMENTS.has(name)) continue + if (typeof segment !== "string" && typeof segment.file === "string") { + return segment.file.startsWith("~/") ? resolve(home, segment.file.slice(2)) : segment.file + } + wanted = true + } + } + return wanted ? budgetPath(home) : undefined +} diff --git a/packages/status/src/core/builtins/index.ts b/packages/status/src/core/builtins/index.ts index dbf040aa..927be3ff 100644 --- a/packages/status/src/core/builtins/index.ts +++ b/packages/status/src/core/builtins/index.ts @@ -3,9 +3,10 @@ import { SEGMENTS as model } from "./model.ts" import { SEGMENTS as place } from "./place.ts" import { SEGMENTS as session } from "./session.ts" import { SEGMENTS as system } from "./system.ts" +import { SEGMENTS as table } from "./table.ts" /** * Every built-in, grouped by what it talks about rather than listed in one file: adding a segment * should mean opening the twenty lines it belongs with, not four hundred. */ -export const BUILTINS: SegmentDef[] = [...place, ...model, ...session, ...system] +export const BUILTINS: SegmentDef[] = [...place, ...model, ...session, ...system, ...table] diff --git a/packages/status/src/core/builtins/model.ts b/packages/status/src/core/builtins/model.ts index 20b718db..a8a65dbc 100644 --- a/packages/status/src/core/builtins/model.ts +++ b/packages/status/src/core/builtins/model.ts @@ -5,6 +5,7 @@ import { contextRatio, contextUsed } from "../context.ts" import { bar, compact, gradient, money, percent, shortModel } from "../format.ts" import type { Run, SegmentDef, Tone } from "../types.ts" import { formatted, num, str } from "./settings.ts" +import { labelled, level } from "./table.ts" export const SEGMENTS: SegmentDef[] = [ { @@ -74,6 +75,23 @@ export const SEGMENTS: SegmentDef[] = [ return { runs } } + if (style === "solid") { + /** + * The sidebar table's bar: filled cells in the gauge rule's tone, the rest a solid dark track. + * Not `â–‘`, which reads as floating gaps, and not `─`, a row of dashes. No end caps — `â–•` and + * `â–�` are eighth-blocks whose ink sits against one edge, so a cap indents the bar off the + * label column — and no figure: the `tokens` row under it reads the percentage out, and a + * number printed twice in a column of ten rows is what the eye catches on. + */ + const filled = Math.round(ratio * width) + return { + runs: [ + { text: "â–ˆ".repeat(filled), tone: gaugeTone(ratio, warnAt, dangerAt) }, + { text: "â–ˆ".repeat(width - filled), tone: "border" }, + ], + } + } + if (style === "bar") { const filled = bar(ratio, width) /** @@ -128,6 +146,15 @@ export const SEGMENTS: SegmentDef[] = [ }) if (shaped) return shaped + /** A row of the sidebar table: the whole window, and how full it is, coloured only as a level. */ + if (str(config, "style") === "row") { + const ratio = contextRatio(ctx.session) + return labelled("tokens", [ + { text: compact(used), tone: "text" }, + ...(ratio === undefined ? [] : [{ text: ` · ${percent(ratio)}`, tone: level(ratio) }]), + ]) + } + /** * The parts, coloured by what they are rather than labelled in a row of equal-weight text: * cache green, fresh input blue, output accent — the same three colours the split bar uses, diff --git a/packages/status/src/core/builtins/session.ts b/packages/status/src/core/builtins/session.ts index 0c7b894e..574211e2 100644 --- a/packages/status/src/core/builtins/session.ts +++ b/packages/status/src/core/builtins/session.ts @@ -38,7 +38,7 @@ export const SEGMENTS: SegmentDef[] = [ name: "session.status", icon: "â—�", priority: 95, - render(ctx) { + render(ctx, config) { const session = ctx.session if (!session) return undefined if (session.status === "retry") { @@ -50,7 +50,8 @@ export const SEGMENTS: SegmentDef[] = [ tone: "warning", } } - if (session.status === "busy") { + /** `"working": false` keeps the row for what is wrong (a retry) and drops the turn's clock. */ + if (session.status === "busy" && config.working !== false) { // From the prompt, not from the session's creation: a conversation reopened two days later // was `working 2d 15h` within a second of being asked something. const started = session.turn?.startedAt diff --git a/packages/status/src/core/builtins/system.ts b/packages/status/src/core/builtins/system.ts index 12e99385..6ac431eb 100644 --- a/packages/status/src/core/builtins/system.ts +++ b/packages/status/src/core/builtins/system.ts @@ -21,7 +21,11 @@ export const SEGMENTS: SegmentDef[] = [ .map((item) => item.name) .join(", ") const more = broken.length > 2 ? ` +${broken.length - 2}` : "" - return { text: `${GLYPH.warn} ${names}${more}`, tone: "error" } + // The names give way before the count: cut at the edge, `! web-search-prime-with…` hid that + // three servers broke, not one. + const room = ctx.width > 0 ? ctx.width - 2 - more.length : Number.POSITIVE_INFINITY + const shown = names.length > room ? `${names.slice(0, Math.max(1, room - 1))}${GLYPH.more}` : names + return { text: `${GLYPH.warn} ${shown}${more}`, tone: "error" } }, }, { diff --git a/packages/status/src/core/builtins/table.ts b/packages/status/src/core/builtins/table.ts new file mode 100644 index 00000000..ada02de8 --- /dev/null +++ b/packages/status/src/core/builtins/table.ts @@ -0,0 +1,152 @@ +/** + * The rows of the sidebar's table: a heading, the tokens by where they went, a proxy's budget, the + * branch's diff, and the hairlines between the groups. + * + * They were the `sidebar-budget` example's, a layout a user arrived at after five rejected + * iterations, and became built-ins when it became the `sidebar` preset — so the default needs no + * module. Three rules from it hold every row here: the word is the label, in a fixed column so the + * figures line up; a row whose figure is zero is not drawn (`write 0 · 0%` said nothing, at length); + * and colour is a level — calm until a threshold, then the warning, then the error — never a category. + */ + +import { GAUGE, gaugeTone } from "@opencode-cockpit/client/design" +import type { StatusContext } from "../context.ts" +import { contextUsed } from "../context.ts" +import { compact } from "../format.ts" +import type { Run, SegmentDef, Tone } from "../types.ts" +import { num, str } from "./settings.ts" + +/** + * The label column, padded so every figure starts in the same place. Seven: the longest label is + * `tokens`, and at six it ran into its own figure (`tokens167.8k`). + */ +export const LABEL = 7 + +export function labelled(label: string, value: Run[]): { runs: Run[] } { + return { runs: [{ text: label.padEnd(LABEL), tone: "muted" }, ...value] } +} + +/** A level's tone: the text colour until it is worth a colour, so a column of figures is not a column of greens. */ +export const level = (ratio: number): Tone => (ratio >= GAUGE.warnAt ? gaugeTone(ratio) : "text") + +const widthOf = (runs: readonly Run[]) => runs.reduce((sum, run) => sum + run.text.length, 0) + +/** `extra` when the row still fits the room with it, so a narrow column loses a word rather than a figure. */ +function ifRoom(ctx: StatusContext, runs: Run[], extra: Run): Run[] { + return widthOf(runs) + LABEL + extra.text.length <= ctx.width ? [...runs, extra] : runs +} + +/** One token row: its own figure, and its share of the window. Zero is not a row. */ +function share(ctx: StatusContext, label: string, value: number): { runs: Run[] } | undefined { + const total = contextUsed(ctx.session?.tokens) + if (total === 0 || value === 0) return undefined + return labelled(label, [ + { text: compact(value), tone: "text" }, + { text: ` · ${Math.round((value / total) * 100)}%`, tone: "muted" }, + ]) +} + +export const SEGMENTS: SegmentDef[] = [ + { + /** The column's subject, so the table under it has one; `text` names it something else. */ + name: "title", + priority: 90, + render(_ctx, config) { + return { runs: [{ text: str(config, "text") ?? "Status", tone: "text", bold: true }] } + }, + }, + { + /** Fresh prompt tokens: neither cached nor generated. */ + name: "in", + priority: 35, + render: (ctx) => share(ctx, "in", ctx.session?.tokens?.input ?? 0), + }, + { + /** What the model wrote, reasoning included. */ + name: "out", + priority: 35, + render(ctx) { + const tokens = ctx.session?.tokens + return share(ctx, "out", (tokens?.output ?? 0) + (tokens?.reasoning ?? 0)) + }, + }, + { + /** Served from the prompt cache — cheap, and usually most of the window. */ + name: "cache", + priority: 35, + render: (ctx) => share(ctx, "cache", ctx.session?.tokens?.cache.read ?? 0), + }, + { + /** Written into the cache this session — a one-time premium each. Not the same as `out`. */ + name: "write", + priority: 30, + render: (ctx) => share(ctx, "write", ctx.session?.tokens?.cache.write ?? 0), + }, + { + /** + * A hairline, to group without a heading that might strand itself. Drawn only between two rows: + * one with nothing under it, or under another, is left out. + */ + name: "sep", + priority: 5, + divider: true, + render(_ctx, config) { + return { + runs: [{ text: "─".repeat(Math.max(1, num(config, "width", 14))), tone: "border", dim: true }], + } + }, + }, + { + /** Spent so far against the cap: a figure, coloured only once the budget is a level to watch. */ + name: "spend", + priority: 60, + render(ctx) { + const budget = ctx.budget + if (!budget) return undefined + return labelled("spend", [ + { text: `$${budget.spent.toFixed(2)}`, tone: level(budget.spent / budget.cap) }, + ]) + }, + }, + { + /** What is left, and the share of the cap that decides whether it earns a colour. */ + name: "avail", + priority: 60, + render(ctx) { + const budget = ctx.budget + if (!budget) return undefined + const left = Math.max(0, budget.cap - budget.spent) + const tone = level(budget.spent / budget.cap) + const runs: Run[] = [ + { text: `$${left.toFixed(2)}`, tone }, + { text: ` · ${Math.round((left / budget.cap) * 100)}%`, tone: tone === "text" ? "muted" : tone }, + ] + return labelled("avail", ifRoom(ctx, runs, { text: " left", tone: tone === "text" ? "muted" : tone })) + }, + }, + { + /** + * What git would commit: the uncommitted files, by default — what this work has changed that is + * not saved anywhere yet. `"against": "branch"` counts the whole branch against where it forked + * instead (every commit plus what is uncommitted, `vs main`), the reviewer's view. + */ + name: "git", + priority: 50, + render(ctx, config) { + const branch = config.against === "branch" + const diff = branch ? ctx.branchDiff : ctx.diff + if (!diff || diff.files === 0) return undefined + const runs: Run[] = [ + { text: `${diff.files}f`, tone: "muted" }, + { text: ` +${compact(diff.additions)}`, tone: "success" }, + { text: ` -${compact(diff.deletions)}`, tone: "error" }, + ] + return labelled( + "git", + branch + ? ifRoom(ctx, runs, { text: ` vs ${ctx.defaultBranch ?? "main"}`, tone: "muted", dim: true }) + : runs, + ) + }, + }, +] diff --git a/packages/status/src/core/config.ts b/packages/status/src/core/config.ts index 20364ad3..7e9b93fc 100644 --- a/packages/status/src/core/config.ts +++ b/packages/status/src/core/config.ts @@ -1,19 +1,25 @@ -import { existsSync, readFileSync } from "node:fs" -import { homedir } from "node:os" -import { join } from "node:path" +import { + baySettings, + closestName, + cockpitNotices, + noticeText, + OPTIONS_SOURCE, + type Settings, + type SettingsNotice, + type SettingsWhere, +} from "@opencode-cockpit/client/settings" /** - * Statusline settings, read from the same two files every cockpit bay uses: + * Status's settings: the `status` section of the files every cockpit bay reads, through the one + * loader in `@opencode-cockpit/client/settings`: * * ~/.config/opencode-cockpit/config.json → /.cockpit.json → plugin-entry options * - * Only the `statusline` section is read here. An unreadable or invalid file is ignored rather than - * fatal — a typo in a config should never cost you the interface. + * Only `status` is read. `statusline` (the section's name until 0.9) and keys at the file's root are + * old names: the loader recognises them and the bay draws a `!` row for each, but their values are + * not read. A file that cannot be parsed is a notice too, never the end of the interface. */ -export const CONFIG_FILE = "config.json" -export const PROJECT_FILE = ".cockpit.json" - /** * Where a line is drawn. * @@ -23,6 +29,9 @@ export const PROJECT_FILE = ".cockpit.json" */ export type Surface = "bottom" | "sidebar" +/** Where Status draws when nothing says otherwise. The sidebar since 0.9. */ +export const DEFAULT_SURFACE: Surface = "sidebar" + /** * A segment is either a built-in named by string ("cwd"), or that name with settings. `when` and * `priority` are what make a line survive a narrow terminal instead of wrapping into noise. @@ -61,11 +70,26 @@ export interface CommandConfig { */ export type Stack = "horizontal" | "vertical" +/** + * One segment's change, on top of the preset's list (or `segments`): `false` drops it, a name swaps + * it for that segment in the same place, an object merges into its settings. + */ +export type SegmentChange = false | string | Record + +/** + * Changes to a line's segments, keyed by segment name: `{ "git": { "against": "branch" } }`. A + * small change used to mean copying the preset's whole list into `segments`, which then stopped + * following the preset — and one wrong entry in fourteen was a row gone with no word said. + */ +export type Override = Record + export interface LineConfig { /** A whole line by name; anything written beside it wins. */ preset?: string surface?: Surface segments?: (string | SegmentConfig)[] + /** Changes to the preset's segments, or to `segments`, by segment name. */ + override?: Override /** Drawn between segments. Defaults to " · " across, and nothing down. */ separator?: string /** Defaults to vertical in the sidebar, horizontal everywhere else. */ @@ -74,7 +98,7 @@ export interface LineConfig { icons?: boolean /** Draw a placeholder where a segment said nothing, so a typo and missing data look different. */ debug?: boolean - /** Vertical only: rows to draw at most. Lowest priority goes first. Defaults to 8. */ + /** Vertical only: rows to draw at most. Lowest priority goes first. The bay's `sidebarRows` by default. */ maxRows?: number /** * Columns of space either side. The defaults line each surface up with OpenCode's own @@ -87,8 +111,14 @@ export interface LineConfig { paddingBottom?: number } +/** The `status` section. */ export interface StatusConfig { enabled?: boolean + /** + * Draw in the sidebar: the key every bay shares. `false` is read as `surface: "bottom"`, so the + * one switch works here as it does everywhere; `surface` says the same thing in Status's words. + */ + sidebar?: boolean /** * A whole line by name: `minimal`, `default`, `detailed`, `sidebar`. Anything you write * alongside it wins, so a preset is a starting point rather than a mode. @@ -96,15 +126,13 @@ export interface StatusConfig { preset?: string /** One line, for the common case. Use `lines` for more than one surface. */ surface?: Surface + segments?: (string | SegmentConfig)[] /** - * Where this bay's block sits among the others in a shared surface. Lower draws first. - * - * The sidebar holds whatever bays you have installed, in the order they registered — which until - * now was a constant nobody could reach: shells above the statusline, whatever you would rather - * see. Defaults to 200, and Shell's is 150. + * Changes to the preset's segments by name, so changing one row keeps the rest of the preset: + * `false` drops a segment, a name swaps it, an object merges into its settings. With `segments` + * written too, the changes apply to those. A line in `lines` may carry its own. */ - sidebarOrder?: number - segments?: (string | SegmentConfig)[] + override?: Override separator?: string stack?: Stack /** Built-in icons. On by default; switch off for a terminal missing the glyphs. */ @@ -112,11 +140,10 @@ export interface StatusConfig { /** Draw a placeholder where a segment said nothing, so a typo and missing data look different. */ debug?: boolean /** - * Vertical lines: rows to draw at most. Settable here as well as per line, because writing it - * here is the natural guess and having it quietly ignored costs exactly the rows it was meant - * to keep. + * Rows a column draws at most: the name every bay's sidebar block uses. Inside `lines`, a line's + * own cap is still `maxRows`. */ - maxRows?: number + sidebarRows?: number paddingLeft?: number paddingRight?: number paddingTop?: number @@ -131,80 +158,237 @@ export interface StatusConfig { modules?: string[] } -export interface CockpitStatusConfig { - statusline?: StatusConfig +/** + * The kind of value each key takes, as the loader checks it: a value of another kind is dropped + * with a `!` row naming the key, where it used to reach the renderer and fail there — or nowhere. + */ +export const KINDS = { + preset: "", + surface: "", + segments: [] as unknown[], + override: {} as Record, + separator: "", + stack: "", + icons: true, + debug: false, + paddingLeft: 0, + paddingRight: 0, + paddingTop: 0, + paddingBottom: 0, + lines: [] as unknown[], + commands: {} as Record, + modules: [] as unknown[], } -export function globalConfigPath(env: Record = process.env): string { - const base = env.XDG_CONFIG_HOME ?? join(env.HOME ?? homedir(), ".config") - return join(base, "opencode-cockpit", CONFIG_FILE) +const SURFACES: readonly string[] = ["bottom", "sidebar"] + +export interface LoadedStatus { + /** Every source merged, as written: the gaps are `resolveLines`'s to fill. */ + config: StatusConfig + /** The block's place in the sidebar, from the top-level `sidebar` list. */ + order: number + /** + * What to fix, one `!` row each: Status's own settings, and — because Status is the one bay every + * install draws — the notices that belong to no bay (a file that would not parse, a top-level + * name nothing reads, an entry in the `sidebar` list that is not a bay). + */ + notices: string[] + settings: Settings } -/** Reads and merges every source. `options` is the plugin entry's own options object. */ -export function loadStatusConfig( - directory: string, - options?: unknown, - env: Record = process.env, -): StatusConfig { - return mergeStatus( - mergeStatus(readStatusFile(globalConfigPath(env)), readStatusFile(join(directory, PROJECT_FILE))), - asStatusConfig(options), - ) +export interface StatusInput { + /** The plugin entry's options: the section's own keys, or a whole config with a `status` section. */ + options?: unknown + where?: SettingsWhere + /** Already loaded, as `cockpit_settings` and doctor have them. */ + settings?: Settings } -export function readStatusFile(path: string): StatusConfig { - if (!existsSync(path)) return {} - try { - return asStatusConfig(JSON.parse(readFileSync(path, "utf8"))) - } catch { - return {} +/** Reads and merges every source. Never throws. */ +export function loadStatus(input: StatusInput = {}): LoadedStatus { + const loaded = baySettings("status", KINDS, { + options: input.options, + ...(input.settings ? { settings: input.settings } : { where: input.where }), + }) + const written = loaded.written as StatusConfig + const config: StatusConfig = { ...written } + /** `features.status: false` turns the bay off as `enabled: false` does. */ + if (!loaded.config.enabled) config.enabled = false + /** The shared switch, in Status's words: off the sidebar means at the bottom. */ + if (written.sidebar === false && written.surface === undefined) config.surface = "bottom" + if (typeof written.sidebarRows === "number") config.sidebarRows = loaded.config.sidebarRows + /** + * Modules add up rather than replace: a project can bring its own segments without losing the ones + * you use everywhere. Every other list replaces the one before it, as in every bay. + */ + const modules = [ + ...loaded.settings.layers.flatMap((layer) => strings(layer.sections.status?.modules)), + ...strings(optionsSection(input.options)?.modules), + ] + if (modules.length > 0) config.modules = [...new Set(modules)] + else delete config.modules + + const own = [...cockpitNotices(loaded.settings), ...loaded.notices].map(noticeText) + return { + config, + order: loaded.order, + notices: [...own, ...configNotices(config)], + settings: loaded.settings, } } +const isObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +const strings = (value: unknown): string[] => + Array.isArray(value) ? value.filter((each): each is string => typeof each === "string") : [] + +/** Plugin options as the section: a whole config's `status`, else the options themselves. */ +function optionsSection(options: unknown): Record | undefined { + if (!isObject(options)) return undefined + return isObject(options.status) ? options.status : options +} + +/** One thing wrong in Status's settings that only Status can tell, and the key it is about. */ +export interface StatusProblem { + /** The section key it is about: `preset`, `surface`, `override`, or `lines` for one of the lines'. */ + key: "preset" | "surface" | "override" | "lines" + /** Without the `settings: ` the row adds. */ + text: string +} + /** - * Section-wise merge. `segments` is replaced rather than concatenated: a project that lists its - * own segments means "this line", not "these as well as the global ones". + * What the loader cannot know is wrong, because only Status knows its vocabulary: a preset nothing + * answers to, a surface that does not exist. Each used to fall back in silence — an unknown preset + * was quietly the default line, which looks like a preset that does nothing. */ -export function mergeStatus(base: StatusConfig, over: StatusConfig): StatusConfig { - const merged: StatusConfig = { ...base, ...over } - if (base.commands || over.commands) merged.commands = { ...base.commands, ...over.commands } - // Modules add up: a project can bring its own segments without losing the ones you use everywhere. - if (base.modules || over.modules) merged.modules = [...(base.modules ?? []), ...(over.modules ?? [])] - return merged +export function configNotices(config: StatusConfig): string[] { + return configProblems(config).map((problem) => `settings: ${problem.text}`) +} + +/** The same, with the key each one is about: for a notice that names its file (`statusNotices`). */ +export function configProblems(config: StatusConfig): StatusProblem[] { + const out: StatusProblem[] = [] + const names = Object.keys(PRESETS).join(", ") + const lines: LineConfig[] = [config, ...(Array.isArray(config.lines) ? config.lines : [])] + for (const [index, line] of lines.entries()) { + const where = index === 0 ? "status" : `status.lines[${index - 1}]` + const key = index === 0 ? undefined : "lines" + /** The name first: in a 24-column sidebar it is what survives the wrap. */ + if (typeof line.preset === "string" && !PRESETS[line.preset]) { + out.push({ key: key ?? "preset", text: `no preset "${line.preset}" (${names})` }) + } + if (line.surface !== undefined && !SURFACES.includes(line.surface)) { + out.push({ key: key ?? "surface", text: `"${where}.surface" is "sidebar" or "bottom"` }) + } + } + return [...out, ...overrideProblems(config)] } /** - * Accepts either a whole cockpit config (`{ statusline: {...} }`) or the statusline section on its - * own, because plugin-entry options are written straight onto the `tui.json` entry. + * An override that changes nothing is said out loud: `"gti"` matching no segment would otherwise + * be a row that kept its old look with no word as to why. A section-wide override is checked against + * every line that uses it, and is only wrong when it matches none of them. */ -export function asStatusConfig(input: unknown): StatusConfig { - if (!input || typeof input !== "object") return {} - const raw = input as Record - const section = raw.statusline ?? (raw.status as unknown) - if (section && typeof section === "object") return section as StatusConfig - const own: StatusConfig = {} - for (const key of [ - "enabled", - "preset", - "surface", - "segments", - "separator", - "stack", - "icons", - "debug", - "maxRows", - "paddingLeft", - "paddingRight", - "paddingTop", - "paddingBottom", - "lines", - "commands", - "modules", - "sidebarOrder", - ] as const) { - if (raw[key] !== undefined) Object.assign(own, { [key]: raw[key] }) +function overrideProblems(config: StatusConfig): StatusProblem[] { + const out: StatusProblem[] = [] + const checked = new Map }>() + for (const [index, source] of lineSources(config).entries()) { + const raw = source.line.override ?? config.override + const where = source.line.override !== undefined && config.lines?.length ? `status.lines[${index}].` : "" + if (raw === undefined) continue + if (!isObject(raw)) { + out.push({ + key: where ? "lines" : "override", + text: `"${where || "status."}override" should be an object of segment names`, + }) + continue + } + const base = baseSegments(source.line, config) + const seen = checked.get(raw) ?? { where, from: base.from, types: new Set() } + for (const entry of base.segments) seen.types.add(segmentType(entry)) + checked.set(raw, seen) } - return own + for (const [override, { where, from, types }] of checked) { + const key = where ? "lines" : "override" + for (const [name, change] of Object.entries(override)) { + if (!isChange(change)) { + out.push({ + key, + text: `${where}override "${name}" is false, a segment name, or an object of its settings`, + }) + } else if (!types.has(name)) { + const meant = closestSegment(name, [...types]) + out.push({ + key, + text: `${where}override "${name}" matches no segment in ${from}${meant ? ` — did you mean "${meant}"?` : ""}`, + }) + } + } + } + return out +} + +/** + * Every notice Status draws for these settings, as notices — the loader's, its own keys' kinds, and + * its vocabulary's — each naming the file its key was written in. Offered to `cockpit_settings` and + * doctor (`offerSettingsCheck`), so "Notices: none" there means no `!` row here. + */ +export function statusNotices(input: { settings: Settings; options?: unknown }): SettingsNotice[] { + const loaded = loadStatus({ options: input.options, settings: input.settings }) + const options = optionsSection(input.options) + /** The last source that wrote the key: plugin options win, then the project file, then the global. */ + const fileOf = (key: StatusProblem["key"]) => + options && key in options + ? OPTIONS_SOURCE + : ([...input.settings.layers] + .reverse() + .find((layer) => layer.sections.status && key in layer.sections.status)?.path ?? "status") + const own = baySettings("status", KINDS, { options: input.options, settings: input.settings }).notices + return [ + ...own, + ...configProblems(loaded.config).map( + (problem): SettingsNotice => ({ + bay: "status", + file: fileOf(problem.key), + kind: "invalid", + text: problem.text, + }), + ), + ] +} + +/** The name a typo most likely meant: the same letters in another order first (`gti` → `git`). */ +function closestSegment(name: string, valid: string[]): string | undefined { + const letters = (text: string) => [...text].sort().join("") + return valid.find((each) => letters(each) === letters(name)) ?? closestName(name, valid) +} + +const isChange = (change: unknown): change is SegmentChange => + change === false || (typeof change === "string" && change.length > 0) || isObject(change) + +const segmentType = (entry: string | SegmentConfig) => (typeof entry === "string" ? entry : entry.type) + +/** + * A line's segments with an override applied, each change in the place of the segment it names — + * every segment of that name, so `{ "sep": false }` takes out every hairline. A change that is not + * one (`true`, a number) leaves the segment as it was; `configNotices` says so. + */ +export function applyOverride( + segments: readonly (string | SegmentConfig)[], + override: unknown, +): (string | SegmentConfig)[] { + if (!isObject(override)) return [...segments] + const out: (string | SegmentConfig)[] = [] + for (const entry of segments) { + const type = segmentType(entry) + const change = Object.hasOwn(override, type) ? override[type] : undefined + if (!isChange(change)) out.push(entry) + else if (change === false) continue + else if (typeof change === "string") out.push(change) + else out.push({ ...asSegmentConfig(entry), ...change } as SegmentConfig) + } + return out } /** @@ -238,6 +422,46 @@ export const DEFAULT_SEGMENTS: (string | SegmentConfig)[] = [ export const DEFAULT_SEPARATOR = " │ " +/** + * The sidebar's column: a table. A heading, the window as one solid bar, the tokens broken into named + * rows, a proxy's budget, and the branch's whole diff. + * + * It is the layout a user arrived at after five rejected iterations, and the reasons are worth more + * than the rows: every number gets a word, the labels are a fixed column so the values line up, the + * bar is solid rather than dashed, and the groups are separated by hairlines rather than headings — a + * heading cannot know whether the rows under it will draw. A row whose figure is zero is not drawn, + * the budget rows say nothing without a proxy, and a hairline with nothing on one side of it goes too. + * + * It sits beside OpenCode's own Context block and says it better; turn that one off with + * `{ "plugin_enabled": { "internal:sidebar-context": false } }` in `tui.json` (OpenCode 1) or + * `"-opencode.sidebar.context"` in `cli.json`'s `plugins` (OpenCode 2). + */ +export const SIDEBAR_SEGMENTS: (string | SegmentConfig)[] = [ + "title", + { type: "context", style: "solid", width: 16, icon: "" }, + /** + * Why it stalled — `retry 2 in 5s` — which OpenCode shows as a spinner and nothing more. It is the + * reason this bay exists, so it outranks everything but the bar when rows run out; under the bar + * rather than above it, so a row that comes and goes does not move the bar about. + */ + { type: "session.status", priority: 95, icon: "", working: false }, + /** + * A broken MCP or language server — `! github, linear +2` in red — and nothing while every one is + * healthy. The table is the default now, so it is where most people would ever learn one failed. + */ + "diagnostics", + { type: "tokens", style: "row", icon: "" }, + "in", + "out", + "cache", + "write", + "sep", + "spend", + "avail", + "sep", + "git", +] + /** * Whole lines, by the name of what you want. * @@ -247,7 +471,7 @@ export const DEFAULT_SEPARATOR = " │ " */ export const PRESETS: Record< string, - { about: string; surface: Surface; segments: (string | SegmentConfig)[] } + { about: string; surface: Surface; segments: (string | SegmentConfig)[]; maxRows?: number } > = { minimal: { about: "how full the context is, and what changed", @@ -280,26 +504,17 @@ export const PRESETS: Record< ], }, sidebar: { - about: "a quiet column beside OpenCode's own blocks", + about: "a table: the window, where the tokens went, a proxy's budget, the branch's diff", surface: "sidebar", - segments: [ - { type: "context", style: "bar", width: 14, icon: "" }, - /** - * Why it stalled — `retry 2 in 5s` — which OpenCode shows as a spinner and nothing more. It is - * the reason this bay exists, so it outranks everything but the bar when rows run out; under - * the bar rather than above it, so a row that comes and goes does not move the bar about. - */ - { type: "session.status", priority: 95 }, - { type: "tokens", format: "tk {total}", icon: "" }, - { type: "tokens", format: "cache {cacheRead}", icon: "" }, - { type: "git.diff", icon: "" }, - { type: "session.time", of: "turn", icon: "" }, - "todo", - "diagnostics", - ], + segments: SIDEBAR_SEGMENTS, + /** Every row it has, on a busy session with a budget: the table is the point of it. */ + maxRows: 14, }, } +/** What a line draws when it names no preset and lists no segments: the surface's own. */ +const PRESET_FOR: Record = { sidebar: "sidebar", bottom: "default" } + export interface ResolvedLine { surface: Surface segments: (string | SegmentConfig)[] @@ -323,34 +538,68 @@ const PADDING: Record ({ line })) + return [ + { + line: { + preset: config.preset, + surface: config.surface, + segments: config.segments, + override: config.override, + separator: config.separator, + stack: config.stack, + icons: config.icons, + debug: config.debug, + }, + }, + ] +} + +/** + * Where a line draws and the segments it starts from, before its override — and what to call that + * list in a notice: `the sidebar preset`, or the `segments` that were written. + */ +function baseSegments( + line: LineConfig, + config: StatusConfig, +): { surface: Surface; segments: (string | SegmentConfig)[]; from: string; maxRows?: number } { + // A preset fills in what was not written; it never overrides what was. + const name = line.preset ?? config.preset ?? "" + const named = PRESETS[name] + const asked = line.surface ?? config.surface + const surface: Surface = SURFACES.includes(asked ?? "") + ? (asked as Surface) + : (named?.surface ?? DEFAULT_SURFACE) + /** No preset by a name that exists: the one for the surface, so the sidebar is never blank. */ + const presetName = named ? name : PRESET_FOR[surface] + const preset = PRESETS[presetName] + const written = line.segments ?? config.segments + return { + surface, + segments: written ?? preset?.segments ?? DEFAULT_SEGMENTS, + from: written ? '"segments"' : `the ${presetName} preset`, + ...(preset?.maxRows !== undefined ? { maxRows: preset.maxRows } : {}), + } +} + /** Normalises whatever the config said into the lines the renderer draws. */ export function resolveLines(config: StatusConfig): ResolvedLine[] { - const lines = config.lines?.length - ? config.lines - : [ - { - preset: config.preset, - surface: config.surface, - segments: config.segments, - separator: config.separator, - stack: config.stack, - icons: config.icons, - debug: config.debug, - maxRows: config.maxRows, - }, - ] - return lines.map((line) => { - // A preset fills in what was not written; it never overrides what was. - const preset = PRESETS[line.preset ?? config.preset ?? ""] - const surface = line.surface ?? config.surface ?? preset?.surface ?? "bottom" + return lineSources(config).map(({ line }) => { + const base = baseSegments(line, config) + const surface = base.surface // The sidebar is a narrow column: across, it would be three truncated words. const stack = line.stack ?? config.stack ?? (surface === "sidebar" ? "vertical" : "horizontal") return { surface, - segments: line.segments ?? config.segments ?? preset?.segments ?? DEFAULT_SEGMENTS, + segments: applyOverride(base.segments, line.override ?? config.override), separator: line.separator ?? config.separator ?? (stack === "vertical" ? "" : DEFAULT_SEPARATOR), stack, - maxRows: line.maxRows ?? config.maxRows ?? 8, + maxRows: line.maxRows ?? config.sidebarRows ?? base.maxRows ?? MAX_ROWS, icons: line.icons ?? config.icons ?? true, debug: line.debug ?? config.debug ?? false, paddingLeft: line.paddingLeft ?? config.paddingLeft ?? PADDING[surface].left, diff --git a/packages/status/src/core/context.ts b/packages/status/src/core/context.ts index 2148c070..f31fc908 100644 --- a/packages/status/src/core/context.ts +++ b/packages/status/src/core/context.ts @@ -4,6 +4,7 @@ * OpenCode to draw it in. */ +import type { Budget } from "./budget.ts" import type { DiffCounts } from "./diff.ts" export interface TokenCounts { @@ -103,6 +104,13 @@ export interface StatusContext { * the second renders zeros. */ diff?: DiffCounts + /** + * The branch's whole diff against where it forked from the default branch: every commit on it plus + * what is uncommitted — what a reviewer will read. Absent until git answers, and outside a repo. + */ + branchDiff?: DiffCounts + /** What a proxy reports spending against its cap. Absent when no proxy writes one. */ + budget?: Budget session?: SessionSnapshot lsp: ServiceSnapshot[] mcp: ServiceSnapshot[] @@ -129,8 +137,28 @@ export function todoRemaining(session: SessionSnapshot | undefined): number { return Math.max(0, session.todo.total - session.todo.completed) } +/** + * A service's state as one word. OpenCode 1 hands a word; OpenCode 2 a tagged object — + * `{ status: "connected" }`, `{ status: "failed", error }` — which `String()` turned into + * `[object Object]`, so every connected MCP server read as broken (issue #34). + */ +export function serviceStatus(raw: unknown): string { + if (typeof raw === "string") return raw + const tagged = raw && typeof raw === "object" ? (raw as { status?: unknown }).status : undefined + return typeof tagged === "string" ? tagged : "" +} + const HEALTHY = new Set(["connected", "ready", "ok", "running", "active"]) +/** + * Not working, and not wrong: turned off on purpose, or still connecting — OpenCode marks a server + * that never connects `failed` once it gives up (measured: under a minute on 2.0.18). A missing word + * is quiet too, since a false alarm in red is what #34 was; any other word, known or not, is an alarm. + */ +const QUIET = new Set(["disabled", "pending", "starting", "connecting", ""]) export function unhealthy(list: readonly ServiceSnapshot[]): ServiceSnapshot[] { - return list.filter((item) => !HEALTHY.has(item.status.toLowerCase())) + return list.filter((item) => { + const status = item.status.toLowerCase() + return !HEALTHY.has(status) && !QUIET.has(status) + }) } diff --git a/packages/status/src/core/custom.ts b/packages/status/src/core/custom.ts index b1353714..5a4ab14a 100644 --- a/packages/status/src/core/custom.ts +++ b/packages/status/src/core/custom.ts @@ -1,4 +1,4 @@ -import { rmSync } from "node:fs" +import { existsSync, rmSync } from "node:fs" import { homedir } from "node:os" import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path" import { pathToFileURL } from "node:url" @@ -159,8 +159,24 @@ export async function loadCustomSegments( }) } } catch (err) { - errors.push(`${path}: ${err instanceof Error ? err.message : String(err)}`) + errors.push( + `${path}: ${existsSync(full) ? (err instanceof Error ? err.message : String(err)) : missing(full)}`, + ) } } return { segments, errors } } + +/** + * The sidebar examples 0.9 removed when the `sidebar` preset became the table they built toward. A + * config that still points at one in the package gets a sentence that says what replaced it, rather + * than a resolver's stack of paths. + */ +const REMOVED_EXAMPLES = new Set(["sidebar.ts", "sidebar-full.ts", "sidebar-budget.ts"]) + +/** What a config pointing at one of them is told — in the brief, the log and the `!` row. */ +export const REMOVED_EXAMPLE = `removed in 0.9: use "preset": "sidebar"` + +function missing(full: string): string { + return REMOVED_EXAMPLES.has(basename(full)) ? REMOVED_EXAMPLE : "no file there" +} diff --git a/packages/status/src/core/diff.ts b/packages/status/src/core/diff.ts index ac430e24..d7f63a26 100644 --- a/packages/status/src/core/diff.ts +++ b/packages/status/src/core/diff.ts @@ -47,12 +47,38 @@ export const UNCOMMITTED = "git diff --shortstat HEAD" * it — and a great many are — costs exactly what it did before this segment learned to use git. */ export function wantsDiff( - lines: ReadonlyArray<{ segments: ReadonlyArray }>, + lines: ReadonlyArray<{ segments: ReadonlyArray }>, ): boolean { return lines.some((line) => line.segments.some((segment) => { const name = typeof segment === "string" ? segment : segment.type - return name === "git.diff" || name === "session.diff" + return name === "git.diff" || name === "session.diff" || (name === "git" && !againstBranch(segment)) }), ) } + +/** The table's `git` row counts what is uncommitted unless it says `"against": "branch"`. */ +export const againstBranch = (segment: string | { against?: unknown }): boolean => + typeof segment !== "string" && segment.against === "branch" + +/** + * The branch's whole diff: from where it forked off `base` to the working tree — every commit on the + * branch plus what is uncommitted, which is what a reviewer will read rather than what this session + * happened to touch. On the default branch itself the fork point is `HEAD`, so it is what is + * uncommitted. A base that does not resolve fails the command, and the row keeps saying nothing. + */ +export function branchDiffCommand(base: string): string { + const quoted = `'${base.replaceAll("'", "'\\''")}'` + return `git diff --shortstat "$(git merge-base ${quoted} HEAD)"` +} + +/** Whether any line draws the branch's diff (`git`), so no git runs for a row nobody shows. */ +export function wantsBranchDiff( + lines: ReadonlyArray<{ segments: ReadonlyArray }>, +): boolean { + return lines.some((line) => + line.segments.some( + (segment) => (typeof segment === "string" ? segment : segment.type) === "git" && againstBranch(segment), + ), + ) +} diff --git a/packages/status/src/core/fixtures.ts b/packages/status/src/core/fixtures.ts index 88c1f96d..aae31a5a 100644 --- a/packages/status/src/core/fixtures.ts +++ b/packages/status/src/core/fixtures.ts @@ -46,6 +46,8 @@ const base = (over: Partial = {}): StatusContext => ({ home: "/Users/you", branch: "feature/checkout", defaultBranch: "main", + /** The branch against where it forked: more than this session touched, and there with no session. */ + branchDiff: { files: 5, additions: 312, deletions: 48 }, version: "0.3.0", lsp: [{ name: "tsserver", status: "connected" }], mcp: [{ name: "github", status: "connected" }], @@ -99,6 +101,7 @@ export const FIXTURES: Record - `${line.surface} (${line.stack}), ${line.segments} segment${line.segments === 1 ? "" : "s"}`, - ) - .join("; ") - const modules = - report.modules.listed.length === 0 - ? "none" - : `${report.modules.listed.join(", ")} — ${report.modules.registered} segment${report.modules.registered === 1 ? "" : "s"} registered` - - return [ - "Help me customise my opencode-cockpit statusline.", - "", - "## Where it stands", - "", - `- Config to edit: ${target.path}${target.exists ? "" : " (does not exist yet — create it)"}`, - `- Drawing now: ${drawing}`, - `- My modules: ${modules}`, - ...(report.modules.errors.length > 0 - ? ["- Failing to load:", ...report.modules.errors.map((error) => ` - ${error}`)] - : []), - `- Version: ${report.version}`, - "", - "## What you can change", - "", - 'The config file holds `{ "statusline": { ... } }`. Two surfaces: `bottom` (a line under the', - "prompt) and `sidebar` (a column). A whole line by name with `preset`, then anything written", - "beside it wins:", - "", - ...Object.entries(PRESETS).map( - ([name, preset]) => `- \`"preset": "${name}"\` — ${preset.about} (${preset.surface})`, - ), - "", - `Built-in segment names: ${BUILTINS.map((segment) => segment.name).join(", ")}.`, - 'A segment can also be `{ "type": "...", ... }` with its own settings, or a shell command.', - "", - "For anything the built-ins do not cover, write a TypeScript module and list it in `modules`:", - "it exports `{ segments: { name(ctx, config) { return { runs: [...] } } } }` against", - "`@opencode-cockpit/status/segment`, is handed a snapshot rather than OpenCode's api, and is", - "called on every repaint so it can keep history. Returning `undefined` hides a segment.", - "", - "## Before you say it is done", - "", - "Look at it. Do not edit, restart OpenCode and judge from a sentence:", - "", - "```sh", - "bunx @opencode-cockpit/status preview --watch # redraws on every save", - "bunx @opencode-cockpit/status preview --debug # mark segments that drew nothing", - "bunx @opencode-cockpit/status preview --module --state full", - "```", - "", - "The design rules are a skill shipped with the package at", - "`node_modules/@opencode-cockpit/status/skills/statusline-design/SKILL.md` — read it before", - "designing anything, and copy the examples beside it rather than inventing glyphs. The reference", - "is https://codestz.github.io/opencode-cockpit/status/.", - "", - "Ask me what I want it to show before you edit anything.", - ].join("\n") -} diff --git a/packages/status/src/core/notices.ts b/packages/status/src/core/notices.ts index 707dfa16..12983a74 100644 --- a/packages/status/src/core/notices.ts +++ b/packages/status/src/core/notices.ts @@ -2,22 +2,21 @@ * Rows the line draws about itself. * * Everything else here follows the rule that a segment with nothing to say says nothing — which is - * right for data and wrong for the line's own failures. A module that would not load and a column - * that ran out of rows both end as *segments that are simply not there*, indistinguishable from a - * segment that had nothing to report, and the only notice either one gets is a toast that is gone - * in ten seconds. Both cost a debugging session to tell apart from a bug in the module itself. + * right for data and wrong for the line's own failures. A module that would not load, a setting that + * is no longer read and a column that ran out of rows all end as *segments that are simply not + * there*, indistinguishable from a segment that had nothing to report, and the only notice any of + * them got was a toast that is gone in ten seconds. Each cost a debugging session to tell apart from + * a bug in the module itself. * * So they get a row. It costs one line of the design and it says what happened where the person is * already looking. */ -import { GLYPH } from "@opencode-cockpit/client/design" -import { truncate } from "./format.ts" +import { warnRows } from "@opencode-cockpit/client/design" +import { REMOVED_EXAMPLE } from "./custom.ts" +import { basename } from "./format.ts" import type { Segment } from "./types.ts" -/** Long enough to name the module, short enough for a sidebar column. */ -const NOTICE = 30 - /** * A column could not draw every row it was given. The preview has always said so; without this the * running TUI just left them out, and a row that never appears reads as a broken segment. @@ -27,17 +26,35 @@ export function overflowNotice(dropped: number): Segment | undefined { return { id: "notice.rows", priority: 0, - runs: [{ text: `↳ ${dropped} more — raise maxRows`, tone: "muted", dim: true }], + runs: [{ text: `↳ ${dropped} more — raise sidebarRows`, tone: "muted", dim: true }], } } /** - * A module did not load, so none of its segments exist. Named rather than counted where there is - * only one, because the name is what turns "nothing drew" into something to go and fix. + * A module that did not load, as a notice: the file's name rather than the path written in the + * config, which is the part a sidebar has room for and the part you go and fix. + */ +export function moduleNoticeText(error: string): string { + const at = error.indexOf(": ") + if (at === -1) return `module ${error}` + const file = basename(error.slice(0, at)) + const why = error.slice(at + 2) + /** One of the examples 0.9 folded into the `sidebar` preset: the fix, in as few words as fit. */ + if (why === REMOVED_EXAMPLE) return `${file} was ${why}` + return `module ${file}: ${why}` +} + +/** + * Every notice, as the rows a surface draws: `!` in the warning tone, each sentence wrapped to the + * room there is (`warnRows`), so a 24-column sidebar keeps the fix the sentence ends with. Above the + * line and outside its row cap — a notice never pushes out a row you asked for, nor gives way to one. */ -export function moduleNotice(errors: readonly string[], width = NOTICE): Segment | undefined { - if (errors.length === 0) return undefined - const text = - errors.length === 1 ? truncate(errors[0] as string, width) : `${errors.length} modules failed to load` - return { id: "notice.module", priority: 1000, runs: [{ text: `${GLYPH.warn} ${text}`, tone: "error" }] } +export function noticeRows(notices: readonly string[], width: number): Segment[] { + return notices.flatMap((text, n) => + warnRows(text, width).map((runs, row) => ({ + id: `notice.${n}.${row}`, + priority: 1000, + runs: runs.map((run) => ({ text: run.text, ...(run.tone ? { tone: run.tone } : {}) })), + })), + ) } diff --git a/packages/status/src/core/preview.ts b/packages/status/src/core/preview.ts new file mode 100644 index 00000000..66daa6e6 --- /dev/null +++ b/packages/status/src/core/preview.ts @@ -0,0 +1,236 @@ +/** + * The preview's settings and drawing, apart from the terminal it prints to, so a test can hold the + * preview to the rule that matters most: it draws what OpenCode will. An agent setting the table up + * once previewed a file that said `"preset": "sidebar"` and was shown a line under the prompt, with + * `sidebarRows: 14` capped at 8 — and could not tell whether the preview or the setting was wrong. + */ + +import { readFileSync } from "node:fs" +import { type SettingsWhere, settingsPaths } from "@opencode-cockpit/client/settings" +import type { Budget } from "./budget.ts" +import { + asSegmentConfig, + configNotices, + type LoadedStatus, + loadStatus, + type ResolvedLine, + resolveLines, + type Surface, +} from "./config.ts" +import type { StatusContext } from "./context.ts" +import { noticeRows } from "./notices.ts" +import { fit, fitColumn } from "./render.ts" +import { buildSegments, MARK, type SegmentDef, segmentWidth } from "./segments.ts" +import type { Run } from "./types.ts" + +/** The flags `preview` takes: those with a value, and the switches. */ +const VALUED = ["config", "as", "proxy", "module", "state", "width", "surface"] as const +const SWITCHES = ["with-config", "debug", "watch", "help"] as const + +export interface PreviewArgs { + values: Partial> + switches: Set<(typeof SWITCHES)[number]> + /** What could not be read: an unknown flag, a flag missing its value, a surface that is not one. */ + errors: string[] +} + +/** + * `--config path` and `--config=path` alike. An unknown flag is an error rather than ignored: a flag + * the preview quietly dropped drew the user's own settings in place of the file it was handed. + */ +export function parseArgs(argv: readonly string[]): PreviewArgs { + const out: PreviewArgs = { values: {}, switches: new Set(), errors: [] } + const args = argv.filter((arg) => arg !== "preview") + for (let index = 0; index < args.length; index++) { + const arg = args[index] as string + if (!arg.startsWith("--")) { + out.errors.push(`"${arg}" is not a flag — try --help`) + continue + } + const [name, inline] = arg.slice(2).split(/=(.*)/s, 2) as [string, string | undefined] + if ((SWITCHES as readonly string[]).includes(name)) { + out.switches.add(name as (typeof SWITCHES)[number]) + } else if ((VALUED as readonly string[]).includes(name)) { + const value = inline ?? args[++index] + if (value === undefined || (inline === undefined && value.startsWith("--"))) { + out.errors.push(`--${name} needs a value`) + if (value !== undefined) index-- + continue + } + out.values[name as (typeof VALUED)[number]] = value + } else out.errors.push(`no flag --${name} — try --help`) + } + const surface = out.values.surface + if (surface !== undefined && surface !== "sidebar" && surface !== "bottom") { + out.errors.push(`--surface is sidebar or bottom, not "${surface}"`) + } + const as = out.values.as + if (as !== undefined && as !== "global" && as !== "project") { + out.errors.push(`--as is global or project, not "${as}"`) + } + if (as !== undefined && out.values.config === undefined) out.errors.push("--as goes with --config") + return out +} + +/** Which of the two settings files a candidate stands in for. */ +export type ConfigAs = "global" | "project" + +export interface PreviewInput { + directory: string + env?: Record + /** + * A config's text — a file's, or a candidate piped on stdin. Read through the same loader as the + * TUI's, so its `status` section — comments, old names, `sidebarRows`, `override` and all — means + * here exactly what it will mean in OpenCode. + */ + configText?: string + /** + * The file `configText` is meant to become. Set, the text takes that file's place and the other file + * is read as OpenCode reads it, so a project's candidate merges over the real global config. Unset, + * the text stands alone in place of the global config, with no project file beside it. + */ + as?: ConfigAs + /** How the other file is read; the disk by default. */ + readFile?: (path: string) => string | undefined + /** Draw on this surface whatever the settings say. */ + surface?: Surface +} + +export interface PreviewSettings { + loaded: LoadedStatus + lines: ResolvedLine[] + /** The file the text stood in for, with `as`. */ + target?: string +} + +const fromDisk = (path: string): string | undefined => { + try { + return readFileSync(path, "utf8") + } catch { + return undefined + } +} + +/** The settings and the lines the TUI would draw from them: `loadStatus`, then `resolveLines`. */ +export function previewSettings(input: PreviewInput): PreviewSettings { + const env = input.env ? { env: input.env } : {} + let where: SettingsWhere = { directory: input.directory, ...env } + let target: string | undefined + if (input.configText !== undefined && input.as) { + const paths = settingsPaths({ directory: input.directory, ...env }) + target = input.as === "project" ? paths.project : paths.global + const readFile = input.readFile ?? fromDisk + where = { ...where, read: (path) => (path === target ? input.configText : readFile(path)) } + } else if (input.configText !== undefined) { + // With no `directory` the loader asks for one file, the global one: this is it. + where = { ...env, read: () => input.configText } + } + const loaded = loadStatus({ where }) + const at = target ? { target } : {} + if (!input.surface) return { loaded, lines: resolveLines(loaded.config), ...at } + /** Moved, a line starts from another preset: its notices are the moved line's, as they would be. */ + const config = forced(loaded.config, input.surface) + const before = new Set(configNotices(loaded.config)) + const notices = [...loaded.notices.filter((notice) => !before.has(notice)), ...configNotices(config)] + return { loaded: { ...loaded, notices }, lines: resolveLines(config), ...at } +} + +/** `--surface`: every line on that surface, the way `"surface"` in the file would put it. */ +function forced(config: LoadedStatus["config"], surface: Surface | undefined): LoadedStatus["config"] { + if (!surface) return config + return { + ...config, + surface, + ...(config.lines ? { lines: config.lines.map((line) => ({ ...line, surface })) } : {}), + } +} + +/** What a sidebar is in OpenCode at a usual window size: a preview no wider than the real thing. */ +export const SIDEBAR_WIDTH = 34 + +/** The room a line has: `--width`, else the sidebar's, else the terminal's less the padding. */ +export const roomFor = (line: ResolvedLine, terminal: number, width?: number): number => + width && width > 0 + ? width + : line.surface === "sidebar" + ? SIDEBAR_WIDTH + : terminal - line.paddingLeft - line.paddingRight + +export interface DrawInput { + lines: readonly ResolvedLine[] + /** The `!` rows the bay would draw above its first sidebar line. */ + troubles: readonly string[] + fixture: { ctx: StatusContext } + terminal: number + width?: number + debug: boolean + custom?: ReadonlyMap + budget?: Budget + /** Runs to text: ANSI colour for a terminal, the plain text otherwise. */ + paint: (runs: readonly Run[]) => string + dim: (text: string) => string +} + +/** + * One state, every line, as the TUI fits it. With `debug`, every row says where it came from: + * `✓git` beside a row that drew, `✗todo` in place of one that ran and said nothing, `?gti` for a + * name nothing answers to — glyphs rather than colour, so they read the same piped into a file. + */ +export function drawState(input: DrawInput): string[] { + const out: string[] = [] + const noticeLine = input.lines.find((line) => line.surface === "sidebar") ?? input.lines[0] + for (const line of input.lines) { + const room = roomFor(line, input.terminal, input.width) + const ctx = { ...input.fixture.ctx, width: room, ...(input.budget ? { budget: input.budget } : {}) } + if (line === noticeLine) { + for (const row of noticeRows(input.troubles, room)) out.push(` ${input.paint(row.runs).trimEnd()}`) + } + const built = buildSegments(ctx, line.segments.map(asSegmentConfig), { + ...(input.custom ? { custom: input.custom } : {}), + icons: line.icons, + debug: input.debug, + }) + const vertical = line.stack === "vertical" + const fitted = vertical ? fitColumn(built, room, line.maxRows) : fit(built, room, line.separator) + if (fitted.segments.length === 0) { + out.push(` ${input.dim(`(${line.surface}: nothing to draw)`)}`) + continue + } + out.push(` ${input.dim(`${line.surface}, ${room} cols`)}`) + const named = (id: string) => `${MARK.drew}${id.replace(/#\d+$/, "")}` + if (vertical) { + for (const segment of fitted.segments) { + const row = input.paint(segment.runs) + if (!input.debug || segment.marker) out.push(` ${row}`.trimEnd()) + else + out.push( + ` ${row}${" ".repeat(Math.max(1, room - segmentWidth(segment) + 2))}${input.dim(named(segment.id))}`, + ) + } + } else { + out.push( + ` ${fitted.segments.map((segment) => input.paint(segment.runs)).join(input.dim(line.separator))}`, + ) + if (input.debug && fitted.segments.some((segment) => !segment.marker)) { + const marks = fitted.segments.map((segment) => + segment.marker ? segmentText(segment) : named(segment.id), + ) + out.push(` ${input.dim(marks.join(" "))}`) + } + } + /** Rows a real sidebar would have dropped — the TUI says `↳ N more` in their place. */ + if (fitted.dropped > 0) { + const over = vertical ? `sidebarRows is ${line.maxRows}` : `${room} columns` + out.push(` ${input.dim(`↳ ${fitted.dropped} dropped — ${over}`)}`) + } + const widest = Math.max(0, ...fitted.segments.map(segmentWidth)) + if (vertical && widest > room) + out.push(` ${input.dim(`↳ widest row is ${widest} cols, the column has ${room}`)}`) + } + return out +} + +const segmentText = (segment: { runs: readonly Run[] }) => segment.runs.map((run) => run.text).join("") + +/** Plain text, for a pipe, NO_COLOR, and tests. */ +export const plainRuns = (runs: readonly Run[]): string => runs.map((run) => run.text).join("") diff --git a/packages/status/src/core/reference.ts b/packages/status/src/core/reference.ts new file mode 100644 index 00000000..ef2382a7 --- /dev/null +++ b/packages/status/src/core/reference.ts @@ -0,0 +1,129 @@ +/** + * The `status-setup` skill's reference, written from the code: the `status` keys (from the catalog + * every bay's reference comes from), the presets, every built-in segment and what each says. A test + * fails when `skills/status-setup/references/settings.md` is not what `statusReference()` writes — + * `bun packages/status/src/cli/reference.ts` writes it again. + */ + +import { bayKeys, shownDefault } from "@opencode-cockpit/client/catalog" +import { BUILTINS } from "./builtins/index.ts" +import { DEFAULT_SURFACE, PRESETS, type SegmentConfig } from "./config.ts" + +/** + * What each built-in says, in a line, with the settings worth knowing. Keyed by name, so a built-in + * added without a line here fails the reference's test rather than shipping undescribed. + */ +export const SEGMENT_ABOUT: Readonly> = { + cwd: "the folder, shortened from the left (`maxWidth`, default 28)", + "git.branch": "the branch; muted on the default branch", + "git.diff": "what is uncommitted: `+added / -removed` (`format` with `{files}`, `{added}`, `{removed}`)", + model: "the model, short (`full: true` for its whole id)", + context: + 'how full the window is: `style` `"percent"` (default), `"bar"`, `"solid"` (the table\'s), `"split"` (cache, input, output in one bar); `width`, `warnAt` 0.75, `dangerAt` 0.9', + tokens: + 'the token total; `format` with `{total}`, `{input}`, `{output}`, `{cacheRead}`, `{cacheWrite}`; `style` `"row"` (the table\'s) or `"parts"`', + cost: "what the session cost, hidden when nothing is priced (`showZero`, `currency`)", + title: 'the table\'s heading, "Status" (`text` to rename it)', + in: "table row: fresh prompt tokens, with their share", + out: "table row: output and reasoning tokens, with their share", + cache: "table row: tokens read from cache, with their share", + write: "table row: tokens written to cache, with their share", + sep: "a hairline between groups; drawn only with a row on both sides", + spend: "table row: a proxy's spend, from its budget file; silent without one", + avail: "table row: what is left of a proxy's budget; silent without one", + git: "table row: the branch's whole diff against its base", + diagnostics: "errors and warnings from the language servers", + version: "the Cockpit version", + text: 'a fixed `value`: `{ "type": "text", "value": "hi" }`', + command: + 'a shell command\'s output: `{ "type": "command", "name": "" }` (`row` for one line of many)', + todo: "todo progress, `3/7`; hidden when all are done (`showComplete`)", + "session.status": "why a turn stalled, `retry 2 in 5s`; silent otherwise", + "session.time": 'how long: the session, or the last answer with `of: "turn"` (`coarse` for minutes)', +} + +const segmentName = (segment: string | SegmentConfig) => + typeof segment === "string" ? segment : segment.type + +/** `references/settings.md` of the `status-setup` skill, exactly. */ +export function statusReference(): string { + const cell = (text: string) => text.replaceAll("|", "\\|") + return [ + "", + "", + "# Status settings reference", + "", + 'Everything goes in the `"status"` section of `~/.config/opencode-cockpit/config.json` (every project) or', + "`/.cockpit.json` (this one). JSONC. Read when OpenCode starts: a change applies after a restart.", + "`cockpit_settings` gives the exact paths and what is written now.", + "", + "## Keys", + "", + "| Key | Type | Default | What it does |", + "| --- | --- | --- | --- |", + ...bayKeys("status").map( + (info) => `| \`${info.key}\` | ${cell(info.type)} | ${cell(shownDefault(info))} | ${info.about} |`, + ), + "", + 'A line in `"lines": [ … ]` takes `preset`, `surface`, `segments`, `override`, `separator`, `stack`, `icons`,', + "`debug`, `maxRows` (its own row cap) and the paddings; anything a line leaves out comes from the section.", + "", + "## Changing a row or two: `override`", + "", + "`override` changes the preset's segments by name and keeps the rest, so the line still follows the", + "preset. `segments` is the whole list instead: write it only to build a different line.", + "", + "```jsonc", + '"override": {', + ' "git": { "against": "branch" }, // an object merges into that segment\'s settings', + ' "write": false, // false drops it', + ' "session.status": { "working": true }, // the working clock as well as a stall', + ' "spend": "cost" // a name swaps it, in the same place', + "}", + "```", + "", + 'A change applies to every segment of that name (`"sep": false` drops every hairline). With `segments`', + "written too, the changes apply to those. A name that matches no segment in the line is a `!` row that", + "names the closest one.", + "", + "## Presets", + "", + "A preset fills in what is not written; anything written beside it wins.", + "", + "| Preset | Surface | What it shows | Segments |", + "| --- | --- | --- | --- |", + ...Object.entries(PRESETS).map( + ([name, preset]) => + `| \`${name}\` | ${preset.surface}${preset.surface === DEFAULT_SURFACE ? " (the default)" : ""} | ${preset.about} | ${preset.segments.map((segment) => `\`${segmentName(segment)}\``).join(" ")} |`, + ), + "", + "## Built-in segments", + "", + 'Write a name (`"cwd"`), or the name with settings (`{ "type": "context", "style": "bar" }`). Every', + "segment also takes `prefix`, `suffix`, `priority` (higher survives a narrow line), `color` (a tone:", + "`text muted accent success warning error info`, or `#rrggbb`) and `icon`.", + "", + "| Segment | What it says |", + "| --- | --- |", + ...BUILTINS.map((segment) => `| \`${segment.name}\` | ${cell(SEGMENT_ABOUT[segment.name] ?? "")} |`), + "", + "## Commands", + "", + "Any CLI's output as a segment — an existing Claude Code statusline script runs unchanged:", + "", + "```jsonc", + '"commands": { "budget": { "run": "~/bin/budget.sh", "intervalMs": 2000, "timeoutMs": 1000 } },', + '"segments": ["context", { "type": "command", "name": "budget" }]', + "```", + "", + "`claudeCodeCompat` (default true) feeds the command Claude Code's statusline JSON on stdin.", + "", + "## Modules", + "", + "For what needs the session read, a decision, or memory across ticks (a rate, a trend): a TypeScript", + 'module listed in `"modules"`, exporting `{ segments: { name(ctx, config) { return { runs: [...] } } } }`', + "against `@opencode-cockpit/status/segment`. It is handed a snapshot, not OpenCode's api, and called on", + "every repaint; returning `undefined` hides the segment. Its segments are used by name like built-ins.", + "", + ].join("\n") +} diff --git a/packages/status/src/core/render.ts b/packages/status/src/core/render.ts index 0d0ab57a..ced59714 100644 --- a/packages/status/src/core/render.ts +++ b/packages/status/src/core/render.ts @@ -1,4 +1,4 @@ -import { cutSegment, type Segment, segmentWidth } from "./segments.ts" +import { cutSegment, type Segment, segmentWidth, tidyDividers } from "./segments.ts" /** * Fitting the line to the terminal. @@ -73,8 +73,10 @@ export function fitColumn(segments: readonly Segment[], width: number, maxRows: .slice(0, maxRows) .map(({ segment }) => segment.id), ) + /** Rows dropped can strand a hairline; it goes too, and is not counted as a row you lost. */ + const kept = tidyDividers(segments.filter((segment) => keep.has(segment.id))) return { - segments: segments.filter((segment) => keep.has(segment.id)).map((segment) => cutSegment(segment, width)), + segments: kept.map((segment) => cutSegment(segment, width)), dropped: segments.length - keep.size, } } diff --git a/packages/status/src/core/report.ts b/packages/status/src/core/report.ts deleted file mode 100644 index 4c48f3b0..00000000 --- a/packages/status/src/core/report.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * What the statusline is actually doing right now, as data. - * - * The bay's own rule is that a segment with nothing to say says nothing, which is right on screen - * and leaves exactly one question unanswerable from the screen itself: is this line quiet because - * there is nothing to report, or because the config never arrived? This is the answer — which files - * were read, which surfaces are drawing, how many segments each carries, and what a module did when - * it failed to load. The `/statusline` command draws it; it lives here because it is a pure - * function of the settings, and so can be tested without a terminal. - */ - -import { existsSync } from "node:fs" -import { join } from "node:path" -import { globalConfigPath, PROJECT_FILE, type ResolvedLine } from "./config.ts" - -export interface ReportLine { - surface: string - stack: string - segments: number - /** Vertical lines only: the cap that decides which rows survive. */ - maxRows?: number -} - -export interface StatusReport { - version: string - /** Every config file the settings could have come from, in merge order, and whether it exists. */ - sources: { path: string; found: boolean }[] - lines: ReportLine[] - modules: { - /** Paths listed in `modules`, as written. */ - listed: string[] - /** Segments those modules actually registered. */ - registered: number - /** One per module that would not load, already phrased for a human. */ - errors: string[] - } -} - -export interface ReportInput { - version: string - directory: string - lines: readonly ResolvedLine[] - modules?: readonly string[] - registered: number - errors: readonly string[] - env?: Record -} - -export function buildReport(input: ReportInput): StatusReport { - const global = globalConfigPath(input.env ?? process.env) - const project = join(input.directory, PROJECT_FILE) - return { - version: input.version, - sources: [global, project].map((path) => ({ path, found: existsSync(path) })), - lines: input.lines.map((line) => ({ - surface: line.surface, - stack: line.stack, - segments: line.segments.length, - maxRows: line.stack === "vertical" ? line.maxRows : undefined, - })), - modules: { - listed: [...(input.modules ?? [])], - registered: input.registered, - errors: [...input.errors], - }, - } -} - -/** - * The one sentence a report is worth opening for: whether anything is drawing at all, and if not, - * the likeliest reason given what was found. - */ -export function reportHeadline(report: StatusReport): string { - if (report.modules.errors.length > 0) { - const count = report.modules.errors.length - return `${count} module${count === 1 ? "" : "s"} failed to load — segments from ${count === 1 ? "it" : "them"} are missing` - } - if (report.lines.length === 0) return "Nothing is drawing: no line is configured" - if (report.sources.every((source) => !source.found)) { - return "Drawing the defaults — neither config file exists yet" - } - const rows = report.lines.reduce((sum, line) => sum + line.segments, 0) - const where = [...new Set(report.lines.map((line) => line.surface))].join(" and ") - return `${rows} segment${rows === 1 ? "" : "s"} across the ${where}` -} diff --git a/packages/status/src/core/segments.ts b/packages/status/src/core/segments.ts index d3c5863a..10f141cd 100644 --- a/packages/status/src/core/segments.ts +++ b/packages/status/src/core/segments.ts @@ -103,7 +103,7 @@ export function buildSegments( const def = custom?.get(config.type) ?? findSegment(config.type) if (!def) { // A name nothing answers to: a typo, or a segment from a module that failed to load. - if (debug) out.push(marker(`?${config.type}`, "error", config.type, seen)) + if (debug) out.push(marker(`${MARK.unknown}${config.type}`, "error", config.type, seen)) continue } let piece: Pieces | undefined @@ -111,12 +111,12 @@ export function buildSegments( piece = def.render(ctx, config) } catch { // A segment that throws costs its own place on the line and nothing else. - if (debug) out.push(marker(`!${config.type}`, "error", config.type, seen)) + if (debug) out.push(marker(`${MARK.threw}${config.type}`, "error", config.type, seen)) continue } if (!piece) { // It ran and chose silence: the input it needs is missing, not its name. - if (debug) out.push(marker(config.type, "border", config.type, seen)) + if (debug) out.push(marker(`${MARK.silent}${config.type}`, "border", config.type, seen)) continue } @@ -156,21 +156,46 @@ export function buildSegments( id: count === 1 ? config.type : `${config.type}#${count}`, runs: styled, priority: typeof config.priority === "number" ? config.priority : def.priority, + ...(def.divider ? { divider: true } : {}), }) drew = true } - if (!drew && debug) out.push(marker(config.type, "border", config.type, seen)) + if (!drew && debug) out.push(marker(`${MARK.silent}${config.type}`, "border", config.type, seen)) } + return tidyDividers(out) +} + +/** + * Hairlines only between rows. A divider groups what is above it from what is below; with nothing on + * one side — the budget rows silent without a proxy, the first reply not in yet — it is a line of + * ink that says nothing, and two in a row read as a rendering fault. + */ +export function tidyDividers(segments: readonly Segment[]): Segment[] { + const out: Segment[] = [] + for (const segment of segments) { + if (segment.divider && (out.length === 0 || out[out.length - 1]?.divider)) continue + out.push(segment) + } + while (out[out.length - 1]?.divider) out.pop() return out } +/** + * What `debug` marks a segment with, one glyph in front of its name. They used to share one pair of + * brackets — `⟨?title⟩` for a name nothing answers to, `⟨todo⟩` for a segment that ran and said + * nothing — and the one character between them was easy to read past. `drew` is the preview's: it + * names the segment beside a row that did draw, so every row says where it came from. + */ +export const MARK = { drew: "✓", silent: "✗", unknown: "?", threw: "!" } as const + /** What a silent segment looks like while `debug` is on. */ function marker(text: string, tone: Tone, type: string, seen: Map): Segment { const count = (seen.get(type) ?? 0) + 1 seen.set(type, count) return { id: count === 1 ? type : `${type}#${count}`, - runs: [{ text: `⟨${text}⟩`, tone, dim: true }], + runs: [{ text, tone, dim: true }], + marker: true, // Above everything, so the thing you are debugging is not the first dropped when it is narrow. priority: 100, } diff --git a/packages/status/src/core/setup.ts b/packages/status/src/core/setup.ts new file mode 100644 index 00000000..f68cbd2b --- /dev/null +++ b/packages/status/src/core/setup.ts @@ -0,0 +1,25 @@ +/** + * Setting Status up with the agent: the `status-setup` skill and the commands that load it. + * + * Designing a line is an editing job in a file the interface never names, judged in a terminal, so + * the agent does it with the person. The flow, the presets, every segment and the design rules are + * the skill, shipped in this package (`skills/status-setup`) and written partly from the code + * (`references/settings.md`, `bun packages/status/src/cli/reference.ts`). What the files say right now + * is `cockpit_settings`, the one tool every Cockpit install has. + */ + +import { fileURLToPath } from "node:url" + +export const SETUP_SKILL = "status-setup" +/** The command, by the name it has had since 0.9. */ +export const SETUP_SLASH = "status-setup" +/** Its name until 0.9, kept one release as a command that says the new one. Removed in 0.10. */ +export const OLD_SLASH = "statusline" + +/** What either command, and the palette, hands the agent. */ +export const SETUP_PROMPT = "Use the status-setup skill to help me set up the Status bay." +/** The old name says its new one where the person reads it: first in the line it sends. */ +export const OLD_PROMPT = `/${OLD_SLASH} is now /${SETUP_SLASH}. ${SETUP_PROMPT}` + +/** Where the skill sits in this package: `src/core/` and `dist/core/` are both two levels under its root. */ +export const SETUP_SKILL_DIR = fileURLToPath(new URL(`../../skills/${SETUP_SKILL}`, import.meta.url)) diff --git a/packages/status/src/core/types.ts b/packages/status/src/core/types.ts index 809eaa7c..c5c276ee 100644 --- a/packages/status/src/core/types.ts +++ b/packages/status/src/core/types.ts @@ -48,6 +48,10 @@ export interface Segment { runs: Run[] /** Higher survives when the line is too long for the terminal. */ priority: number + /** A hairline between groups: drawn only with a row on either side of it (`tidyDividers`). */ + divider?: boolean + /** `debug`'s placeholder for a segment that drew nothing, rather than anything it drew. */ + marker?: boolean } /** What a segment may return: one styled string, or several runs. */ @@ -73,5 +77,7 @@ export interface SegmentDef { priority: number /** Shown before the text when icons are on. */ icon?: string + /** It separates groups rather than saying anything; see `Segment.divider`. */ + divider?: boolean render(ctx: StatusContext, config: SegmentConfig): Pieces | undefined } diff --git a/packages/status/src/server.ts b/packages/status/src/server.ts new file mode 100644 index 00000000..988eb90e --- /dev/null +++ b/packages/status/src/server.ts @@ -0,0 +1,60 @@ +/** + * Status's agent side: no tools of its own, only the `status-setup` skill and its commands. + * + * `/status-setup` is shipped from here rather than registered by the interface so OpenCode runs it as + * it runs its own commands: from home it opens a conversation, while the agent answers it waits its + * turn — on both versions, with nothing of ours in between (docs/opencode/shipping-agents.md). The + * line it sends names the skill; the skill calls `cockpit_settings` for what the files say now. + * + * Published entry point: `@opencode-cockpit/status/server`. + */ + +import { existsSync } from "node:fs" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { offerSettingsCheck } from "@opencode-cockpit/client/checks" +import { claimFeature } from "@opencode-cockpit/client/feature" +import { dualServer, type ServerStart } from "@opencode-cockpit/client/server" +import { offerPreview } from "@opencode-cockpit/client/setup" +import { statusNotices } from "./core/config.ts" +import { OLD_PROMPT, OLD_SLASH, SETUP_PROMPT, SETUP_SKILL_DIR, SETUP_SLASH } from "./core/setup.ts" + +const STATUS_PACKAGE = "@opencode-cockpit/status" + +/** The agent half as a factory, so the `opencode-cockpit` bundle can include it. */ +export function createStatusServer({ source = STATUS_PACKAGE }: { source?: string } = {}): ServerStart { + return async (host) => { + /** The bundle and this package side by side register the skill once, as the bays do. */ + const claim = claimFeature(host.scope, "status", source) + if (!claim.active) return {} + /** This copy's preview, by path: `cockpit_settings` hands it to the skill instead of `bunx`. */ + const here = dirname(fileURLToPath(import.meta.url)) + const preview = ["preview.js", "preview.ts"].map((file) => join(here, "cli", file)).find(existsSync) + if (preview) offerPreview("status", `bun ${JSON.stringify(preview)}`) + /** What Status's line warns about, so `cockpit_settings` says it too: presets, surfaces, overrides. */ + offerSettingsCheck("status", statusNotices) + return { + skills: [{ dir: SETUP_SKILL_DIR }], + commands: [ + { + name: SETUP_SLASH, + description: "design the Status bay's line with the agent: what it shows, where", + prompt: SETUP_PROMPT, + }, + /** + * The old name, for one release (removed in 0.10): a command of its own, because neither + * OpenCode tells a command which of its names was typed, so only its own line can say it was + * renamed — and it says so first, in the message the person sees in the conversation. + */ + { + name: OLD_SLASH, + description: `renamed: use /${SETUP_SLASH}`, + prompt: OLD_PROMPT, + }, + ], + dispose: () => claim.release(), + } + } +} + +export default dualServer("opencode-cockpit.status", createStatusServer()) diff --git a/packages/status/src/tui/components/statusline.tsx b/packages/status/src/tui/components/statusline.tsx index a6bb7e5b..95a1b624 100644 --- a/packages/status/src/tui/components/statusline.tsx +++ b/packages/status/src/tui/components/statusline.tsx @@ -2,6 +2,7 @@ import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui" import type { Host } from "@opencode-cockpit/client/host" +import type { BoxRenderable } from "@opentui/core" import type { JSX } from "solid-js" import { For, Show } from "solid-js" import type { Run, Segment, Tone } from "../../core/segments.ts" @@ -68,6 +69,10 @@ function decorate(run: Run): JSX.Element { export interface StatusLineProps { api: Host segments: () => Segment[] + /** Rows above the line about the line itself — a setting to fix, a module that would not load. */ + notices?: () => Segment[] + /** The line's box, once laid out, so a column can measure the room the host gave it. */ + onReady?: (box: BoxRenderable) => void separator: string /** Across the window, or down a column. */ stack?: "horizontal" | "vertical" @@ -77,43 +82,52 @@ export interface StatusLineProps { paddingBottom?: number } +/** One segment's runs, as one row of styled text. */ +function Runs(props: { theme: TuiThemeCurrent; segment: Segment }) { + return ( + + + {(run) => ( + {decorate(run)}}> + + {run.text} + + + )} + + + ) +} + export function StatusLine(props: StatusLineProps) { const theme = () => props.api.theme.current const down = () => props.stack === "vertical" return ( props.onReady?.(box)} + flexDirection="column" flexShrink={0} paddingLeft={props.paddingLeft ?? 1} paddingRight={props.paddingRight ?? 1} paddingTop={props.paddingTop ?? 0} paddingBottom={props.paddingBottom ?? 0} > - - {(segment, index) => ( - <> - 0 && !down() && props.separator.length > 0}> - - {props.separator} - - - - - {(run) => ( - {decorate(run)}} - > - - {run.text} - - - )} - - - - )} - + {/* Their own rows, whichever way the line reads: a sentence does not fit between two segments. */} + {(notice) => } + + + {(segment, index) => ( + <> + 0 && !down() && props.separator.length > 0}> + + {props.separator} + + + + + )} + + ) } diff --git a/packages/status/src/tui/index.tsx b/packages/status/src/tui/index.tsx index a48a71c5..b008dc2c 100644 --- a/packages/status/src/tui/index.tsx +++ b/packages/status/src/tui/index.tsx @@ -1,19 +1,19 @@ /** @jsxImportSource @opentui/solid */ +import { briefAgent } from "@opencode-cockpit/client/brief" import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client/feature" import { dualTui, type Host } from "@opencode-cockpit/client/host" -import { sidebarOrder } from "@opencode-cockpit/client/sidebar" +import type { BoxRenderable } from "@opentui/core" import { createMemo } from "solid-js" import pkg from "../../package.json" with { type: "json" } -import { asSegmentConfig, loadStatusConfig, type ResolvedLine, resolveLines } from "../core/config.ts" +import { asSegmentConfig, loadStatus, type ResolvedLine, resolveLines } from "../core/config.ts" import { loadCustomSegments } from "../core/custom.ts" -import { statuslineBrief } from "../core/instructions.ts" -import { moduleNotice, overflowNotice } from "../core/notices.ts" +import { moduleNoticeText, noticeRows, overflowNotice } from "../core/notices.ts" import { fit, fitColumn } from "../core/render.ts" -import { buildReport } from "../core/report.ts" import { buildSegments, type SegmentDef } from "../core/segments.ts" +import { SETUP_PROMPT } from "../core/setup.ts" import { StatusLine } from "./components/statusline.tsx" -import { buildContext, currentSession } from "./state/snapshot.ts" +import { buildContext } from "./state/snapshot.ts" import { createStatusStore } from "./state/store.ts" const STATUS_PACKAGE = "@opencode-cockpit/status" @@ -27,7 +27,7 @@ export function createStatusTui({ source = STATUS_PACKAGE }: { source?: string } api.ui.toast({ variant: "warning", title: "opencode-cockpit", - message: duplicateFeatureMessage("Statusline", claim.owner, source), + message: duplicateFeatureMessage("Status", claim.owner, source), duration: 10_000, }) return @@ -35,7 +35,8 @@ export function createStatusTui({ source = STATUS_PACKAGE }: { source?: string } api.lifecycle.onDispose(() => claim.release()) const directory = api.state.path.directory - const config = loadStatusConfig(directory, rawOptions) + const { config, order, notices } = loadStatus({ options: rawOptions, where: { directory } }) + for (const notice of notices) api.log.warn("status: settings", { notice }) if (config.enabled === false) return // Your own segments, loaded before the first draw so they are never missing from frame one. @@ -54,12 +55,12 @@ export function createStatusTui({ source = STATUS_PACKAGE }: { source?: string } moduleErrors.push(...loaded.errors) for (const error of loaded.errors) { api.log.error("status: module failed to load", { error }) - api.ui.toast({ variant: "error", title: "Statusline", message: error, duration: 10_000 }) + api.ui.toast({ variant: "error", title: "Status", message: error, duration: 10_000 }) void api.v1?.client.app .log({ service: "opencode-cockpit.status", level: "error", - message: `statusline module failed to load: ${error}`, + message: `status module failed to load: ${error}`, }) .catch(() => {}) } @@ -70,25 +71,49 @@ export function createStatusTui({ source = STATUS_PACKAGE }: { source?: string } api.lifecycle.onDispose(() => store.dispose()) /** - * The line's own failures, drawn once rather than on every surface. A module that would not - * load has no segments to be missing from, so without a row of its own the only notice is a - * toast that is gone in ten seconds — and the log, which nobody reads while looking at a line - * that seems to have quietly done nothing. + * The line's own trouble — a setting no longer read, a file that would not parse, a module that + * would not load — drawn once, as `!` rows above the first line, and for the whole session: the + * toast is gone in ten seconds and the log is not where anyone looks at a line that seems to have + * quietly done nothing. Status draws the notices that belong to no bay, too, being the one bay + * every install has. */ - const failure = moduleNotice(moduleErrors) - let noticeShown = false + const troubles = [...notices, ...moduleErrors.map(moduleNoticeText)] - /** A line, fitted to the room its surface actually has. */ - const line = (spec: ResolvedLine, width: () => number) => { - const mine = failure && !noticeShown - if (mine) noticeShown = true + const on = (surface: string) => lines.filter((spec) => spec.surface === surface) + const bottom = on("bottom") + const sidebar = on("sidebar") + /** The sidebar's when there is one: it is where the default draws, and where a notice has room. */ + const noticeLine = sidebar[0] ?? bottom[0] + + /** + * A line, fitted to the room its surface actually has. A sidebar line measures it, as Subagents + * and Trust do: the container the host gave it, once laid out, and a guess before that. Guessed, + * a notice padded to a quarter of the window ran past the sidebar's edge and lost its last words. + */ + const line = (spec: ResolvedLine, guess: () => number, measure = false) => { + let box: BoxRenderable | undefined + const width = () => { + if (!measure) return guess() + /** + * The parent, not the line's own box: the box is as wide as its widest row, so a row that + * gave up a word for want of room would keep the box — and itself — that narrow for good. + */ + const parent = (box?.parent as { width?: number } | null | undefined)?.width ?? 0 + return parent >= 12 ? parent : guess() + } + /** Read on the tick too, so a width the host settles after the first frame is picked up. */ + const said = createMemo(() => { + store.context() + return spec === noticeLine ? noticeRows(troubles, width()) : [] + }) const segments = createMemo(() => { - const built = buildSegments(store.context(), spec.segments.map(asSegmentConfig), { + /** The room this line has, rather than the window's: a row with a word to spare uses it. */ + const ctx = { ...store.context(), width: width() } + const built = buildSegments(ctx, spec.segments.map(asSegmentConfig), { custom, icons: spec.icons, debug: spec.debug, }) - if (mine && failure) built.unshift(failure) if (spec.stack !== "vertical") return fit(built, width(), spec.separator).segments /** * Say how many rows did not fit. The preview has always printed `↳ N dropped`; the TUI @@ -105,6 +130,10 @@ export function createStatusTui({ source = STATUS_PACKAGE }: { source?: string } { + box = ready + }} separator={spec.separator} stack={spec.stack} paddingLeft={spec.paddingLeft} @@ -116,69 +145,23 @@ export function createStatusTui({ source = STATUS_PACKAGE }: { source?: string } } /** - * `/statusline` draws nothing. Customising a line is an editing job in a file the TUI never - * names, so the useful thing is not a help panel the user then has to act on themselves — it is - * a brief handed to the agent already in the session, carrying what it cannot look up: which - * config file this project reads, what is in it now, and what would not load. + * The palette's way to the `status-setup` skill. `/status-setup` itself is a command the agent + * side ships (`../server.ts`), which OpenCode runs like its own — but neither OpenCode lists such a + * command in the palette, so this entry sends the same line. No slash name: that one is taken by + * the shipped command, and two rows doing one thing would be one too many. */ api.keymap.registerLayer({ commands: [ { - name: "cockpit.status.customise", - title: "Ask the agent to customise the statusline", + name: "cockpit.status.setup", + title: "Ask the agent to set up the status bay", category: "Cockpit · Status", namespace: "palette", - slashName: "statusline", - run: () => { - const brief = statuslineBrief( - buildReport({ - version: pkg.version, - directory, - lines, - modules: config.modules, - registered: custom.size, - errors: moduleErrors, - }), - ) - /** - * Sent, not left in the prompt. The brief is forty lines; parked in the input it is a - * wall of text the user has to scroll past to type their own sentence, and it ends by - * asking what they want anyway — so the agent is the right place for it to land. - * - * On the next tick, because running a slash command clears the prompt it was typed - * into: writing during the command itself is wiped a moment later, which looks exactly - * like a command that did nothing. - */ - setTimeout(() => { - const failed = () => { - api.log.warn("status: could not reach the prompt") - api.ui.toast({ variant: "error", title: "Statusline", message: "could not reach the prompt" }) - } - if (api.v1) { - const tui = api.v1.client.tui - void tui - .appendPrompt({ text: brief }) - .then(() => tui.submitPrompt()) - .catch(failed) - return - } - /** OpenCode 2: straight to the conversation on screen, there being no prompt to fill. */ - const session = currentSession(api) - if (!session) { - api.ui.toast({ title: "Statusline", message: "Open a conversation first." }) - return - } - void api.promptSession(session, brief).catch(failed) - }, 0) - }, + run: () => briefAgent(api, SETUP_PROMPT, "Status"), }, ], }) - const on = (surface: string) => lines.filter((spec) => spec.surface === surface) - const bottom = on("bottom") - const sidebar = on("sidebar") - api.slots.register({ /** After the shell dock (150), so the line sits at the very bottom of the window. */ order: 200, @@ -193,15 +176,16 @@ export function createStatusTui({ source = STATUS_PACKAGE }: { source?: string } }, }) api.slots.register({ - /** - * First in the sidebar by default — statusline, subagents, shells. `"sidebar"` in Cockpit's - * config, or `sidebarOrder`, moves it; lower draws first. - */ - order: sidebarOrder("status", 140, config.sidebarOrder, { directory: api.state.path.directory }), + /** First of Cockpit's blocks by default; the top-level `sidebar` list moves it. */ + order, slots: { // sidebar_content, not sidebar_footer: the host does not draw plugin content in the footer. sidebar_content: () => ( - <>{sidebar.map((spec) => line(spec, () => Math.max(10, Math.floor(api.renderer.width / 4))))} + <> + {sidebar.map((spec) => + line(spec, () => Math.max(20, Math.min(40, Math.floor(api.renderer.width / 4) - 2)), true), + )} + ), }, }) diff --git a/packages/status/src/tui/state/snapshot.ts b/packages/status/src/tui/state/snapshot.ts index e5949e5d..4f1584a4 100644 --- a/packages/status/src/tui/state/snapshot.ts +++ b/packages/status/src/tui/state/snapshot.ts @@ -1,10 +1,12 @@ import { homedir } from "node:os" import type { TuiPluginApi } from "@opencode-ai/plugin/tui" import type { Host, V2Context } from "@opencode-cockpit/client/host" +import type { Budget } from "../../core/budget.ts" import { lastTurn, type SessionSnapshot, type StatusContext, + serviceStatus, type TimedMessage, type TokenCounts, type Turn, @@ -217,6 +219,8 @@ export function buildContext( version: string commands: Record diff?: DiffCounts + branchDiff?: DiffCounts + budget?: Budget }, ): StatusContext { const sessionID = currentSession(api) @@ -229,6 +233,8 @@ export function buildContext( ...(api.state.vcs?.default_branch ? { defaultBranch: api.state.vcs.default_branch } : {}), version: options.version, ...(options.diff ? { diff: options.diff } : {}), + ...(options.branchDiff ? { branchDiff: options.branchDiff } : {}), + ...(options.budget ? { budget: options.budget } : {}), ...(sessionID ? { session: api.v1 @@ -236,16 +242,21 @@ export function buildContext( : sessionSnapshotV2(api.v2 as V2Context, sessionID, options.now, options.diff ?? NOTHING), } : {}), - /** v2 runs no language servers; its MCP servers carry a name and a status as v1's did. */ - lsp: api.v1 ? api.v1.state.lsp().map((item) => ({ name: item.id, status: String(item.status) })) : [], + /** + * v2 runs no language servers. Its MCP servers carry a name and a status — but the status is a + * tagged object, not v1's word, so both go through `serviceStatus` (#34). + */ + lsp: api.v1 + ? api.v1.state.lsp().map((item) => ({ name: item.id, status: serviceStatus(item.status) })) + : [], mcp: api.v1 - ? api.v1.state.mcp().map((item) => ({ name: item.name, status: String(item.status) })) + ? api.v1.state.mcp().map((item) => ({ name: item.name, status: serviceStatus(item.status) })) : ( (api.v2?.data.location.mcp?.server.list(api.v2.location) ?? []) as { name?: string status?: unknown }[] - ).map((item) => ({ name: item.name ?? "", status: String(item.status ?? "") })), + ).map((item) => ({ name: item.name ?? "", status: serviceStatus(item.status) })), commands: options.commands, width: options.width, } diff --git a/packages/status/src/tui/state/store.ts b/packages/status/src/tui/state/store.ts index e2cef6b6..8111b607 100644 --- a/packages/status/src/tui/state/store.ts +++ b/packages/status/src/tui/state/store.ts @@ -1,9 +1,17 @@ import type { Host } from "@opencode-cockpit/client/host" import { type Accessor, createMemo, createRoot, createSignal } from "solid-js" +import { BUDGET_EVERY_MS, type Budget, budgetFile, readBudget } from "../../core/budget.ts" import { type CommandRunner, createRunner, execShell } from "../../core/command.ts" import { resolveLines, type StatusConfig } from "../../core/config.ts" import type { StatusContext } from "../../core/context.ts" -import { type DiffCounts, parseShortstat, UNCOMMITTED, wantsDiff } from "../../core/diff.ts" +import { + branchDiffCommand, + type DiffCounts, + parseShortstat, + UNCOMMITTED, + wantsBranchDiff, + wantsDiff, +} from "../../core/diff.ts" /** * Keeps one snapshot of OpenCode's state for every line to read. One memo rather than one per @@ -24,6 +32,8 @@ export interface StoreOptions { diffIntervalMs?: number /** Injected in tests, so no shell runs and no clock is needed. */ exec?: (command: string, stdin: string, timeoutMs: number) => Promise + /** Injected in tests, so no file is read. */ + readBudget?: (path: string) => Budget | undefined now?: () => number build: ( api: Host, @@ -33,6 +43,8 @@ export interface StoreOptions { version: string commands: Record diff: DiffCounts | undefined + branchDiff: DiffCounts | undefined + budget: Budget | undefined }, ) => StatusContext } @@ -65,7 +77,8 @@ export function createStatusStore(api: Host, config: StatusConfig, options: Stor * they were built for. `claudeCodeCompat` is off because nothing is being handed a session on * stdin — this is one command with one answer. */ - const diffRunner = wantsDiff(resolveLines(config)) + const lines = resolveLines(config) + const diffRunner = wantsDiff(lines) ? createRunner( { run: UNCOMMITTED, @@ -77,20 +90,51 @@ export function createStatusStore(api: Host, config: StatusConfig, options: Stor ) : undefined + /** + * The branch's whole diff, for `git`. The command names the default branch, which OpenCode may + * only learn after the first frame, so the runner is made once it is known — and made again if + * it changes. Until then `main`, the likeliest answer. + */ + const branchWanted = wantsBranchDiff(lines) + let branch: { base: string; runner: CommandRunner } | undefined + const branchRunner = (base: string) => { + if (branch?.base === base) return branch.runner + branch?.runner.dispose() + const runner = createRunner( + { run: branchDiffCommand(base), intervalMs: 10_000, timeoutMs: 3000, claudeCodeCompat: false }, + { exec: options.exec ?? execShell, now: clock, onValue: () => bump((n) => n + 1) }, + ) + branch = { base, runner } + return runner + } + + /** A proxy's budget, for `spend` and `avail`: a tiny file, read again every few seconds. */ + const budgetPath = budgetFile(lines) + let budget: Budget | undefined + let budgetAt = Number.NEGATIVE_INFINITY + const context = createMemo(() => { commandTick() + const at = now() const commands: Record = {} for (const [name, runner] of runners) commands[name] = runner.value() + if (budgetPath && at - budgetAt >= BUDGET_EVERY_MS) { + budgetAt = at + budget = (options.readBudget ?? readBudget)(budgetPath) + } const ctx = options.build(api, { - now: now(), + now: at, width: api.renderer.width, version: options.version, commands, diff: diffRunner ? parseShortstat(diffRunner.value()) : undefined, + branchDiff: branch ? parseShortstat(branch.runner.value()) : undefined, + budget, }) // Asking here keeps the schedule tied to what is actually drawn: a hidden line costs nothing. for (const runner of runners.values()) runner.maybeRun(ctx) diffRunner?.maybeRun(ctx) + if (branchWanted) branchRunner(ctx.defaultBranch ?? "main").maybeRun(ctx) return ctx }) @@ -99,6 +143,7 @@ export function createStatusStore(api: Host, config: StatusConfig, options: Stor dispose() { clearInterval(tick) for (const runner of runners.values()) runner.dispose() + branch?.runner.dispose() dispose() }, } diff --git a/packages/status/test/checks.test.ts b/packages/status/test/checks.test.ts new file mode 100644 index 00000000..83a9b004 --- /dev/null +++ b/packages/status/test/checks.test.ts @@ -0,0 +1,69 @@ +import { afterEach, expect, test } from "bun:test" +import { offerSettingsCheck } from "@opencode-cockpit/client/checks" +import { loadSettings } from "@opencode-cockpit/client/settings" +import { settingsReport, settingsText } from "@opencode-cockpit/client/setup" +import { loadStatus, statusNotices } from "../src/core/config.ts" + +/** + * Status offers its own check to `cockpit_settings` (and doctor), so what its line warns about is + * listed there too — the audit's "Notices: none" while the sidebar still said `override "gti"`. + */ + +const GLOBAL = "/home/me/.config/opencode-cockpit/config.json" +const PROJECT = "/work/app/.cockpit.json" +const OPENCODE = "/home/me/.config/opencode/opencode.json" +const CHECKS = Symbol.for("opencode-cockpit.settings-checks") + +afterEach(() => { + ;(globalThis as { [CHECKS]?: Map })[CHECKS]?.clear() +}) + +const reader = (files: Record) => (path: string) => { + const all: Record = { [OPENCODE]: { plugins: ["opencode-cockpit@0.9.0"] }, ...files } + return all[path] === undefined ? undefined : JSON.stringify(all[path]) +} +const where = (files: Record) => ({ + directory: "/work/app", + env: {}, + home: "/home/me", + read: reader(files), +}) + +test("cockpit_settings lists Status's override and Trust's threshold; fixed, it says none", () => { + offerSettingsCheck("status", statusNotices) + const report = (files: Record) => + settingsText(settingsReport({ opencode: 2, ...where(files) })) + + const broken = report({ [GLOBAL]: { status: { override: { gti: false } }, trust: { threshold: "3" } } }) + expect(broken.split("\n")[2]).toBe("## Fix these first (2)") + expect(broken).toContain( + `- ${GLOBAL}: override "gti" matches no segment in the sidebar preset — did you mean "git"?\n`, + ) + expect(broken).toContain(`- ${GLOBAL}: "trust.threshold" should be a number; the default is used.`) + + const fixed = report({ [GLOBAL]: { status: { override: { git: false } }, trust: { threshold: 3 } } }) + expect(fixed.split("\n")[2]).toBe("Notices: none. Every setting written is read.") +}) + +test("statusNotices says what the line draws, as notices naming the file the key is in", () => { + const files = { + [GLOBAL]: { status: { preset: "nope" } }, + [PROJECT]: { status: { override: { gti: false }, surface: "top" } }, + } + const settings = loadSettings(where(files)) + const notices = statusNotices({ settings }) + expect(notices.map((notice) => [notice.file, notice.text])).toEqual([ + [GLOBAL, expect.stringContaining('no preset "nope"')], + [PROJECT, '"status.surface" is "sidebar" or "bottom"'], + [PROJECT, expect.stringContaining('override "gti" matches no segment')], + ]) + /** The same set as the `!` rows the bay draws, word for word. */ + const drawn = loadStatus({ where: where(files) }).notices + expect(notices.map((notice) => `settings: ${notice.text}`)).toEqual(drawn) +}) + +test("plugin options are the file for a key written there", () => { + const settings = loadSettings(where({})) + const notices = statusNotices({ settings, options: { preset: "nope" } }) + expect(notices.map((notice) => notice.file)).toEqual(["plugin options"]) +}) diff --git a/packages/status/test/config.test.ts b/packages/status/test/config.test.ts index 84479312..68b8ccf1 100644 --- a/packages/status/test/config.test.ts +++ b/packages/status/test/config.test.ts @@ -1,18 +1,15 @@ -import { afterEach, describe, expect, test } from "bun:test" -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" -import { join } from "node:path" +import { describe, expect, test } from "bun:test" import { asSegmentConfig, - asStatusConfig, + configNotices, DEFAULT_SEGMENTS, DEFAULT_SEPARATOR, - globalConfigPath, - loadStatusConfig, - mergeStatus, + type LineConfig, + loadStatus, PRESETS, - PROJECT_FILE, - readStatusFile, resolveLines, + SIDEBAR_SEGMENTS, + type StatusConfig, } from "../src/core/config.ts" import { FIXTURES } from "../src/core/fixtures.ts" import { fitColumn } from "../src/core/render.ts" @@ -20,88 +17,193 @@ import { buildSegments, findSegment } from "../src/core/segments.ts" const rowText = (runs: readonly { text: string }[]) => runs.map((run) => run.text).join("") -const dirs: string[] = [] -const tmp = () => { - const dir = mkdtempSync("/tmp/ck-status-") - dirs.push(dir) - return dir -} -afterEach(() => { - for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) -}) +/** Both files in memory, through the loader every bay reads with. */ +const GLOBAL = "/cfg/opencode-cockpit/config.json" +const PROJECT = "/w/app/.cockpit.json" +const load = (files: Record, options?: unknown) => + loadStatus({ + options, + where: { + directory: "/w/app", + env: { XDG_CONFIG_HOME: "/cfg" }, + read: (path) => { + const file = files[path] + return file === undefined ? undefined : typeof file === "string" ? file : JSON.stringify(file) + }, + }, + }) describe("reading the config", () => { - test("a missing file is simply no settings", () => { - expect(readStatusFile("/nowhere/at/all.json")).toEqual({}) + test("no file at all is no settings, and nothing to fix", () => { + const loaded = load({}) + expect(loaded.config).toEqual({}) + expect(loaded.notices).toEqual([]) }) - // A typo in a config should cost you your settings, never the interface. - test("a broken file is ignored rather than fatal", () => { - const dir = tmp() - const file = join(dir, "broken.json") - writeFileSync(file, "{ not json") - expect(readStatusFile(file)).toEqual({}) + test("reads the `status` section, comments and trailing commas included", () => { + const loaded = load({ [GLOBAL]: '{\n // mine\n "status": { "separator": " | ", },\n}' }) + expect(loaded.config.separator).toBe(" | ") + expect(loaded.notices).toEqual([]) }) - test("reads the statusline section out of a whole cockpit config", () => { - const dir = tmp() - const file = join(dir, PROJECT_FILE) - writeFileSync(file, JSON.stringify({ watch: { auto: true }, statusline: { separator: " | " } })) - expect(readStatusFile(file)).toEqual({ separator: " | " }) + /** A typo in a config should cost you your settings, never the interface — and it says so. */ + test("a broken file is ignored, with a `!` row that says the file could not be read", () => { + const loaded = load({ [GLOBAL]: "{ not json" }) + expect(loaded.config).toEqual({}) + expect(loaded.notices).toHaveLength(1) + expect(loaded.notices[0]).toMatch(/^settings: .*the whole file is ignored$/) }) - test("plugin-entry options are accepted as the section itself", () => { - expect(asStatusConfig({ separator: " | ", segments: ["cwd"] })).toEqual({ + test("plugin-entry options are accepted as the section itself, or as a whole config", () => { + expect(load({}, { separator: " | ", segments: ["cwd"] }).config).toMatchObject({ separator: " | ", segments: ["cwd"], }) + expect(load({}, { status: { separator: " / " } }).config.separator).toBe(" / ") }) - test("anything that is not an object is no settings", () => { - expect(asStatusConfig(undefined)).toEqual({}) - expect(asStatusConfig("nonsense")).toEqual({}) - expect(asStatusConfig(null)).toEqual({}) + test("a value of the wrong kind is dropped, with a row naming the key", () => { + const loaded = load({ [GLOBAL]: { status: { icons: "no", separator: " | " } } }) + expect(loaded.config).toEqual({ separator: " | " }) + expect(loaded.notices).toEqual(['settings: "status.icons" should be a boolean; the default is used']) }) - test("the global path follows XDG when it is set", () => { - expect(globalConfigPath({ XDG_CONFIG_HOME: "/cfg" })).toBe("/cfg/opencode-cockpit/config.json") - expect(globalConfigPath({ HOME: "/home/u" })).toBe("/home/u/.config/opencode-cockpit/config.json") + test("off by `enabled`, or by the bundle's `features.status`", () => { + expect(load({ [GLOBAL]: { status: { enabled: false } } }).config.enabled).toBe(false) + expect(load({ [GLOBAL]: { features: { status: false } } }).config.enabled).toBe(false) }) }) -describe("precedence", () => { - test("the project file beats the global one, and plugin options beat both", () => { - const config = tmp() - const project = tmp() - const env = { XDG_CONFIG_HOME: config } - mkdirSync(join(config, "opencode-cockpit"), { recursive: true }) - writeFileSync( - globalConfigPath(env), - JSON.stringify({ statusline: { separator: " ~ ", segments: ["version"] } }), +/** + * 0.9 renamed the section and stopped reading the file's root as Status's. Neither is read, and + * neither is silent: each is a `!` row, which is how a 0.8 config learns what changed. + */ +describe("old names", () => { + test("`statusline` is not read: it is a notice that names the fix", () => { + const loaded = load({ [GLOBAL]: { statusline: { separator: " | " } } }) + expect(loaded.config.separator).toBeUndefined() + expect(loaded.notices).toEqual(['settings: "statusline" is no longer read — run /cockpit-setup']) + }) + + test("keys at the file's root are not Status's, and say where they belong", () => { + const loaded = load({ [GLOBAL]: { enabled: false, debug: true } }) + expect(loaded.config.enabled).toBeUndefined() + expect(loaded.notices).toContain( + 'settings: "enabled" at the top level is not read: it belongs in "status"', ) + }) - // Global alone. - expect(loadStatusConfig(project, undefined, env).separator).toBe(" ~ ") + test("a bay-level `maxRows` is `sidebarRows` now; inside `lines` it is still `maxRows`", () => { + const loaded = load({ [GLOBAL]: { status: { maxRows: 4, lines: [{ surface: "sidebar", maxRows: 3 }] } } }) + expect(loaded.notices).toEqual(['settings: "status.maxRows" is no longer read — run /cockpit-setup']) + expect(resolveLines(loaded.config)[0]?.maxRows).toBe(3) + }) - // The project overrides what it names and inherits what it does not. - writeFileSync(join(project, PROJECT_FILE), JSON.stringify({ statusline: { separator: " | " } })) - const merged = loadStatusConfig(project, undefined, env) - expect(merged.separator).toBe(" | ") - expect(merged.segments).toEqual(["version"]) + test("`sidebarOrder` is the top-level `sidebar` list now", () => { + const loaded = load({ [GLOBAL]: { status: { sidebarOrder: 120 } } }) + expect(loaded.notices).toEqual(['settings: "status.sidebarOrder" is no longer read — run /cockpit-setup']) + }) +}) + +/** Status is the one bay every install draws, so the notices that belong to no bay are its to draw. */ +describe("notices that belong to no bay", () => { + test("a top-level name nothing reads is drawn here", () => { + expect(load({ [GLOBAL]: { wobble: 1 } }).notices).toEqual(['settings: "wobble" is not a setting']) + }) + + test("as is a `sidebar` entry that is not a bay", () => { + const loaded = load({ [GLOBAL]: { sidebar: ["status", "panels"] } }) + expect(loaded.notices).toHaveLength(1) + expect(loaded.notices[0]).toContain('"panels" in "sidebar" is not a bay') + }) + test("another bay's own notice is that bay's to draw, not this one's", () => { + expect(load({ [GLOBAL]: { trust: { sidebarOrder: 1 } } }).notices).toEqual([]) + }) +}) + +describe("precedence", () => { + test("the project file beats the global one, and plugin options beat both", () => { + const files = { + [GLOBAL]: { status: { separator: " ~ ", segments: ["version"] } }, + [PROJECT]: { status: { separator: " | " } }, + } + expect(load({ [GLOBAL]: files[GLOBAL] }).config.separator).toBe(" ~ ") + // The project overrides what it names and inherits what it does not. + expect(load(files).config).toMatchObject({ separator: " | ", segments: ["version"] }) // The plugin entry wins over both. - expect(loadStatusConfig(project, { separator: " / " }, env).separator).toBe(" / ") + expect(load(files, { separator: " / " }).config.separator).toBe(" / ") }) // Listing segments in a project means "this line", not "these as well as the global ones". test("segments are replaced, not concatenated", () => { - const merged = mergeStatus({ segments: ["cwd", "cost"] }, { segments: ["model"] }) - expect(merged.segments).toEqual(["model"]) + const loaded = load({ + [GLOBAL]: { status: { segments: ["cwd", "cost"] } }, + [PROJECT]: { status: { segments: ["model"] } }, + }) + expect(loaded.config.segments).toEqual(["model"]) }) test("commands from both sources are kept", () => { - const merged = mergeStatus({ commands: { budget: { run: "a" } } }, { commands: { pods: { run: "b" } } }) - expect(Object.keys(merged.commands ?? {}).sort()).toEqual(["budget", "pods"]) + const loaded = load({ + [GLOBAL]: { status: { commands: { budget: { run: "a" } } } }, + [PROJECT]: { status: { commands: { pods: { run: "b" } } } }, + }) + expect(Object.keys(loaded.config.commands ?? {}).sort()).toEqual(["budget", "pods"]) + }) + + test("modules from every source add up rather than replacing each other", () => { + const loaded = load( + { [GLOBAL]: { status: { modules: ["~/a.ts"] } }, [PROJECT]: { status: { modules: ["./b.ts"] } } }, + { modules: ["./c.ts"] }, + ) + expect(loaded.config.modules).toEqual(["~/a.ts", "./b.ts", "./c.ts"]) + }) +}) + +describe("where it draws", () => { + test("the sidebar, when nothing says otherwise", () => { + expect(resolveLines(load({}).config)[0]?.surface).toBe("sidebar") + }) + + /** The switch every bay shares, in Status's words: off the sidebar means at the bottom. */ + test("`sidebar: false` means the bottom, with the bottom's own line", () => { + const [line] = resolveLines(load({ [GLOBAL]: { status: { sidebar: false } } }).config) + expect(line?.surface).toBe("bottom") + expect(line?.segments).toEqual(DEFAULT_SEGMENTS) + }) + + test("`surface` says it in Status's words, and wins", () => { + expect(load({ [GLOBAL]: { status: { sidebar: false, surface: "sidebar" } } }).config.surface).toBe( + "sidebar", + ) + }) + + test("`sidebarRows` caps the column", () => { + const loaded = load({ [GLOBAL]: { status: { sidebarRows: 5 } } }) + expect(resolveLines(loaded.config)[0]?.maxRows).toBe(5) + }) + + test("the block's place comes from the top-level `sidebar` list", () => { + expect(load({}).order).toBeLessThan(load({ [GLOBAL]: { sidebar: ["trust", "shell", "status"] } }).order) + }) +}) + +/** What only Status can tell is wrong: its own vocabulary. A preset nothing answers to used to fall back in silence. */ +describe("settings only Status can check", () => { + test("a preset nothing answers to is a row, and the line still draws", () => { + const loaded = load({ [GLOBAL]: { status: { preset: "sidebar-budget" } } }) + expect(loaded.notices).toEqual([ + 'settings: no preset "sidebar-budget" (minimal, default, detailed, sidebar)', + ]) + expect(resolveLines(loaded.config)[0]?.segments).toEqual(SIDEBAR_SEGMENTS) + }) + + test("so is one inside `lines`, and a surface that does not exist", () => { + expect(configNotices({ lines: [{ preset: "nope" }, { surface: "top" as never }] })).toEqual([ + 'settings: no preset "nope" (minimal, default, detailed, sidebar)', + 'settings: "status.lines[1].surface" is "sidebar" or "bottom"', + ]) }) }) @@ -119,9 +221,10 @@ describe("presets", () => { test("a preset brings its own surface", () => { expect(resolveLines({ preset: "sidebar" })[0]?.surface).toBe("sidebar") expect(resolveLines({ preset: "sidebar" })[0]?.stack).toBe("vertical") + expect(resolveLines({ preset: "default" })[0]?.surface).toBe("bottom") }) - /** Why a turn stalled is what OpenCode does not show; the sidebar preset dropped it entirely. */ + /** Why a turn stalled is what OpenCode does not show; the sidebar keeps it when rows run out. */ test("every preset says why a turn stalled, and the sidebar keeps it when rows run out", () => { for (const [name, preset] of Object.entries(PRESETS)) expect( @@ -141,9 +244,9 @@ describe("presets", () => { expect(line?.segments).toEqual(["cwd"]) }) - test("a name nothing answers to falls back rather than drawing nothing", () => { - const [line] = resolveLines({ preset: "nope" }) - expect(line?.segments).toEqual(DEFAULT_SEGMENTS) + test("a name nothing answers to falls back to the surface's own line rather than drawing nothing", () => { + expect(resolveLines({ preset: "nope" })[0]?.segments).toEqual(SIDEBAR_SEGMENTS) + expect(resolveLines({ preset: "nope", surface: "bottom" })[0]?.segments).toEqual(DEFAULT_SEGMENTS) }) test("every preset uses only built-ins, so none of them needs a module", () => { @@ -157,18 +260,19 @@ describe("presets", () => { }) describe("resolving lines", () => { - test("writing nothing gives the default line at the bottom", () => { + test("writing nothing gives the sidebar's table", () => { const [line] = resolveLines({}) - expect(line?.surface).toBe("bottom") + expect(line?.surface).toBe("sidebar") + expect(line?.segments).toEqual(SIDEBAR_SEGMENTS) + expect(line?.separator).toBe("") + }) + + test("the bottom with nothing else written gives the default line", () => { + const [line] = resolveLines({ surface: "bottom" }) expect(line?.segments).toEqual(DEFAULT_SEGMENTS) expect(line?.separator).toBe(DEFAULT_SEPARATOR) }) - /** - * OpenCode's own footer, sidebar and prompt already carry the path, branch, tokens, context - * percentage, spend and model. A default that repeated them would draw the same figure several - * times on one screen, which is exactly what it looked like before this rule. - */ /** * The rule is not to avoid every fact OpenCode mentions -- it is to avoid saying one no better * than the host does. A bar with the breakdown beside it is a different instrument from @@ -225,11 +329,6 @@ describe("resolving lines", () => { expect(side?.separator).toBe(DEFAULT_SEPARATOR) }) - test("modules from both sources add up rather than replacing each other", () => { - const merged = mergeStatus({ modules: ["~/a.ts"] }, { modules: ["./b.ts"] }) - expect(merged.modules).toEqual(["~/a.ts", "./b.ts"]) - }) - // Promised in the docs, so it has to actually reach the builder. test("icons are on by default and can be switched off globally or per line", () => { expect(resolveLines({})[0]?.icons).toBe(true) @@ -253,17 +352,22 @@ describe("resolving lines", () => { * Writing these at the top level is the natural guess, and having them quietly ignored costs * exactly the rows they were meant to keep. */ - test("maxRows and padding can be set once for every line", () => { - const [line] = resolveLines({ surface: "sidebar", maxRows: 20, paddingLeft: 4 }) + test("the row cap and padding can be set once for every line", () => { + const [line] = resolveLines({ surface: "sidebar", sidebarRows: 20, paddingLeft: 4 }) expect(line?.maxRows).toBe(20) expect(line?.paddingLeft).toBe(4) }) test("a line still overrides what the top level set", () => { - const [line] = resolveLines({ maxRows: 20, lines: [{ surface: "sidebar", maxRows: 3 }] }) + const [line] = resolveLines({ sidebarRows: 20, lines: [{ surface: "sidebar", maxRows: 3 }] }) expect(line?.maxRows).toBe(3) }) + test("the table's preset brings room for every row it has; another column keeps eight", () => { + expect(resolveLines({})[0]?.maxRows).toBe(PRESETS.sidebar?.maxRows) + expect(resolveLines({ surface: "sidebar", segments: ["cwd"], preset: "minimal" })[0]?.maxRows).toBe(8) + }) + test("a bare string is that built-in with no settings", () => { expect(asSegmentConfig("cwd")).toEqual({ type: "cwd" }) expect(asSegmentConfig({ type: "cwd", priority: 5 })).toEqual({ type: "cwd", priority: 5 }) @@ -271,16 +375,106 @@ describe("resolving lines", () => { }) /** - * Two bays share the sidebar and draw in registration order, which was a constant nobody could - * reach. A key the loader drops is a setting that silently does nothing. + * Changing one row of the table used to mean copying all fourteen into `segments`, which then + * stopped following the preset. `override` changes the rows it names and keeps the rest. */ -describe("where the line sits among other bays", () => { - test("sidebarOrder survives the loader, from a file and from plugin options", () => { - expect(asStatusConfig({ statusline: { sidebarOrder: 120 } }).sidebarOrder).toBe(120) - expect(asStatusConfig({ sidebarOrder: 120 }).sidebarOrder).toBe(120) +describe("override", () => { + const types = (config: StatusConfig, at = 0) => + resolveLines(config)[at]?.segments.map((entry) => asSegmentConfig(entry).type) + const settingsOf = (config: StatusConfig, type: string) => + resolveLines(config)[0] + ?.segments.map(asSegmentConfig) + .find((entry) => entry.type === type) + const sidebar = SIDEBAR_SEGMENTS.map((entry) => asSegmentConfig(entry).type) + + test("an object merges into that segment's settings, every other row kept", () => { + expect(types({ override: { git: { against: "branch" } } })).toEqual(sidebar) + expect(settingsOf({ override: { git: { against: "branch" } } }, "git")).toEqual({ + type: "git", + against: "branch", + }) + expect(settingsOf({ override: { "session.status": { working: true } } }, "session.status")).toEqual({ + type: "session.status", + priority: 95, + icon: "", + working: true, + }) + }) + + test("false drops it — every segment of that name", () => { + expect(types({ override: { write: false } })).toEqual(sidebar.filter((type) => type !== "write")) + expect(types({ override: { sep: false } })).not.toContain("sep") + }) + + test("a name swaps it, in the same place", () => { + const swapped = types({ override: { spend: "cost" } }) + expect(swapped?.indexOf("cost")).toBe(sidebar.indexOf("spend")) + expect(swapped).not.toContain("spend") + }) + + test("it applies to the preset a line names, and to `segments` when they are written", () => { + expect(types({ preset: "minimal", override: { diagnostics: false } })).toEqual([ + "context", + "git.diff", + "session.status", + ]) + expect(types({ segments: ["cwd", "model"], override: { model: false } })).toEqual(["cwd"]) + }) + + test("a line in `lines` takes its own, else the section's", () => { + const config: StatusConfig = { + override: { todo: false }, + lines: [ + { surface: "bottom", segments: ["cwd", "todo"] }, + { surface: "bottom", segments: ["cwd", "todo"], override: { cwd: false } }, + ], + } + expect(types(config, 0)).toEqual(["cwd"]) + expect(types(config, 1)).toEqual(["todo"]) + }) + + test("read from the files, a project's override adds to the global one", () => { + const loaded = load({ + [GLOBAL]: { status: { override: { git: { against: "branch" } } } }, + [PROJECT]: { status: { override: { write: false } } }, + }) + expect(loaded.notices).toEqual([]) + expect(types(loaded.config)).not.toContain("write") + expect(settingsOf(loaded.config, "git")?.against).toBe("branch") + }) + + test("a name that matches no segment is a `!` row naming the closest", () => { + expect(configNotices({ override: { gti: false } })).toEqual([ + 'settings: override "gti" matches no segment in the sidebar preset — did you mean "git"?', + ]) + expect(configNotices({ segments: ["cwd"], override: { model: false } })).toEqual([ + 'settings: override "model" matches no segment in "segments"', + ]) + expect(load({ [GLOBAL]: { status: { override: { gti: false } } } }).notices).toHaveLength(1) + }) + + test("a section-wide override is wrong only when it matches no line that uses it", () => { + const lines: LineConfig[] = [ + { surface: "bottom", segments: ["cwd"] }, + { surface: "bottom", segments: ["todo"] }, + ] + expect(configNotices({ override: { todo: false }, lines })).toEqual([]) + expect(configNotices({ lines: [{ segments: ["cwd"], override: { tood: false } }] })).toEqual([ + 'settings: status.lines[0].override "tood" matches no segment in "segments"', + ]) + }) + + test("a change that is not one is said, and leaves the segment as it was", () => { + const config = { override: { git: true } } as unknown as StatusConfig + expect(configNotices(config)).toEqual([ + 'settings: override "git" is false, a segment name, or an object of its settings', + ]) + expect(types(config)).toEqual(sidebar) }) - test("and is absent when nobody set it, so the default stands", () => { - expect(asStatusConfig({ surface: "sidebar" }).sidebarOrder).toBeUndefined() + test("not an object, it is dropped by the loader with a row naming the key", () => { + const loaded = load({ [GLOBAL]: { status: { override: ["git"] } } }) + expect(loaded.config.override).toBeUndefined() + expect(loaded.notices).toEqual(['settings: "status.override" should be an object; the default is used']) }) }) diff --git a/packages/status/test/custom.test.ts b/packages/status/test/custom.test.ts index 1fcf703b..fae52534 100644 --- a/packages/status/test/custom.test.ts +++ b/packages/status/test/custom.test.ts @@ -125,7 +125,20 @@ describe("loading your own segments", () => { }) expect(segments.size).toBe(0) expect(errors[0]).toContain("./nope.ts") - expect(errors[0]).toContain("Cannot find module") + // Nothing at that path: said in words, rather than as the resolver's stack of paths. + expect(errors[0]).toContain("no file there") + }) + + /** 0.9 folded the sidebar examples into the `sidebar` preset; a config naming one is told so. */ + test("a sidebar example 0.9 removed says what replaced it", async () => { + const { errors } = await loadCustomSegments( + ["~/node_modules/@opencode-cockpit/status/examples/sidebar-budget.ts"], + "/w/app", + async () => { + throw new Error("Cannot find module") + }, + ) + expect(errors[0]).toContain('use "preset": "sidebar"') }) test("an export that is not a function is reported, and its siblings still load", async () => { diff --git a/packages/status/test/examples.test.ts b/packages/status/test/examples.test.ts index 31d40425..20cebb83 100644 --- a/packages/status/test/examples.test.ts +++ b/packages/status/test/examples.test.ts @@ -9,7 +9,8 @@ import { buildSegments, type Segment, segmentText, segmentWidth } from "../src/c * run is worse than no example, and this is the only thing that catches one. */ -const EXAMPLES = ["bottom", "sidebar", "sidebar-full", "sidebar-budget"] as const +/** The sidebar ones became the `sidebar` preset in 0.9; its rows are built-ins, tested in table.test.ts. */ +const EXAMPLES = ["bottom"] as const const session = (over: Partial = {}): SessionSnapshot => ({ id: "ses_1", @@ -126,127 +127,3 @@ describe("the bottom example", () => { expect(segmentText(drawn as Segment)).toBe("â–Œ50% cached") }) }) - -/** - * This one replaces OpenCode's own Context block rather than sitting beside it, so it has to carry - * what that block carried — the percentage, the token total and the spend — or turning the host's - * block off leaves the user worse off than before. - */ -describe("the full sidebar example", () => { - const full = (over: Partial> = {}) => ({ ...working(0.4), ...over }) - - test("carries everything the host's Context block did", () => { - const ctxWith = ctx({ session: full() }) - expect(segmentText(draw("sidebar-full", "bar", ctxWith) as Segment)).toContain("40%") - expect(segmentText(draw("sidebar-full", "window", ctxWith) as Segment)).toContain("/") - expect(segmentText(draw("sidebar-full", "spend", ctxWith) as Segment)).toContain("$") - }) - - test("every row fits a sidebar column", () => { - const ctxWith = ctx({ session: full() }) - for (const type of loaded["sidebar-full"].segments.keys()) { - const drawn = draw("sidebar-full", type, ctxWith) - if (drawn) expect(segmentWidth(drawn)).toBeLessThanOrEqual(32) - } - }) - - test("without a declared window it reports the total rather than a share of nothing", () => { - const unmeasured = ctx({ - session: { - ...working(0.4), - model: { providerID: "p", modelID: "m" }, - }, - }) - expect(draw("sidebar-full", "bar", unmeasured)).toBeUndefined() - expect(segmentText(draw("sidebar-full", "window", unmeasured) as Segment)).toContain("tok") - }) - - test("spend stays silent on an unpriced model", () => { - const unpriced = ctx({ session: { ...working(0.4), priced: false } }) - expect(draw("sidebar-full", "spend", unpriced)).toBeUndefined() - }) - - test("tasks go quiet once the list is finished", () => { - const done = ctx({ session: { ...working(0.4), todo: { total: 5, completed: 5 } } }) - expect(draw("sidebar-full", "todo", done)).toBeUndefined() - }) -}) - -describe("the sidebar example", () => { - test("rows carry a dim label so a column of them reads as a table", () => { - const drawn = draw("sidebar", "changes", ctx({ session: working() })) - expect(drawn?.runs[0]).toMatchObject({ text: "diff ", dim: true }) - expect(segmentText(drawn as Segment)).toBe("diff 3f +120 -18") - }) - - test("the bar is exactly the width asked for, with its figure beside it", () => { - const drawn = buildSegments(ctx({ session: working(0.5) }), [{ type: "bar", width: 10 }], { - custom: loaded.sidebar.segments, - icons: false, - })[0] - expect(segmentText(drawn as Segment)).toMatch(/^[█░]{10} 50%$/) - }) - - test("the split parts always add up to the whole window", () => { - const drawn = draw("sidebar", "split", ctx({ session: working() })) - const shares = [...segmentText(drawn as Segment).matchAll(/(\d+)%/g)].map((m) => Number(m[1])) - expect(shares.reduce((a, b) => a + b, 0)).toBe(100) - }) -}) - -/** - * The table sidebar. Its whole point is that a column of rows lines up, so the label gutter is - * worth asserting: a row that pads to a different width reads as a typo from across the room. - */ -describe("the budget sidebar example", () => { - const withSession = () => ctx({ session: working(0.4) }) - - test("every labelled row starts with the same seven-column gutter, a space past the longest label", () => { - for (const type of ["tokens", "in", "out", "cache"]) { - const drawn = draw("sidebar-budget", type, withSession()) - expect(drawn?.runs[0]?.text).toHaveLength(7) - } - }) - - test("the token rows are shares of the window, and they add up to it", () => { - const shares = ["in", "out", "cache", "write"].map((type) => { - const drawn = draw("sidebar-budget", type, withSession()) - return drawn ? Number(/(\d+)%$/.exec(segmentText(drawn))?.[1] ?? 0) : 0 - }) - expect(shares.reduce((a, b) => a + b, 0)).toBe(100) - }) - - /** The figure lives on the tokens row below; printing it twice is what the bar is spared. */ - test("the bar is the full width of solid cells and nothing else", () => { - const text = segmentText(draw("sidebar-budget", "bar", withSession()) as Segment) - expect(text).toBe("â–ˆ".repeat(16)) - }) - - /** Without a proxy writing the file there is no budget, and a made-up one would be worse. */ - test("the budget rows stay silent when nothing reports a spend", () => { - expect(draw("sidebar-budget", "spend", withSession())).toBeUndefined() - expect(draw("sidebar-budget", "avail", withSession())).toBeUndefined() - }) - - test("demo fills them in, so the layout can be looked at before a proxy exists", () => { - const drawn = buildSegments(withSession(), [{ type: "avail", demo: true }], { - custom: loaded["sidebar-budget"].segments, - icons: false, - })[0] - expect(segmentText(drawn as Segment)).toBe("avail $173.76 · 87% left") - }) - - /** `write 0 · 0%` on a session with no cache writes said nothing, in a row of its own. */ - test("a row whose figure is zero is not drawn", () => { - expect(draw("sidebar-budget", "write", withSession())).toBeUndefined() - }) - - /** The word is the label: no coloured square beside it, and the figures are not categories. */ - test("colour is a level, never a label", () => { - for (const type of ["tokens", "in", "out", "cache"]) { - const drawn = draw("sidebar-budget", type, withSession()) - expect(segmentText(drawn as Segment)).not.toContain("â–ª") - for (const run of drawn?.runs ?? []) expect(["text", "muted"]).toContain(run.tone) - } - }) -}) diff --git a/packages/status/test/instructions.test.ts b/packages/status/test/instructions.test.ts deleted file mode 100644 index 636e592e..00000000 --- a/packages/status/test/instructions.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { resolveLines } from "../src/core/config.ts" -import { statuslineBrief, targetConfig } from "../src/core/instructions.ts" -import { buildReport } from "../src/core/report.ts" - -/** - * `/statusline` sends this to the agent instead of drawing a panel. It is worth testing as a string - * because the whole value of it is the part an agent cannot look up: which file to edit, and what - * is in it right now. - */ - -const base = (over: Record = {}) => - buildReport({ - version: "1.2.3", - directory: "/w/app", - lines: resolveLines({ preset: "default" }), - registered: 0, - errors: [], - env: { HOME: "/home/u", XDG_CONFIG_HOME: undefined }, - ...over, - }) - -describe("which file to edit", () => { - test("the project's when it exists, because that is the one this repo reads", () => { - const report = base() - report.sources[1] = { path: "/w/app/.cockpit.json", found: true } - expect(targetConfig(report)).toMatchObject({ path: "/w/app/.cockpit.json", exists: true }) - }) - - test("otherwise the global one, and the brief says it has to be created", () => { - expect(statuslineBrief(base())).toContain("does not exist yet") - }) -}) - -describe("what it tells the agent", () => { - test("names the surfaces drawing now, so it does not have to guess", () => { - expect(statuslineBrief(base())).toMatch(/Drawing now: bottom \(horizontal\), \d+ segments/) - }) - - test("carries the built-in names and the presets, which are otherwise only on a website", () => { - const brief = statuslineBrief(base()) - expect(brief).toContain("git.branch") - expect(brief).toContain('"preset": "minimal"') - }) - - test("a module that would not load is stated, not left to be rediscovered", () => { - const brief = statuslineBrief(base({ modules: ["./x.ts"], errors: ["./x.ts: no such file"] })) - expect(brief).toContain("./x.ts: no such file") - }) - - test("points at the skill and insists the result is looked at before it is called done", () => { - const brief = statuslineBrief(base()) - expect(brief).toContain("skills/statusline-design/SKILL.md") - expect(brief).toContain("preview --watch") - }) - - test("ends by asking what I want, so it does not redesign the line unprompted", () => { - expect(statuslineBrief(base()).trimEnd().endsWith("before you edit anything.")).toBe(true) - }) -}) diff --git a/packages/status/test/notices.test.ts b/packages/status/test/notices.test.ts index 07aabf1f..a54790c2 100644 --- a/packages/status/test/notices.test.ts +++ b/packages/status/test/notices.test.ts @@ -1,16 +1,17 @@ import { describe, expect, test } from "bun:test" -import { moduleNotice, overflowNotice } from "../src/core/notices.ts" +import { REMOVED_EXAMPLE } from "../src/core/custom.ts" +import { moduleNoticeText, noticeRows, overflowNotice } from "../src/core/notices.ts" import { type Segment, segmentText } from "../src/core/segments.ts" /** - * Both of these exist because a failure that draws nothing is indistinguishable from a segment that - * had nothing to say — the one place the bay's own silence rule is wrong. + * These exist because a failure that draws nothing is indistinguishable from a segment that had + * nothing to say — the one place the bay's own silence rule is wrong. */ describe("rows that did not fit", () => { - test("says how many, and what to raise", () => { + test("says how many, and what to raise — by the name every bay's block uses", () => { const notice = overflowNotice(6) - expect(segmentText(notice as Segment)).toBe("↳ 6 more — raise maxRows") + expect(segmentText(notice as Segment)).toBe("↳ 6 more — raise sidebarRows") expect(notice?.runs[0]).toMatchObject({ tone: "muted", dim: true }) }) @@ -26,26 +27,41 @@ describe("rows that did not fit", () => { }) describe("a module that would not load", () => { - test("names the one that failed, because the name is what you go and fix", () => { - const notice = moduleNotice(["./mine.ts: Cannot find module 'foo'"]) - expect(segmentText(notice as Segment)).toContain("./mine.ts") - expect(notice?.runs[0]?.tone).toBe("error") + test("names the file, not the path: the part a sidebar has room for and the part you fix", () => { + expect(moduleNoticeText("~/.config/opencode-cockpit/modules/mine.ts: Cannot find module 'foo'")).toBe( + "module mine.ts: Cannot find module 'foo'", + ) }) - test("a long message is cut to fit a sidebar column", () => { - const notice = moduleNotice([`./x.ts: ${"very ".repeat(40)}long`], 30) - expect(segmentText(notice as Segment).length).toBeLessThanOrEqual(32) + test("a sidebar example 0.9 removed says what replaced it, in a sentence that fits three rows", () => { + const text = moduleNoticeText(`~/x/examples/sidebar-budget.ts: ${REMOVED_EXAMPLE}`) + expect(text).toBe('sidebar-budget.ts was removed in 0.9: use "preset": "sidebar"') + expect(noticeRows([text], 24)).toHaveLength(3) + expect(noticeRows([text], 24).map((row) => segmentText(row).trimEnd())[2]).toBe(' "preset": "sidebar"') + }) +}) + +describe("the rows a notice draws", () => { + test("`!` in the warning tone, wrapped to the column, every row its full width", () => { + const rows = noticeRows(['settings: "statusline" is no longer read — run /cockpit-setup'], 24) + expect(rows.map((row) => segmentText(row).trimEnd())).toEqual([ + '! settings: "statusline"', + " is no longer read —", + " run /cockpit-setup", + ]) + expect(rows[0]?.runs[0]).toMatchObject({ text: "! ", tone: "warning" }) + for (const row of rows) expect(segmentText(row)).toHaveLength(24) }) - test("several are counted rather than listed, which would fill the line", () => { - expect(segmentText(moduleNotice(["a", "b", "c"]) as Segment)).toBe("! 3 modules failed to load") + test("a wide surface keeps a notice to one row", () => { + expect(noticeRows(['settings: "statusline" is no longer read — run /cockpit-setup'], 120)).toHaveLength(1) }) - test("it outranks every segment, so a broken line still reports why", () => { - expect(moduleNotice(["a"])?.priority).toBe(1000) + test("they outrank every segment, so a broken line still reports why", () => { + for (const row of noticeRows(["a", "b"], 30)) expect(row.priority).toBe(1000) }) - test("nothing failed, nothing drawn", () => { - expect(moduleNotice([])).toBeUndefined() + test("nothing to say, nothing drawn", () => { + expect(noticeRows([], 30)).toEqual([]) }) }) diff --git a/packages/status/test/preview.test.ts b/packages/status/test/preview.test.ts new file mode 100644 index 00000000..0e3e7481 --- /dev/null +++ b/packages/status/test/preview.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, test } from "bun:test" +import { asSegmentConfig, SIDEBAR_SEGMENTS } from "../src/core/config.ts" +import { FIXTURES } from "../src/core/fixtures.ts" +import { + drawState, + parseArgs, + plainRuns, + previewSettings, + roomFor, + SIDEBAR_WIDTH, +} from "../src/core/preview.ts" + +/** A `--config` file, read as the global one with nothing beside it: as the CLI reads it. */ +const preview = (file: unknown, surface?: "sidebar" | "bottom") => + previewSettings({ + directory: "/w/app", + env: { XDG_CONFIG_HOME: "/cfg" }, + configText: typeof file === "string" ? file : JSON.stringify(file), + ...(surface ? { surface } : {}), + }) + +const draw = ( + file: unknown, + options: { debug?: boolean; state?: keyof typeof FIXTURES; surface?: "sidebar" | "bottom" } = {}, +) => { + const { loaded, lines } = preview(file, options.surface) + return drawState({ + lines, + troubles: loaded.notices, + fixture: FIXTURES[options.state ?? "busy"], + terminal: 120, + debug: options.debug ?? false, + paint: plainRuns, + dim: (text) => text, + }) +} + +describe("the flags", () => { + test("`--name value` and `--name=value` alike", () => { + expect(parseArgs(["preview", "--config", "a.json", "--state=full", "--debug"])).toEqual({ + values: { config: "a.json", state: "full" }, + switches: new Set(["debug"]), + errors: [], + }) + }) + + /** Dropped in silence, a flag meant for one file drew the user's own settings instead. */ + test("an unknown flag, a missing value or a surface that is not one is an error", () => { + expect(parseArgs(["--cofig", "a.json"]).errors).toEqual([ + "no flag --cofig — try --help", + '"a.json" is not a flag — try --help', + ]) + expect(parseArgs(["--config", "--debug"])).toMatchObject({ errors: ["--config needs a value"] }) + expect(parseArgs(["--config", "--debug"]).switches.has("debug")).toBe(true) + expect(parseArgs(["--surface", "prompt"]).errors).toEqual([ + '--surface is sidebar or bottom, not "prompt"', + ]) + }) +}) + +describe("--config reads a file as OpenCode will", () => { + test("the sidebar preset is the sidebar table, with its own room for every row", () => { + const [line] = preview({ status: { preset: "sidebar" } }).lines + expect(line?.surface).toBe("sidebar") + expect(line?.stack).toBe("vertical") + expect(line?.segments).toEqual(SIDEBAR_SEGMENTS) + expect(line?.maxRows).toBe(14) + }) + + test("`sidebarRows` is the column's cap, above the preset's and below it", () => { + expect(preview({ status: { preset: "sidebar", sidebarRows: 14 } }).lines[0]?.maxRows).toBe(14) + expect(preview({ status: { preset: "sidebar", sidebarRows: 20 } }).lines[0]?.maxRows).toBe(20) + const rows = draw({ status: { preset: "sidebar", sidebarRows: 3 } }) + expect(rows.at(-1)).toMatch(/dropped — sidebarRows is 3$/) + }) + + test("comments, `override` and its notices, exactly as the TUI reads them", () => { + const { loaded, lines } = preview( + '{\n // just git\n "status": { "override": { "git": { "against": "branch" }, "gti": false } },\n}', + ) + const git = lines[0]?.segments.map(asSegmentConfig).find((entry) => entry.type === "git") + expect(git?.against).toBe("branch") + expect(lines[0]?.segments).toHaveLength(SIDEBAR_SEGMENTS.length) + expect(loaded.notices).toEqual([ + 'settings: override "gti" matches no segment in the sidebar preset — did you mean "git"?', + ]) + }) + + test("`sidebar: false` is the line under the prompt", () => { + expect(preview({ status: { sidebar: false } }).lines[0]?.surface).toBe("bottom") + }) +}) + +/** + * `--config -`: the candidate an agent is about to write, piped in rather than written to a temporary + * file — one outside the project is a permission prompt on OpenCode 1, one inside is a stray file in + * the user's repo. It stands in for the file it will become, and the other is read beside it. + */ +describe("a candidate on stdin, `--as` the file it will become", () => { + const GLOBAL = "/cfg/opencode-cockpit/config.json" + const PROJECT = "/w/app/.cockpit.json" + const candidate = (text: unknown, as: "global" | "project", disk: Record) => + previewSettings({ + directory: "/w/app", + env: { XDG_CONFIG_HOME: "/cfg" }, + configText: JSON.stringify(text), + as, + readFile: (path) => (path in disk ? JSON.stringify(disk[path]) : undefined), + }) + + test("as global: it replaces the global file, and the project's still applies over it", () => { + const { lines, target } = candidate({ status: { override: { git: { against: "branch" } } } }, "global", { + [GLOBAL]: { status: { override: { write: false } } }, + [PROJECT]: { status: { override: { cache: false } } }, + }) + expect(target).toBe(GLOBAL) + const types = lines[0]?.segments.map((entry) => asSegmentConfig(entry).type) + expect(types).toContain("write") // the global on disk is what the candidate replaces + expect(types).not.toContain("cache") // the project's file still reads + expect(lines[0]?.segments.map(asSegmentConfig).find((entry) => entry.type === "git")?.against).toBe( + "branch", + ) + }) + + test("as project: it merges over the real global file, as a .cockpit.json does", () => { + const { lines, target, loaded } = candidate({ status: { override: { write: false } } }, "project", { + [GLOBAL]: { status: { override: { git: { against: "branch" } } } }, + [PROJECT]: { status: { override: { tokens: false } } }, + }) + expect(target).toBe(PROJECT) + const types = lines[0]?.segments.map((entry) => asSegmentConfig(entry).type) + expect(types).not.toContain("write") + expect(types).toContain("tokens") // the project file on disk is what the candidate replaces + expect(lines[0]?.segments.map(asSegmentConfig).find((entry) => entry.type === "git")?.against).toBe( + "branch", + ) + expect(loaded.notices).toEqual([]) + }) + + test("JSONC, notices and all, as the loader reads a file", () => { + const { loaded } = previewSettings({ + directory: "/w/app", + env: { XDG_CONFIG_HOME: "/cfg" }, + configText: '{ // mine\n "status": { "override": { "gti": false }, }, }', + as: "global", + readFile: () => undefined, + }) + expect(loaded.notices).toEqual([ + 'settings: override "gti" matches no segment in the sidebar preset — did you mean "git"?', + ]) + }) + + test("`--as` is global or project, and goes with --config", () => { + expect(parseArgs(["--config", "-", "--as", "project"])).toMatchObject({ + values: { config: "-", as: "project" }, + errors: [], + }) + expect(parseArgs(["--config", "-", "--as=home"]).errors).toEqual([ + '--as is global or project, not "home"', + ]) + expect(parseArgs(["--as", "global"]).errors).toEqual(["--as goes with --config"]) + }) +}) + +describe("--surface", () => { + test("draws there whatever the settings say, with that surface's preset and notices", () => { + const moved = preview({ status: { sidebar: false, override: { git: false } } }, "sidebar") + expect(moved.lines[0]?.surface).toBe("sidebar") + expect(moved.lines[0]?.stack).toBe("vertical") + expect(moved.loaded.notices).toEqual([]) + const down = preview({ status: { override: { git: false } } }, "bottom") + expect(down.lines[0]?.surface).toBe("bottom") + expect(down.loaded.notices).toEqual([ + 'settings: override "git" matches no segment in the default preset — did you mean "git.diff"?', + ]) + }) + + test("moves every line in `lines`", () => { + const { lines } = preview( + { status: { lines: [{ surface: "bottom" }, { surface: "sidebar" }] } }, + "sidebar", + ) + expect(lines.map((line) => line.surface)).toEqual(["sidebar", "sidebar"]) + }) +}) + +describe("the room", () => { + test("the sidebar is as wide as OpenCode's, not the terminal; the bottom is the terminal's", () => { + const [side] = preview({}).lines + const [bottom] = preview({ status: { sidebar: false } }).lines + expect(side && roomFor(side, 200)).toBe(SIDEBAR_WIDTH) + expect(SIDEBAR_WIDTH).toBe(34) + expect(bottom && roomFor(bottom, 200)).toBe(195) + expect(side && roomFor(side, 200, 50)).toBe(50) + expect(draw({})[0]).toBe(" sidebar, 34 cols") + }) +}) + +/** + * `⟨?title⟩` and `⟨todo⟩` meant "no such segment" and "drew nothing", one character apart inside the + * same brackets. Each is a glyph of its own now, and readable without colour. + */ +describe("--debug", () => { + test("✓ beside a row that drew, ✗ for one that said nothing, ? for a name nothing answers to", () => { + const rows = draw({ status: { segments: ["title", "spend", "nope"] } }, { debug: true }) + expect(rows).toEqual([ + " sidebar, 34 cols", + ` Status${" ".repeat(34 - "Status".length + 2)}✓title`, + " ✗spend", + " ?nope", + ]) + }) + + test("across a line, the marks follow it in order", () => { + const rows = draw({ status: { sidebar: false, segments: ["todo", "spend", "nope"] } }, { debug: true }) + expect(rows.slice(1)).toEqual([" â–¤ 2/5 todo │ ✗spend │ ?nope", " ✓todo ✗spend ?nope"]) + }) + + test("off, a silent segment draws nothing at all", () => { + expect(draw({ status: { segments: ["title", "spend", "nope"] } })).toEqual([ + " sidebar, 34 cols", + " Status", + ]) + }) +}) diff --git a/packages/status/test/report.test.ts b/packages/status/test/report.test.ts deleted file mode 100644 index df8a62bb..00000000 --- a/packages/status/test/report.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { resolveLines } from "../src/core/config.ts" -import { buildReport, reportHeadline } from "../src/core/report.ts" - -/** - * `/statusline` exists to answer the one question the screen cannot: a quiet line looks the same - * whether there was nothing to report or the config never arrived. So the report has to be right - * about the empty cases, not only the working one. - */ - -const directory = join(tmpdir(), "ck-report-project") -const env = { HOME: join(tmpdir(), "ck-report-home"), XDG_CONFIG_HOME: undefined } - -const report = (over: Parameters[0] extends infer T ? Partial : never = {}) => - buildReport({ - version: "9.9.9", - directory, - lines: resolveLines({ preset: "default" }), - registered: 0, - errors: [], - env, - ...over, - }) - -describe("what it found", () => { - test("names both config files, and says neither is there", () => { - const found = report() - expect(found.sources).toHaveLength(2) - expect(found.sources[0]?.path).toContain(join(".config", "opencode-cockpit", "config.json")) - expect(found.sources[1]?.path).toBe(join(directory, ".cockpit.json")) - expect(found.sources.every((source) => source.found)).toBe(false) - }) - - test("a column reports the row cap; a line across has none to report", () => { - const column = report({ lines: resolveLines({ preset: "sidebar" }) }) - expect(column.lines[0]).toMatchObject({ surface: "sidebar", stack: "vertical" }) - expect(column.lines[0]?.maxRows).toBeGreaterThan(0) - expect(report().lines[0]?.maxRows).toBeUndefined() - }) - - test("modules are reported as listed against registered, which is the pair that shows a miss", () => { - const withModule = report({ modules: ["./segments.ts"], registered: 4 }) - expect(withModule.modules).toMatchObject({ listed: ["./segments.ts"], registered: 4 }) - }) -}) - -describe("the headline", () => { - test("a module failure outranks everything else — its segments are simply missing", () => { - const broken = report({ modules: ["./x.ts"], errors: ["./x.ts: Cannot find module 'foo'"] }) - expect(reportHeadline(broken)).toContain("1 module failed to load") - }) - - test("with no config file at all it says the line is the defaults, not that it is broken", () => { - expect(reportHeadline(report())).toContain("neither config file exists") - }) - - test("nothing configured says exactly that, rather than counting zero segments", () => { - expect(reportHeadline(report({ lines: [] }))).toContain("Nothing is drawing") - }) - - test("a working line counts its segments and names the surfaces", () => { - // A found config file is what takes it off the defaults message; the project file will do. - const running = { ...report(), sources: [{ path: "/w/.cockpit.json", found: true }] } - expect(reportHeadline(running)).toMatch(/^\d+ segments across the bottom$/) - }) -}) diff --git a/packages/status/test/segments.test.ts b/packages/status/test/segments.test.ts index 0143b67f..3e000f81 100644 --- a/packages/status/test/segments.test.ts +++ b/packages/status/test/segments.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { SessionSnapshot, StatusContext } from "../src/core/context.ts" +import { type SessionSnapshot, type StatusContext, serviceStatus } from "../src/core/context.ts" import { buildSegments, findSegment, type Segment, segmentText } from "../src/core/segments.ts" const session = (over: Partial = {}): SessionSnapshot => ({ @@ -283,6 +283,52 @@ describe("the rest of the built-ins", () => { expect(render("diagnostics", broken)?.tone).toBe("error") }) + /** Issue #34: OpenCode 2's MCP status is a tagged object, and every connected server read as broken. */ + /** The tagged shapes: OpenCode 2's five, and 1.18's SDK's `needs_client_registration`. */ + test("a tagged MCP status reads as its word, for every state there is", () => { + const tagged = [ + { status: "connected" }, + { status: "disabled" }, + { status: "pending" }, + { status: "failed", error: "spawn ENOENT" }, + { status: "needs_auth", error: "401" }, + { status: "needs_client_registration", error: "no client id" }, + ] + expect(tagged.map(serviceStatus)).toEqual([ + "connected", + "disabled", + "pending", + "failed", + "needs_auth", + "needs_client_registration", + ]) + expect(serviceStatus("connected")).toBe("connected") + expect(serviceStatus({})).toBe("") + expect(serviceStatus(undefined)).toBe("") + const mcp = tagged.map((raw, i) => ({ name: `s${i}`, status: serviceStatus(raw) })) + expect(render("diagnostics", ctx({ mcp }))?.text).toBe("! s3, s4 +1") + }) + + test("connected, turned off, still connecting or no word at all is not an alarm; any other word is", () => { + const quiet = ["connected", "disabled", "pending", ""].map((status, i) => ({ name: `s${i}`, status })) + expect(render("diagnostics", ctx({ mcp: quiet }))).toBeUndefined() + expect(render("diagnostics", ctx({ mcp: [{ name: "odd", status: "crashed" }] }))?.text).toBe("! odd") + }) + + test("a cut keeps the count: the names give way first", () => { + const mcp = ["web-search-prime-with-a-long-name", "codegraph", "jira"].map((name) => ({ + name, + status: "failed", + })) + const text = render("diagnostics", ctx({ mcp, width: 24 }))?.text ?? "" + expect(text).toHaveLength(24) + expect(text).toEndWith("… +1") + expect(text).toStartWith("! web-search") + expect(render("diagnostics", ctx({ mcp, width: 200 }))?.text).toBe( + "! web-search-prime-with-a-long-name, codegraph +1", + ) + }) + test("a command segment shows whatever the command last returned", () => { const withCommand = ctx({ commands: { budget: "$412 left" } }) expect(render("command", withCommand, { name: "budget" })?.text).toBe("$412 left") diff --git a/packages/status/test/setup.test.ts b/packages/status/test/setup.test.ts new file mode 100644 index 00000000..b2bfc0b1 --- /dev/null +++ b/packages/status/test/setup.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { join } from "node:path" +import { readSkill } from "@opencode-cockpit/client/server" +import { BUILTINS } from "../src/core/builtins/index.ts" +import { + asSegmentConfig, + configNotices, + loadStatus, + PRESETS, + resolveLines, + SIDEBAR_SEGMENTS, + type StatusConfig, +} from "../src/core/config.ts" +import { SEGMENT_ABOUT, statusReference } from "../src/core/reference.ts" +import { OLD_PROMPT, SETUP_PROMPT, SETUP_SKILL, SETUP_SKILL_DIR } from "../src/core/setup.ts" +import { createStatusServer } from "../src/server.ts" + +/** + * The `status-setup` skill is text shipped beside code that changes. These keep it honest: its + * reference is what the code writes, every built-in is described, every starting point it offers is a + * `status` section Status reads without a notice, and the old command says its new name. + */ + +const skill = readFileSync(join(SETUP_SKILL_DIR, "SKILL.md"), "utf8") +const read = (name: string) => readFileSync(join(SETUP_SKILL_DIR, "references", name), "utf8") + +describe("the reference", () => { + test("is what the code writes — run `bun packages/status/src/cli/reference.ts` when it is not", () => { + expect(read("settings.md")).toBe(statusReference()) + }) + + test("every built-in segment is described, and nothing that is not one", () => { + expect(Object.keys(SEGMENT_ABOUT).sort()).toEqual(BUILTINS.map((segment) => segment.name).sort()) + }) + + test("every preset is in it", () => { + for (const name of Object.keys(PRESETS)) expect(read("settings.md")).toContain(`| \`${name}\` |`) + }) + + test("the design rules a copied statusline-design still carries are the same rules", () => { + const old = readFileSync(join(SETUP_SKILL_DIR, "..", "statusline-design", "SKILL.md"), "utf8") + expect(old.endsWith(read("design.md"))).toBe(true) + }) +}) + +describe("the skill", () => { + test("is named for the command, and says when to use it", () => { + const parsed = readSkill({ dir: SETUP_SKILL_DIR }) + expect(parsed?.name).toBe(SETUP_SKILL) + const description = parsed?.description ?? "" + expect(description.length).toBeLessThan(1024) + for (const trigger of ["/status-setup", "/statusline", "Claude Code statusline", "at the bottom"]) + expect(description).toContain(trigger) + expect(skill).toContain("`cockpit_settings`") + expect(skill).toContain("(references/settings.md)") + expect(skill).toContain("(references/design.md)") + }) + + /** Each starting point: a paragraph opening with its name in bold, then its JSON. */ + const presets = skill + .slice(skill.indexOf("## 3."), skill.indexOf("## 4.")) + .split(/^(?=\*\*[^*]+\*\* — )/m) + .slice(1) + .map((part) => ({ + name: /^\*\*([^*]+)\*\*/.exec(part)?.[1] as string, + json: JSON.parse(/```json\n([\s\S]*?)```/.exec(part)?.[1] ?? "null") as Record, + })) + + test("every starting point is a status section Status reads without a notice", () => { + expect(presets.map((preset) => preset.name)).toEqual([ + "The table", + "One line", + "Minimal line", + "Detailed line", + "My Claude Code statusline", + ]) + for (const preset of presets) { + const loaded = loadStatus({ + where: { + env: {}, + home: "/home/me", + read: (path) => (path.endsWith("config.json") ? JSON.stringify(preset.json) : undefined), + }, + }) + expect({ preset: preset.name, notices: [...loaded.notices, ...configNotices(loaded.config)] }).toEqual({ + preset: preset.name, + notices: [], + }) + } + }) + + /** + * Asked to change only the `git` row, an agent once copied the table's fourteen segments into + * `segments`. Each small change the skill teaches is an `override` that the table reads without a + * notice, and that leaves the table's every other row where the preset put it. + */ + test("every small change it teaches is an override of the table, not a copy of it", () => { + const table = skill.slice(skill.indexOf("### A row or two"), skill.indexOf("## 5.")) + const examples = [...table.matchAll(/^\| "[^"]+" \| `(\{.*\})` \|$/gm)].map( + (match) => JSON.parse(match[1] as string) as { status: StatusConfig }, + ) + expect(examples.length).toBeGreaterThanOrEqual(3) + expect(table).toContain('{ "git": { "against": "branch" } }') + for (const example of examples) { + expect(Object.keys(example.status)).toEqual(["override"]) + const loaded = loadStatus({ + where: { + env: {}, + home: "/home/me", + read: (path) => (path.endsWith("config.json") ? JSON.stringify(example) : undefined), + }, + }) + expect({ example, notices: loaded.notices }).toEqual({ example, notices: [] }) + const [line] = resolveLines(loaded.config) + const kept = SIDEBAR_SEGMENTS.filter( + (entry) => !(asSegmentConfig(entry).type in (example.status.override ?? {})), + ) + for (const entry of kept) expect(line?.segments).toContainEqual(entry) + } + }) + + /** + * Piped, never written first: a temporary file outside the project is a permission prompt on + * OpenCode 1, and one inside it dirties the user's repo. + */ + test("it previews what it is about to write on stdin, never through a temporary file", () => { + expect(skill).toContain("| --config - ") + expect(skill).toContain("--as project") + expect(skill).toContain("`✓git`") + for (const text of [skill, read("design.md")]) expect(text).not.toMatch(/\/tmp\/|status-preview\.json/) + }) + + /** `bunx` fetches the newest release from npm, not this install: 0.8 drew a 0.9 sidebar as a bottom line. */ + test("it runs this install's preview, the one cockpit_settings names, never bunx or npx", () => { + expect(skill).toContain("under **Previews**") + for (const text of [skill, read("design.md")]) { + expect(text).not.toMatch(/(bunx|npx) @opencode-cockpit\/status preview/) + } + }) +}) + +describe("the agent side", () => { + const host = () => + ({ version: 1, directory: "/work/app", scope: {}, log: { info() {}, warn() {} } }) as never + + test("the skill and both commands, the old one saying the new name first", async () => { + const parts = await createStatusServer()(host(), {}) + expect(parts.skills).toEqual([{ dir: SETUP_SKILL_DIR }]) + expect(parts.commands?.map((command) => [command.name, command.prompt])).toEqual([ + ["status-setup", SETUP_PROMPT], + ["statusline", OLD_PROMPT], + ]) + expect(OLD_PROMPT.startsWith("/statusline is now /status-setup.")).toBe(true) + }) + + test("the bundle and this package side by side register them once", async () => { + const shared = { version: 1, directory: "/work/app", scope: {}, log: { info() {}, warn() {} } } as never + expect((await createStatusServer({ source: "opencode-cockpit" })(shared, {})).skills).toHaveLength(1) + expect(await createStatusServer()(shared, {})).toEqual({}) + }) +}) diff --git a/packages/status/test/snapshot.test.ts b/packages/status/test/snapshot.test.ts index 8ed2b20a..fd225f69 100644 --- a/packages/status/test/snapshot.test.ts +++ b/packages/status/test/snapshot.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import type { TuiPluginApi } from "@opencode-ai/plugin/tui" import { contextUsed } from "../src/core/context.ts" +import { buildSegments, segmentText } from "../src/core/segments.ts" import { buildContext, sessionSnapshot } from "../src/tui/state/snapshot.ts" /** The adapter between OpenCode's live state and the snapshot every segment reads. */ @@ -198,3 +199,50 @@ describe("the whole context", () => { expect(ctx.session).toBeUndefined() }) }) + +/** + * Issue #34, through the adapter the bug lived in: OpenCode 2.0.18 lists MCP servers with a tagged + * status (`{ status: "connected" }`, `{ status: "failed", error }`) — the shapes its own schema has; + * it has no `needs_client_registration`. `String()` on them made every server read as broken. + */ +describe("OpenCode 2's MCP servers", () => { + const v2Host = (servers: { name: string; status: Record }[]) => + ({ + state: { path: { directory: "/w/app", worktree: "/w/app" } }, + route: { current: { name: "home" } }, + v2: { + location: { directory: "/w/app" }, + data: { location: { mcp: { server: { list: () => servers } } } }, + }, + }) as unknown as Parameters[0] + const context = (servers: { name: string; status: Record }[]) => + buildContext(v2Host(servers), { now: 0, width: 34, version: "0.9.0", commands: {} }) + const diagnostics = (servers: { name: string; status: Record }[]) => { + const [segment] = buildSegments(context(servers), [{ type: "diagnostics" }]) + return segment ? segmentText(segment) : undefined + } + + test("all connected says nothing — the regression #34 was", () => { + const servers = ["github", "linear", "jira"].map((name) => ({ name, status: { status: "connected" } })) + expect(context(servers).mcp.map((item) => item.status)).toEqual(["connected", "connected", "connected"]) + expect(diagnostics(servers)).toBeUndefined() + }) + + test("each tagged state becomes its word, and only the broken ones are named", () => { + const servers = [ + { name: "ok", status: { status: "connected" } }, + { name: "off", status: { status: "disabled" } }, + { name: "wait", status: { status: "pending" } }, + { name: "bad", status: { status: "failed", error: "spawn ENOENT" } }, + { name: "auth", status: { status: "needs_auth", error: "401" } }, + ] + expect(context(servers).mcp.map((item) => item.status)).toEqual([ + "connected", + "disabled", + "pending", + "failed", + "needs_auth", + ]) + expect(diagnostics(servers)).toBe("! bad, auth") + }) +}) diff --git a/packages/status/test/store.test.ts b/packages/status/test/store.test.ts index 2d7a8bef..f5558c89 100644 --- a/packages/status/test/store.test.ts +++ b/packages/status/test/store.test.ts @@ -138,6 +138,72 @@ describe("commands run off the draw path", () => { }) }) +/** The sidebar table's two outside readings: the branch's diff from git, and a proxy's budget file. */ +describe("what the table reads", () => { + const table = (readBudget: (path: string) => { spent: number; cap: number } | undefined) => { + const runs: string[] = [] + const reads: string[] = [] + const store = createStatusStore( + api, + {}, + { + version: "9.9.9", + now: () => 0, + exec: async (command) => { + runs.push(command) + return " 2 files changed, 9 insertions(+), 1 deletion(-)" + }, + readBudget: (path) => { + reads.push(path) + return readBudget(path) + }, + build: (_api, input) => ({ + ...base, + defaultBranch: "dev", + ...(input.branchDiff ? { branchDiff: input.branchDiff } : {}), + ...(input.budget ? { budget: input.budget } : {}), + }), + }, + ) + return { store, runs, reads } + } + + test("the default line asks git what is uncommitted, not the branch against main", async () => { + const { store, runs } = table(() => undefined) + store.context() + await new Promise((done) => setTimeout(done, 0)) + expect(runs).toContain("git diff --shortstat HEAD") + expect(runs.some((run) => run.includes("merge-base"))).toBe(false) + store.dispose() + }) + + test("reads the proxy's file, and carries the budget it found", () => { + const { store, reads } = table(() => ({ spent: 5, cap: 50 })) + expect(store.context().budget).toEqual({ spent: 5, cap: 50 }) + expect(reads[0]).toEndWith("opencode-litellm-iap/spend.json") + store.dispose() + }) + + test("a line with no budget row reads no file", () => { + const reads: string[] = [] + const store = createStatusStore( + api, + { segments: ["context"] }, + { + version: "9.9.9", + readBudget: (path) => { + reads.push(path) + return undefined + }, + build: () => base, + }, + ) + store.context() + expect(reads).toEqual([]) + store.dispose() + }) +}) + describe("disposal", () => { test("stops the clock and the commands", async () => { live = harness({ commands: { probe: { run: "x", intervalMs: 250 } } }) diff --git a/packages/status/test/table.test.ts b/packages/status/test/table.test.ts new file mode 100644 index 00000000..1257f571 --- /dev/null +++ b/packages/status/test/table.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, test } from "bun:test" +import { budgetFile, parseBudget } from "../src/core/budget.ts" +import { asSegmentConfig, PRESETS, resolveLines, SIDEBAR_SEGMENTS } from "../src/core/config.ts" +import type { SessionSnapshot, StatusContext } from "../src/core/context.ts" +import { branchDiffCommand, wantsBranchDiff, wantsDiff } from "../src/core/diff.ts" +import { FIXTURES } from "../src/core/fixtures.ts" +import { fitColumn } from "../src/core/render.ts" +import { buildSegments, findSegment, type Segment, segmentText, segmentWidth } from "../src/core/segments.ts" + +/** + * The sidebar's table — the `sidebar` preset, and the default since 0.9. Its rows were the + * `sidebar-budget` example's, promoted to built-ins so the default needs no module. Its whole point + * is that a column of rows lines up and says nothing it has no figure for, so both are asserted. + */ + +const session = (over: Partial = {}): SessionSnapshot => ({ + id: "ses_1", + status: "idle", + cost: 0, + priced: true, + messages: 2, + startedAt: 0, + model: { providerID: "p", modelID: "m", contextLimit: 200_000 }, + tokens: { input: 24_000, output: 16_000, reasoning: 0, cache: { read: 40_000, write: 0 } }, + diff: { files: 0, additions: 0, deletions: 0 }, + todo: { total: 0, completed: 0 }, + ...over, +}) + +const ctx = (over: Partial = {}): StatusContext => ({ + now: 60_000, + directory: "/w/app", + worktree: "/w/app", + home: "/home/u", + defaultBranch: "main", + version: "0.9.0", + lsp: [], + mcp: [], + commands: {}, + width: 34, + session: session(), + ...over, +}) + +const draw = (type: string, context: StatusContext, config: Record = {}) => + buildSegments(context, [{ type, ...config }], { icons: false })[0] +const text = (segment: Segment | undefined) => (segment ? segmentText(segment) : undefined) + +/** The whole preset, as the sidebar draws it. */ +const table = (context: StatusContext) => + fitColumn( + buildSegments(context, SIDEBAR_SEGMENTS.map(asSegmentConfig)), + context.width, + PRESETS.sidebar?.maxRows ?? 8, + ).segments.map((segment) => segmentText(segment)) + +describe("the sidebar preset", () => { + test("is the default: writing nothing draws the table in the sidebar", () => { + const [line] = resolveLines({}) + expect(line?.surface).toBe("sidebar") + expect(line?.segments).toEqual(SIDEBAR_SEGMENTS) + }) + + test("every name in it is a built-in, so it needs no module", () => { + for (const entry of SIDEBAR_SEGMENTS) expect(findSegment(asSegmentConfig(entry).type)).toBeDefined() + }) + + test("a working session, no proxy: heading, bar, tokens by where they went, one hairline, the branch", () => { + expect(table(ctx({ diff: { files: 3, additions: 42, deletions: 7 } }))).toEqual([ + "Status", + "â–ˆ".repeat(5) + "â–ˆ".repeat(11), + "tokens 80k · 40%", + "in 24k · 30%", + "out 16k · 20%", + "cache 40k · 50%", + "─".repeat(14), + "git 3f +42 -7", + ]) + }) + + test("with a proxy's budget, the budget is a group of its own", () => { + const rows = table( + ctx({ budget: { spent: 26.24, cap: 200 }, diff: { files: 3, additions: 42, deletions: 7 } }), + ) + expect(rows.slice(-5)).toEqual([ + "─".repeat(14), + "spend $26.24", + "avail $173.76 · 87% left", + "─".repeat(14), + "git 3f +42 -7", + ]) + }) + + test("a hairline left with nothing under it goes: a budget and no branch", () => { + const rows = table(ctx({ budget: { spent: 26.24, cap: 200 } })) + expect(rows.at(-1)).toBe("avail $173.76 · 87% left") + expect(rows.filter((row) => row.startsWith("─"))).toHaveLength(1) + }) + + /** Every row of a busy session with a budget, uncommitted work and a broken server fits under the cap. */ + test("the cap holds every row it has, so none is dropped on a full session", () => { + const full = ctx({ + ...FIXTURES.full.ctx, + width: 34, + budget: { spent: 26.24, cap: 200 }, + diff: { files: 3, additions: 42, deletions: 7 }, + mcp: [{ name: "github", status: "failed" }], + session: { + ...(FIXTURES.full.ctx.session as SessionSnapshot), + status: "retry", + retry: { attempt: 2, message: "", next: 6_000 }, + }, + }) + const built = buildSegments(full, SIDEBAR_SEGMENTS.map(asSegmentConfig)) + const fitted = fitColumn(built, 34, PRESETS.sidebar?.maxRows ?? 8) + expect(fitted.dropped).toBe(0) + expect(fitted.segments.map((segment) => segment.id)).toContain("diagnostics") + }) + + test("before the first reply: the heading and what is uncommitted, and no hairline with nothing under it", () => { + const fresh = ctx({ ...FIXTURES.fresh.ctx, width: 34, diff: { files: 5, additions: 312, deletions: 48 } }) + expect(table(fresh)).toEqual(["Status", "─".repeat(14), "git 5f +312 -48"]) + expect(table(ctx({ ...FIXTURES.fresh.ctx, width: 34 }))).toEqual(["Status"]) + }) + + test("every row fits a 24-column sidebar, a word given up before a figure", () => { + const narrow = ctx({ + width: 24, + budget: { spent: 26.24, cap: 200 }, + diff: { files: 28, additions: 1_840, deletions: 620 }, + }) + for (const row of table(narrow)) expect(row.length).toBeLessThanOrEqual(24) + expect(table(narrow)).toContain("avail $173.76 · 87%") + expect(table(narrow)).toContain("git 28f +1.8k -620") + }) +}) + +describe("the table's rows", () => { + test("every labelled row starts with the same seven-column gutter, a space past the longest label", () => { + for (const type of ["in", "out", "cache", "spend", "avail", "git"]) { + const drawn = draw( + type, + ctx({ budget: { spent: 1, cap: 10 }, diff: { files: 1, additions: 1, deletions: 1 } }), + ) + expect(drawn?.runs[0]?.text, type).toHaveLength(7) + } + expect(draw("tokens", ctx(), { style: "row" })?.runs[0]?.text).toBe("tokens ") + }) + + test("the token rows are shares of the window, and they add up to it", () => { + const shares = ["in", "out", "cache", "write"].map((type) => { + const drawn = draw(type, ctx()) + return drawn ? Number(/(\d+)%$/.exec(segmentText(drawn))?.[1] ?? 0) : 0 + }) + expect(shares.reduce((a, b) => a + b, 0)).toBe(100) + }) + + /** `write 0 · 0%` on a session with no cache writes said nothing, in a row of its own. */ + test("a row whose figure is zero is not drawn", () => { + expect(draw("write", ctx())).toBeUndefined() + }) + + /** The figure lives on the tokens row below; printing it twice is what the bar is spared. */ + test("the bar is the full width of solid cells and nothing else", () => { + expect(text(draw("context", ctx(), { style: "solid", width: 16 }))).toBe("â–ˆ".repeat(16)) + }) + + /** Without a proxy writing the file there is no budget, and a made-up one would be worse. */ + test("the budget rows stay silent when nothing reports a spend", () => { + expect(draw("spend", ctx())).toBeUndefined() + expect(draw("avail", ctx())).toBeUndefined() + }) + + test("the budget is calm until it is a level to watch", () => { + expect(draw("spend", ctx({ budget: { spent: 26, cap: 200 } }))?.runs[1]?.tone).toBe("text") + expect(draw("spend", ctx({ budget: { spent: 190, cap: 200 } }))?.runs[1]?.tone).toBe("error") + }) + + /** The word is the label: no coloured square beside it, and the figures are not categories. */ + test("colour is a level, never a label", () => { + for (const type of ["in", "out", "cache"]) { + const drawn = draw(type, ctx()) + for (const run of drawn?.runs ?? []) expect(["text", "muted"]).toContain(run.tone) + } + }) + + test("git is what is uncommitted, and silent until git says", () => { + expect(draw("git", ctx({ diff: undefined }))).toBeUndefined() + expect(draw("git", ctx({ diff: { files: 0, additions: 0, deletions: 0 } }))).toBeUndefined() + expect(text(draw("git", ctx({ diff: { files: 2, additions: 9, deletions: 1 } })))).toBe("git 2f +9 -1") + }) + + test("against the branch, git is the branch against where it forked", () => { + const branched = ctx({ branchDiff: { files: 2, additions: 9, deletions: 1 }, defaultBranch: "dev" }) + expect(text(draw("git", branched, { against: "branch" }))).toBe("git 2f +9 -1 vs dev") + }) + + test("the heading says Status unless told otherwise", () => { + expect(draw("title", ctx())?.runs[0]).toMatchObject({ text: "Status", bold: true }) + expect(text(draw("title", ctx(), { text: "Window" }))).toBe("Window") + }) + + test("a hairline is drawn only between two rows", () => { + const rows = (types: string[]) => + buildSegments( + ctx(), + types.map((type) => ({ type })), + { icons: false }, + ).map((segment) => segment.id) + expect(rows(["sep", "in", "sep", "sep", "out", "sep"])).toEqual(["in", "sep#2", "out"]) + }) + + test("the widest row stays inside a sidebar", () => { + for (const entry of SIDEBAR_SEGMENTS) { + const drawn = buildSegments(ctx({ budget: { spent: 1, cap: 10 } }), [asSegmentConfig(entry)], { + icons: false, + })[0] + if (drawn) expect(segmentWidth(drawn)).toBeLessThanOrEqual(34) + } + }) +}) + +describe("what the table reads", () => { + test("a proxy's file: { baseline, delta, cap }", () => { + expect(parseBudget('{"baseline": 20, "delta": 6.5, "cap": 200}')).toEqual({ spent: 26.5, cap: 200 }) + expect(parseBudget('{"baseline": 20, "cap": 200}')).toEqual({ spent: 20, cap: 200 }) + expect(parseBudget('{"baseline": 20, "cap": 0}')).toBeUndefined() + expect(parseBudget("not json")).toBeUndefined() + expect(parseBudget(undefined)).toBeUndefined() + }) + + test("the file is read only when a line draws a budget, and a segment's `file` points elsewhere", () => { + expect(budgetFile([{ segments: ["in", "git"] }])).toBeUndefined() + expect(budgetFile([{ segments: ["spend"] }], "/home/u")).toBe( + "/home/u/.cache/opencode-litellm-iap/spend.json", + ) + expect(budgetFile([{ segments: [{ type: "avail", file: "~/b.json" }] }], "/home/u")).toBe( + "/home/u/b.json", + ) + }) + + test("git runs only for a line that draws it — uncommitted by default, the branch when asked", () => { + expect(wantsBranchDiff([{ segments: ["git.diff"] }])).toBe(false) + expect(wantsBranchDiff([{ segments: SIDEBAR_SEGMENTS }])).toBe(false) + expect(wantsDiff([{ segments: SIDEBAR_SEGMENTS }])).toBe(true) + expect(wantsBranchDiff([{ segments: [{ type: "git", against: "branch" }] }])).toBe(true) + expect(wantsDiff([{ segments: [{ type: "git", against: "branch" }] }])).toBe(false) + expect(branchDiffCommand("main")).toBe(`git diff --shortstat "$(git merge-base 'main' HEAD)"`) + expect(branchDiffCommand("it's")).toContain(`'it'\\''s'`) + }) +}) diff --git a/packages/subagents/README.md b/packages/subagents/README.md index b244d489..ba7c2ba3 100644 --- a/packages/subagents/README.md +++ b/packages/subagents/README.md @@ -16,16 +16,20 @@ opencode plugin add @opencode-cockpit/subagents@0.8.0 # OpenCo ## What it does -**In the sidebar**, a Subagents block: each subagent in this conversation, its task — and its type, -unless it is `general` — and under one still at it, what it is doing now: `grep "session" src/auth/**`, -`thinking`, `waiting for permission`, `stopped` — with how many calls and how long the whole run has -taken on the right. Working ones come first. A finished one is a single quiet row; one held on a +**In the sidebar**, a Subagents block: each subagent in this conversation, its agent, muted, and its +task — a run with no title is named by its task's first words. The agents are one column, as wide as +the longest shown and eight cells at most, so `general`, `explore` and `build` read whole and the +titles line up. Under one still at it, what it is doing now: `grep "session" src/auth/**`, +`thinking`, `waiting for permission` — with how many calls and how long the run has taken on the +right. Working ones come first. A finished one is a single quiet row that says how long it ran +(`â—� general Update README for the… 28s`); its calls and rounds are in the pane. One held on a permission is drawn in the warning tone and counted apart in the heading (`1 running · 1 needs you`). A subagent that launched its own has them indented under it, and the same helper launched again and again for the same task is one entry with a count (`×6`). A finished nested one leaves the sidebar -after `hideNestedAfter` seconds; the heading still counts it. +after `hideNestedAfterSeconds`; the heading still counts it. With none yet, the block still shows +its heading and `none yet`, so you can tell it is there. -**Click one** — or `ctrl+x w`, or `/subagents` — and its run opens in a pane on the right, half the +**Click one** — or `ctrl+x d`, or `/subagents` — and its run opens in a pane on the right, half the window or all of it: its model and who launched it, the task, then the run. A shell command or a file change is a box with its output (ten lines, sixty open, all with `a`); reads and searches are one quiet line each; a task names the subagent it launched, todos are a checklist, and an MCP tool is titled @@ -46,9 +50,10 @@ markdown too; the answer is markdown. | `b` | Move it to the background, so the main agent carries on (OpenCode's own `ctrl+b`) | | `i` | Details: model, what it is denied, calls by tool, tokens, cost | | `w` | Half the window, or all of it (remembered) | -| `[` `]` | Another subagent of this conversation | +| `[` `]` · `â†�` `→` | Another subagent of this conversation | | `d` `u` · `g` `G` | Page down · up · to the start · follow the run | -| `esc` `q` | Back to the conversation | +| `?` | Every key, in the pane — the footer has room for the ones you use constantly | +| `esc` `q` | Let go of the cursor, then back to the conversation | **Follow-ups keep their context.** The main agent is asked to continue the subagent that did the work (`task_id` on OpenCode 1, `sessionID` on 2) rather than launch a new one. Each round shows in the @@ -82,16 +87,33 @@ export OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true # in ~/.zshrc, then sta ## Settings -In the bundle's entry (`"subagents": { … }`) or this package's own: +In the `subagents` section of Cockpit's config file — read by both halves, in every project: + +``` +~/.config/opencode-cockpit/config.json → /.cockpit.json → plugin-entry options +``` + +```jsonc +{ "subagents": { "sidebarRows": 6, "hideWhenEmpty": false, "hideFinishedAfterMinutes": 60 } } +``` + +The same keys also work on the plugin entry (the bundle's `"subagents": { … }`, or this package's +own), which wins over both files. | Setting | Default | | | --- | --- | --- | | `sidebarRows` | `6` | Subagents shown before the rest fold into a count — working ones first | -| `hideFinishedAfter` | unset | Minutes a finished subagent stays in the sidebar; unset keeps it for the conversation | -| `hideNestedAfter` | `30` | Seconds a finished *nested* subagent — one a subagent launched — stays in the sidebar; a negative number keeps them. The heading still counts them and `[` `]` still reach them | -| `sidebarOrder` | `150` | Where the block sits in the sidebar; lower draws first | -| `keybinds` | `{ "cockpit.subagents.open": "w" }` | The key that opens the latest one | +| `hideWhenEmpty` | `false` | With no subagents the block says `none yet` under its heading; `true` draws nothing instead | +| `hideFinishedAfterMinutes` | unset | Minutes a finished subagent stays in the sidebar; unset keeps it for the conversation | +| `hideNestedAfterSeconds` | `30` | Seconds a finished *nested* subagent — one a subagent launched — stays in the sidebar; a negative number keeps them. The heading still counts them and `[` `]` still reach them | +| `keybinds` | `{ "cockpit.subagents.open": "d" }` | The key that opens the latest one | | `guidance` | `true` | Tell the main agent about background subagents and follow-ups (agent side) | +| `enabled` | `true` | `false` switches both halves off | + +Where the block sits is the top-level `"sidebar"` list's to say (`["status", "subagents", "shell", +"trail", "trust"]` by default). The names from before 0.9 — `hideFinishedAfter`, `hideNestedAfter`, +`sidebarOrder` — are no longer read: the block shows a `!` row naming the new one, and +`/cockpit-setup` fixes it. ## See it without OpenCode diff --git a/packages/subagents/package.json b/packages/subagents/package.json index 455c399c..1c07c520 100644 --- a/packages/subagents/package.json +++ b/packages/subagents/package.json @@ -49,7 +49,7 @@ }, "dependencies": { "@opencode-cockpit/client": "workspace:*", - "@opencode-ai/plugin": "1.18.31" + "@opencode-ai/plugin": "1.18.33" }, "devDependencies": { "@opentui/core": "0.4.5", diff --git a/packages/subagents/src/agent/plugin.ts b/packages/subagents/src/agent/plugin.ts index feec9b5b..8766f179 100644 --- a/packages/subagents/src/agent/plugin.ts +++ b/packages/subagents/src/agent/plugin.ts @@ -1,10 +1,11 @@ import { type ToolContext, type ToolDefinition, tool } from "@opencode-ai/plugin" import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client" -import { dualServer, type ServerHost, type ServerStart } from "@opencode-cockpit/client/server" +import { dualServer, openText, type ServerHost, type ServerStart } from "@opencode-cockpit/client/server" import { createdSession } from "../core/adapt/created.ts" import { endOf, finishedAt } from "../core/adapt/ends.ts" import { createV1Translator } from "../core/adapt/v1.ts" import { createV2Translator } from "../core/adapt/v2.ts" +import { loadSubagents } from "../core/config.ts" import type { Change } from "../core/model/changes.ts" import { applyAll, @@ -13,6 +14,7 @@ import { rootOf, type Session, subagentsOf, + titleOf, working, } from "../core/model/model.ts" import { backgroundOffered, subagentsGuidance } from "../core/view/guidance.ts" @@ -90,7 +92,13 @@ export function createSubagentsServer({ return {} } const log = host.log.child("subagents") - const options = (rawOptions ?? {}) as { guidance?: boolean } + /** The `subagents` section of the files, as the interface reads it, then the entry's options. */ + const { config: options } = loadSubagents(host.directory, rawOptions) + if (!options.enabled) { + log.info("off in the settings") + claim.release() + return {} + } const background = backgroundOffered(host.version, process.env) log.info("background subagents", { offered: background }) const guidance = subagentsGuidance({ version: host.version, background }) @@ -156,7 +164,19 @@ export function createSubagentsServer({ }) return { - ...(options.guidance === false ? {} : { system: async () => [guidance] }), + ...(options.guidance === false + ? {} + : { + system: async () => [guidance], + /** For the Cockpit-wide line: subagents in the sidebar, or only behind their key. */ + surfaces: [ + { + what: "subagents", + ...(options.sidebar ? {} : { where: "the subagents view" }), + open: openText("subagents", "cockpit.subagents.open", options.keybinds, "subagents"), + }, + ], + }), tools: { subagents_list: list, subagents_read: read, subagents_wait: wait }, event: (event) => { try { @@ -372,7 +392,7 @@ function createRuns(host: ServerHost) { known.length === 0 ? `no subagent "${id}": this conversation has none. Ids look like ses_… and come from the task result or subagents_list.` : `no subagent "${id}". This conversation's subagents:\n${known - .map((node) => `- ${node.session.id} "${node.session.title || "subagent"}"`) + .map((node) => `- ${node.session.id} "${titleOf(node.session)}"`) .join("\n")}`, ) } diff --git a/packages/subagents/src/cli/preview.ts b/packages/subagents/src/cli/preview.ts index 1dfe5468..cc6c44cd 100644 --- a/packages/subagents/src/cli/preview.ts +++ b/packages/subagents/src/cli/preview.ts @@ -24,6 +24,8 @@ import { finishedSample, LATE_ROOT, lateSample, + NAMES_ROOT, + namesSample, SAMPLE_NOW, SAMPLE_ROOT, sample, @@ -77,20 +79,34 @@ const FIXTURES: Record Change[]; root: string; about: s root: FINISHED_ROOT, about: "everything ended: six done, one stopped", }, + names: { + changes: namesSample, + root: NAMES_ROOT, + about: "every row names its agent: general, a long name, no title, a placeholder title", + }, + empty: { changes: () => [], root: "ses_empty", about: "nothing launched yet: the heading and `none yet`" }, } +/** A settings notice as the block draws it (client/settings `noticeText`), for `--notice`. */ +const NOTICE = 'settings: "subagents.hideFinishedAfter" is no longer read — run /cockpit-setup' + const args = process.argv.slice(2) if (args.includes("--help") || args.includes("-h")) { process.stdout.write( [ "Usage: subagents preview [--width ] [--fixture ] [--columns ]", - " [--state closed|open|whole]", + " [--state closed|open|whole] [--widths 24,30,36,50] [--notice] [--hide]", + " [--keys]", "", ...Object.entries(FIXTURES).map(([name, { about }]) => ` ${name.padEnd(10)}${about}`), " calls a call of every kind: thinking as markdown, todos, an MCP tool, a task,", " and an ask_advisor with a sixty-line question — folded, open and whole", "", " --state with --fixture calls, only that state", + " --widths the sidebar at each of these widths, and nothing else", + " --notice the block with a settings notice in it", + " --hide hideWhenEmpty: an empty block draws nothing", + " --keys the pane's [?] Keys screen", "", ].join("\n"), ) @@ -152,10 +168,30 @@ if (!fixture) { const model = applyAll(emptyModel(), fixture.changes()) const nodes = subagentsOf(model, fixture.root) -const out: string[] = ["", "Sidebar", ""] /** The host's default for nested ones: thirty seconds. */ -for (const line of sidebarLines({ nodes, width: sidebarWidth, now: SAMPLE_NOW, frame: 2, fadeAfter: 30_000 })) - out.push(paint(line.row)) +const sidebar = (width: number) => + sidebarLines({ + nodes, + width, + now: SAMPLE_NOW, + frame: 2, + fadeAfter: 30_000, + hideWhenEmpty: args.includes("--hide"), + ...(args.includes("--notice") ? { notices: [NOTICE] } : {}), + }) +const widths = option("--widths") +if (widths) { + const out: string[] = [] + for (const width of widths.split(",").map(Number).filter(Boolean)) { + out.push("", `Sidebar, ${width} columns`, "") + /** The edge drawn, so a row a cell short or long shows. */ + for (const line of sidebar(width)) out.push(`${paint(line.row)}│`) + } + process.stdout.write(`${out.join("\n")}\n\n`) + process.exit(0) +} +const out: string[] = ["", "Sidebar", ""] +for (const line of sidebar(sidebarWidth)) out.push(paint(line.row)) const first = nodes[0]?.session if (first) { const base = { @@ -171,6 +207,12 @@ if (first) { thinking: false, details: false, } + if (args.includes("--keys")) { + out.push("", `The pane's [?] Keys — ${columns} columns`, "") + for (const row of screenRows({ ...base, keys: true }).rows) out.push(paint(row)) + process.stdout.write(`${out.join("\n")}\n\n`) + process.exit(0) + } out.push("", "The pane — the first subagent", "") const folded = screenRows(base) for (const row of folded.rows) out.push(paint(row)) diff --git a/packages/subagents/src/core/config.ts b/packages/subagents/src/core/config.ts new file mode 100644 index 00000000..27bdd6a8 --- /dev/null +++ b/packages/subagents/src/core/config.ts @@ -0,0 +1,71 @@ +/** + * Subagents' settings, through the loader every bay shares (`@opencode-cockpit/client/settings`): + * + * ~/.config/opencode-cockpit/config.json → /.cockpit.json → plugin-entry options + * + * Only the `subagents` section of a file is read, by both halves: the interface's keys and the + * agent's `guidance` sit in one place instead of in `tui.json` and `opencode.json` apart. Before 0.9 + * the bay read no file at all. An old name (`hideFinishedAfter`, `hideNestedAfter`, `sidebarOrder`) + * is not read; it is a notice naming the new one, drawn in the Subagents block. + */ + +import { baySettings, type SettingsNotice } from "@opencode-cockpit/client/settings" + +export interface SubagentsConfig { + /** Off switch for this bay, both halves, wherever it is written. `features.subagents: false` too. */ + enabled?: boolean + /** Subagents shown in the sidebar before the rest fold into `+ N more`. Default 6. */ + sidebarRows?: number + /** Draw no Subagents block at all while there are none. Default false: the heading and `none yet`. */ + hideWhenEmpty?: boolean + /** + * Minutes a finished subagent stays in the sidebar; unset keeps it for the conversation. It is only + * out of the sidebar — `/subagents` and the pane's `[` `]` still reach it, and it comes back if it + * works again. + */ + hideFinishedAfterMinutes?: number + /** + * Seconds a finished *nested* subagent — one a subagent launched, an advisor it asks again and + * again — stays in the sidebar; 30 unless set, a negative number keeps them. As with + * `hideFinishedAfterMinutes` it is only out of the sidebar: the heading still counts it and the + * pane's `[` `]` still reach it. Seconds, because these come and go in seconds. + */ + hideNestedAfterSeconds?: number + /** The system-prompt guidance that teaches the agent to follow, wait on and read its subagents. */ + guidance?: boolean + keybinds?: Record +} + +/** What every source left unset becomes; a written value of another kind is dropped, with a notice. */ +export const DEFAULTS = { + guidance: true, + hideNestedAfterSeconds: 30, + /** Unset keeps finished ones: a number only when written. */ + hideFinishedAfterMinutes: undefined as number | undefined, +} + +export interface LoadedSubagents { + config: SubagentsConfig & { + sidebar: boolean + sidebarRows: number + hideWhenEmpty: boolean + enabled: boolean + } + /** The block's place, from the top-level `sidebar` list. */ + order: number + /** Settings to fix, for a `!` row in the block. */ + notices: SettingsNotice[] +} + +/** Reads and merges every source. Never throws. */ +export function loadSubagents( + directory: string, + options?: unknown, + env: Record = process.env, +): LoadedSubagents { + const loaded = baySettings("subagents", DEFAULTS, { options, where: { directory, env } }) + const config: LoadedSubagents["config"] = { ...loaded.config } + /** Undefined has no kind to check against: anything but a number there means "keep them". */ + if (typeof config.hideFinishedAfterMinutes !== "number") delete config.hideFinishedAfterMinutes + return { config, order: loaded.order, notices: loaded.notices } +} diff --git a/packages/subagents/src/core/model/model.ts b/packages/subagents/src/core/model/model.ts index abf5f6c7..e2841508 100644 --- a/packages/subagents/src/core/model/model.ts +++ b/packages/subagents/src/core/model/model.ts @@ -286,8 +286,29 @@ export const working = (s: Session): boolean => s.status !== "done" && s.status * (docs/building/principles.md, rule 2). */ -/** What it is called: its title, else its task, else "subagent". */ -export const titleOf = (s: Session): string => s.title || s.task || "subagent" +/** OpenCode's placeholder for a session nobody has titled yet: `Child session - 2026-10-03T…`. */ +const PLACEHOLDER = /^(new|child) session - \d{4}-\d{2}-\d{2}t/i + +/** Words of a task kept as a name: enough to tell two runs apart, short enough to be a name. */ +const NAME_WORDS = 8 + +/** A task's first words, from its first line that says anything: a name for an untitled run. */ +export function firstWords(text: string | undefined): string { + const line = (text ?? "").split("\n").find((each) => each.trim() !== "") ?? "" + const words = line.trim().split(/\s+/).filter(Boolean) + return words.length > NAME_WORDS ? `${words.slice(0, NAME_WORDS).join(" ")}…` : words.join(" ") +} + +/** + * What a subagent is called, everywhere it is named — the sidebar, the pane, the main agent's tools. + * Its title; with none (or only OpenCode's placeholder) its task's first words; never an empty name, + * which drew a row that was an agent and a time with nothing between them. + */ +export function titleOf(s: Session): string { + const title = s.title?.trim() + if (title && !PLACEHOLDER.test(title)) return title + return firstWords(s.task) || "subagent" +} /** How many calls it made. */ export const callsOf = (s: Session): number => s.entries.filter((entry) => entry.kind === "tool").length diff --git a/packages/subagents/src/core/sample.ts b/packages/subagents/src/core/sample.ts index 7c807e26..a42fbfba 100644 --- a/packages/subagents/src/core/sample.ts +++ b/packages/subagents/src/core/sample.ts @@ -346,6 +346,38 @@ export function finishedSample(): Change[] { ] } +export const NAMES_ROOT = "ses_names" + +/** + * Names, as OpenCode hands them over: `general` beside `explore`, which used to be drawn as two kinds + * of row; an agent with a long name; a run with no title at all and one with only OpenCode's + * placeholder, which drew an agent and a time with nothing between them. + */ +export function namesSample(): Change[] { + const t = (s: number) => SAMPLE_NOW - 300_000 + s * 1000 + const root = NAMES_ROOT + const untitled = (id: string, agent: string, title: string, task: string, start: number, end?: number) => [ + { type: "session", id, parentID: root, agent, title, at: t(start) } as Change, + { type: "prompt", id, key: "u1", text: task, at: t(start) } as Change, + { type: "status", id, status: "busy", at: t(start) } as Change, + ...(end === undefined ? [] : [{ type: "status", id, status: "idle", at: t(end) } as Change]), + ] + return [ + { type: "session", id: root, agent: "build", title: "Plan the billing export", at: t(0) }, + ...run("ses_n_plan", root, "general", "Write a long plan for the export", t(5), t(140), 12), + ...run("ses_n_scan", root, "explore", "Explore the export module", t(10), undefined, 4), + ...run("ses_n_rev", root, "security-reviewer", "Review the export for PII", t(20), t(200), 9), + ...untitled("ses_n_bare", "general", "", "Find every caller of exportCsv\nand list them", 30, 90), + ...untitled( + "ses_n_auto", + "explore", + "Child session - 2026-10-03T10:00:00.000Z", + "Check the export's tests for flakiness across the whole suite please", + 40, + ), + ] +} + export const LATE_ROOT = "ses_late" /** diff --git a/packages/subagents/src/core/view/guidance.ts b/packages/subagents/src/core/view/guidance.ts index 800034de..aa050d95 100644 --- a/packages/subagents/src/core/view/guidance.ts +++ b/packages/subagents/src/core/view/guidance.ts @@ -42,11 +42,19 @@ export function continueHow(version: 1 | 2): string { : "call the subagent tool with its id as sessionID" } +/** + * A plugin tool as the model calls it: directly on OpenCode 1, through Code Mode's `tools.` on + * OpenCode 2, whose catalog may not list it (docs/opencode/trail-server.md). `task` and `subagent` + * are built-ins, called directly on both. + */ +const call = (version: 1 | 2, name: string) => (version === 2 ? `tools.${name}` : name) + export function subagentsGuidance({ version, background }: { version: 1 | 2; background: boolean }): string { + const t = (name: string) => call(version, name) const launch = background ? [ `When you delegate independent work to a subagent, launch it in the background — background: true, a boolean — so this conversation continues while it works; you are notified when it finishes. Work on something else meanwhile, or tell the user what you launched.`, - `If you have nothing else to do and need a background subagent's result, call subagents_wait instead of sleeping or polling.`, + `If you have nothing else to do and need a background subagent's result, call ${t("subagents_wait")} instead of sleeping or polling.`, ] : [ `Your task tool has no background option in this OpenCode (OpenCode 1 offers it only when started with OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true), so never pass background: each task call returns when its subagent answers. To run independent subagents at the same time, call the task tool for each of them in the same message — they run in parallel, and you continue when the last one answers.`, @@ -54,8 +62,8 @@ export function subagentsGuidance({ version, background }: { version: 1 | 2; bac return [ "## Subagents (opencode-cockpit)", ...launch, - `When the user asks for a fix or follow-up on work a subagent already did, continue that same subagent (${continueHow(version)}) rather than launching a new one — it keeps its context. subagents_list gives each subagent's id, task, state and last answer; subagents_read gives one subagent's full answer and what it did.`, - `A subagent that was cancelled or failed can be continued the same way, and keeps what it did. A "Task cancelled" or "aborted" result usually means it was stopped from outside — the user interrupted, or your own turn was stopped — not that it cannot run: read it with subagents_read before deciding to continue it or start over, and never retry with another subagent's id.`, + `When the user asks for a fix or follow-up on work a subagent already did, continue that same subagent (${continueHow(version)}) rather than launching a new one — it keeps its context. ${t("subagents_list")} gives each subagent's id, task, state and last answer; ${t("subagents_read")} gives one subagent's full answer and what it did.`, + `A subagent that was cancelled or failed can be continued the same way, and keeps what it did. A "Task cancelled" or "aborted" result usually means it was stopped from outside — the user interrupted, or your own turn was stopped — not that it cannot run: read it with ${t("subagents_read")} before deciding to continue it or start over, and never retry with another subagent's id.`, "The user can watch each subagent and message it directly; when they do, a note in this conversation tells you what they asked and what it answered.", ].join("\n") } diff --git a/packages/subagents/src/core/view/screen.ts b/packages/subagents/src/core/view/screen.ts index 0ce627ba..c3a511ad 100644 --- a/packages/subagents/src/core/view/screen.ts +++ b/packages/subagents/src/core/view/screen.ts @@ -23,7 +23,15 @@ * the body scrolls. Pure, like everything in `core/`. */ -import { closeHint, fitHints, type Hint, hintRuns, stateMark, toneOf } from "@opencode-cockpit/client/design" +import { + closeHint, + fitHints, + type Hint, + hintRuns, + keyName, + stateMark, + toneOf, +} from "@opencode-cockpit/client/design" import { callsOf, type Entry, type Node, type Session, titleOf } from "../model/model.ts" import { ARG_PREVIEW, argumentRows, large } from "./args.ts" import { markdownRows, plain } from "./markdown.ts" @@ -70,6 +78,8 @@ export interface ScreenInput { thinking: boolean /** The details view in place of the timeline. */ details: boolean + /** `?`: every key the pane takes, in the body's place. */ + keys?: boolean /** A message being typed at the bottom of the pane. */ input?: { draft: string; busy: boolean } /** A line under the keys: what just happened. */ @@ -744,7 +754,7 @@ function header(input: ScreenInput, width: number): Row[] { const each = Math.max(12, Math.floor((width - 14) / nodes.length) - 3) for (const node of nodes) { const current = node.session.id === session.id - const label = cut(`${node.session.agent} ${node.session.title}`, each) + const label = cut(`${node.session.agent} ${titleOf(node.session)}`, each) runs.push( { text: label, tone: current ? "accent" : "muted", bold: current, fill: "band" }, { text: " ", fill: "band" }, @@ -783,10 +793,16 @@ function footer(input: ScreenInput, width: number): Row[] { ), ] } + /** On the keys screen the only key worth a row is the way back to the run (Review's, Trust's). */ + if (input.keys) { + const back: Run[] = [{ text: PAD }, ...fitHints([closeHint("Hide Keys")], width - PAD.length).runs] + return [fit(back, width), fit([], width)] + } /** * In the order drawn, each with its rank: at half width the lowest-ranked go first, and the way out * never does — it used to be the lowest, so the first key a narrow pane lost was how to leave it. - * The cutting, the `…` and the shape are the ones every bay shares (client/design). + * The cutting, the `…` and the shape are the ones every bay shares (client/design). `[?] Keys` + * gives way late: it is where every key the row dropped can still be found. */ const all: Hint[] = [ { key: "j/k", label: "Select", priority: 5 }, @@ -797,6 +813,7 @@ function footer(input: ScreenInput, width: number): Row[] { { key: "t", label: input.thinking ? "Hide Thinking" : "Show Thinking", priority: 3 }, { key: "i", label: input.details ? "Timeline" : "Details", priority: 6 }, { key: "w", label: "Width", priority: 4 }, + { key: "?", label: "Keys", priority: 8.5 }, closeHint("Back"), ] const keys: Run[] = [{ text: PAD }, ...fitHints(all, width - PAD.length).runs] @@ -806,6 +823,71 @@ function footer(input: ScreenInput, width: number): Row[] { return [fit(keys, width), fit([{ text: `${PAD}${note}`, tone: "muted" }], width)] } +export interface KeyLine { + keys: string[] + does: string +} + +/** + * `[?] Keys`: every key the pane takes, in the order a run is read — move, open, act on the + * subagent, the view — then the way out. The footer has room for the ones used constantly and says + * `…` for the rest; this is the rest, the same screen Review and Trust have. + */ +export const SUBAGENT_KEYS: readonly KeyLine[] = [ + { keys: ["j/k", "↑/↓"], does: "Move the cursor through the run: calls, thinking, your messages" }, + { + keys: ["enter", "space"], + does: "Open or fold the item under the cursor; on a task call, go into that subagent", + }, + { keys: ["a"], does: "A call's whole output, or back to its first lines" }, + { keys: ["e"], does: "Open every call, or fold them all" }, + { keys: ["â†�/→"], does: "Previous or next subagent of this conversation (also [ and ])" }, + { keys: ["m"], does: "Message this subagent; finished, its answer is passed on to the main agent" }, + { keys: ["x"], does: "Stop it (press twice), or once it has ended, take it off the list" }, + { keys: ["X"], does: "Take every finished subagent off the list" }, + { keys: ["b"], does: "Move it to the background: the main agent stops waiting for it" }, + { keys: ["t"], does: "Show or hide thinking" }, + { keys: ["i"], does: "Details: model, tokens, cost, the session id — or back to the timeline" }, + { keys: ["w"], does: "Half the window, or all of it" }, + { keys: ["d", "u"], does: "Scroll down or up (also pgdn, pgup)" }, + { keys: ["g", "G"], does: "The start of the run, or follow it as it grows (also home, end)" }, + { keys: ["?", "esc"], does: "Hide these keys; on the run, esc lets go of the cursor, then closes" }, +] + +/** The keys in the body's place: each line's keys in a column, what it does wrapped beside them. */ +function keyLines(width: number): Line[] { + const column = Math.max( + ...SUBAGENT_KEYS.map((line) => widthOf(line.keys.map((name) => `[${keyName(name)}]`).join(" "))), + ) + const indent = PAD.length + column + 3 + const lines: Line[] = [ + { row: fit([{ text: `${PAD}KEYS`, tone: "text", bold: true }], width) }, + { row: fit([], width) }, + ] + for (const line of SUBAGENT_KEYS) { + const keys: Run[] = line.keys.flatMap((name, at): Run[] => [ + ...(at > 0 ? [{ text: " " }] : []), + { text: `[${keyName(name)}]`, tone: "accent", bold: true }, + ]) + const used = widthOf(rowText(keys)) + wrap(line.does, Math.max(1, width - indent - PAD.length)).forEach((text, at) => { + lines.push({ + row: fit( + [ + { text: PAD }, + ...(at === 0 + ? [...keys, { text: " ".repeat(column - used + 3) }] + : [{ text: " ".repeat(column + 3) }]), + { text, tone: "muted" }, + ], + width, + ), + }) + }) + } + return lines +} + /** The selected item's rows, marked: a coloured edge and the selection fill. */ function mark(lines: Line[], selected: string | undefined, width: number): Line[] { if (!selected) return lines @@ -839,14 +921,18 @@ export function screenRows(input: ScreenInput): Screen { const room = Math.max(1, height - top.length - bottom.length - 2) const opened: string[] = [] const links = new Map() - const body = input.details - ? detailLines(input, width) - : mark(bodyLines(input, width, opened, links), input.selected, width) - const keys = input.details - ? [] - : [...new Set(body.map((line) => line.item).filter((item): item is string => Boolean(item)))] + const body = input.keys + ? keyLines(width) + : input.details + ? detailLines(input, width) + : mark(bodyLines(input, width, opened, links), input.selected, width) + const keys = + input.details || input.keys + ? [] + : [...new Set(body.map((line) => line.item).filter((item): item is string => Boolean(item)))] const most = Math.max(0, body.length - room) - let first = input.top === undefined ? most : Math.min(Math.max(0, input.top), most) + /** Following the run means its end; the keys are read from their first line (and scroll as the run does). */ + let first = input.top === undefined ? (input.keys ? 0 : most) : Math.min(Math.max(0, input.top), most) /** * The cursor moved onto an item: bring it into view. Only then — pinned on every paint, a selected * item longer than the pane snapped back to its first line whenever you scrolled into it. @@ -857,6 +943,17 @@ export function screenRows(input: ScreenInput): Screen { if (at >= first + room) first = Math.min(most, at - room + 3) } const shown = body.slice(first, first + room) + /** A key list cut short says so, and how to reach the rest, as Review's and Trust's do. */ + const below = body.length - (first + room) + if (input.keys && below > 0 && shown.length > 1) { + const left = below + 1 + shown[shown.length - 1] = { + row: fit( + [{ text: `${PAD}↓ ${left} more line${left === 1 ? "" : "s"} — [d] scrolls`, tone: "muted" }], + width, + ), + } + } while (shown.length < room) shown.push({ row: fit([], width) }) const blank = fit([], width) return { diff --git a/packages/subagents/src/core/view/sidebar.ts b/packages/subagents/src/core/view/sidebar.ts index 37d67c9c..bb252f7f 100644 --- a/packages/subagents/src/core/view/sidebar.ts +++ b/packages/subagents/src/core/view/sidebar.ts @@ -10,7 +10,13 @@ * â”” grep "session" 9 calls · 51s * â ™ advisor Review the plan ×6 * â”” 2 running · thinking 3s - * â—� Update README 3 calls · 28s + * â—� general Update README 28s + * + * Every row names its agent, muted, before its title, in one column as wide as the longest agent + * shown (at most eight cells: `general` and `explore` whole, `orchest…`), so every title at one depth + * starts in the same column. A finished row says only how long it ran; the calls are the pane's. With nothing to list the block + * still says it exists: the heading and `none yet` in the row the first entry will take + * (client/design `emptyBlock`). * * A finished entry is one row: there is nothing it is doing, and a second row saying `done` under * every one of seven finished subagents was what pushed the rest under `+ 3 more`. What is still @@ -22,6 +28,7 @@ */ import { + emptyBlock, HEADING, HEADING_GAP, moreText, @@ -29,6 +36,7 @@ import { type State, stateMark, summaryRuns, + warnRows, } from "@opencode-cockpit/client/design" import { type Activity, @@ -44,7 +52,7 @@ import { titleOf, working, } from "../model/model.ts" -import { elapsed, fit, type Row, type Run, rowText, spread, widthOf } from "./rows.ts" +import { cut, elapsed, fit, type Row, type Run, spread, widthOf } from "./rows.ts" export interface SidebarLine { row: Row @@ -66,6 +74,13 @@ export interface SidebarInput { * They only leave the sidebar: the heading still counts them, and the pane still reaches them. */ fadeAfter?: number + /** Draw nothing at all while there is nothing to list (`hideWhenEmpty`); else `none yet`. */ + hideWhenEmpty?: boolean + /** + * Settings to fix, each said once in the block as a `!` row (client/settings `noticeText`). A + * failure always speaks: with these, the block shows even when `hideWhenEmpty` would hide it. + */ + notices?: readonly string[] } /** Stopped — by you, or with the main agent — is not broken: OpenCode reports it as an error. */ @@ -143,15 +158,45 @@ function doing(activity: Activity): Row { } } +/** The agent column is never wider than this: `general`, `explore` and `build` stay whole. */ +export const AGENT_COLUMN = 8 + +/** Title cells the agent column must leave; narrower than that, the block draws titles alone. */ +const TITLE_FLOOR = 6 + +/** + * The block's agent column: as wide as the longest agent shown, up to `AGENT_COLUMN` cells. One + * width for every row, so every title starts in the same column — each row shortening its own agent + * put one title at column 9 and the one under it at column 7. None (0) when `room`, a top-level + * row's, would leave the titles fewer than `TITLE_FLOOR` cells. + */ +export function agentColumn(agents: readonly string[], room: number): number { + const column = Math.min(AGENT_COLUMN, Math.max(0, ...agents.map((agent) => widthOf(agent)))) + return room - column - 1 >= TITLE_FLOOR ? column : 0 +} + /** - * The agent's name, when it says something. `general` is what a subagent is when nobody chose one, - * and on every row it cost eight columns the title needed; the pane's header still names it. Any - * other agent is named, muted — it qualifies the title rather than competing with it. + * Who it is, in at most `room` cells: the agent, muted, in the block's `column` (cut with `…`, padded + * to it), then the title in what is left. Every row names its agent the same way — `general` too: + * hiding it to save width made "Write a long plan" and "explore Explore t…" read as two different + * kinds of thing. With no column, or no room after it, the title alone: it tells two rows apart. */ -const agentRun = (agent: string): Run[] => (agent === "general" ? [] : [{ text: `${agent} `, tone: "muted" }]) +export function nameRuns(agent: string, title: string, room: number, column: number): Run[] { + const space = Math.max(0, room) + const left = space - column - 1 + if (column <= 0 || left < 1) return [{ text: cut(title, space), tone: "text" }] + const name = cut(agent, column) + return [ + { text: `${name}${" ".repeat(column - widthOf(name))} `, tone: "muted" }, + { text: cut(title, left), tone: "text" }, + ] +} -/** Columns a finished row keeps for its agent and title before its round count gives way. */ -const TITLE_ROOM = 16 +/** + * Rows a settings notice may wrap to. Its last words are the fix (`run /cockpit-setup`), and at 30 + * columns a long key name alone takes a row: three rows cut the fix off. + */ +const NOTICE_ROWS = 5 /** When the last of a group's members ended. */ const endedAt = (group: Group): number => Math.max(...group.members.map((s) => s.ended ?? s.started)) @@ -188,8 +233,17 @@ function place(groups: readonly Group[], now: number, fadeAfter: number | undefi export function sidebarLines(input: SidebarInput): SidebarLine[] { const { nodes, width, now, frame } = input - /** Silence is the rule: no subagents, no block. */ - if (nodes.length === 0 || width < 8) return [] + if (width < 8) return [] + const title = "Subagents" + /** Each settings notice once, in the warning tone, wrapped to the column (client/design). */ + const warnings: SidebarLine[] = (input.notices ?? []).flatMap((text) => + warnRows(text, width, NOTICE_ROWS).map((row) => ({ row: fit(row, width) })), + ) + /** Present when empty: the heading and `none yet`, unless asked for silence (and nothing to fix). */ + if (nodes.length === 0) { + const hide = input.hideWhenEmpty === true && warnings.length === 0 + return [...emptyBlock(title, width, hide).map((row) => ({ row: fit(row, width) })), ...warnings] + } /** * Every subagent, faded and grouped ones included: the heading counts sessions, not rows. Every * state there is, not only the worst — `1 failed` sat over six done ones — in the words and order @@ -200,7 +254,6 @@ export function sidebarLines(input: SidebarInput): SidebarLine[] { const state = stateOf(node.session) counts[state] = (counts[state] ?? 0) + 1 } - const title = "Subagents" const summary: Run[] = summaryRuns(counts, width - title.length - 1) const lines: SidebarLine[] = [ { row: spread([{ text: title, ...HEADING }], summary, width) }, @@ -223,6 +276,11 @@ export function sidebarLines(input: SidebarInput): SidebarLine[] { ) for (const entry of [...chosen]) for (let up = entry.parent; up; up = up.parent) chosen.add(up) const visible = placed.filter((entry) => chosen.has(entry)) + /** One agent column for the block, from the agents it shows; a top-level row's room decides it. */ + const column = agentColumn( + visible.map((entry) => entry.group.lead.agent), + width - 2, + ) for (const { group } of visible) { const session = group.lead @@ -243,35 +301,30 @@ export function sidebarLines(input: SidebarInput): SidebarLine[] { /** Said in words: a bare "157 · 34m" read as a puzzle. */ const took = calls > 0 ? `${calls} call${calls === 1 ? "" : "s"} · ${elapsed(since)}` : elapsed(since) const count = group.members.length > 1 ? `×${group.members.length}` : "" - const name: Row = [ - { text: indent }, - stateMark(state, frame), - { text: " " }, - ...agentRun(session.agent), - { text: titleOf(session), tone: "text" }, + const lead: Row = [{ text: indent }, stateMark(state, frame), { text: " " }] + const leadWidth = widthOf(indent) + 2 + const title = titleOf(session) + /** The row's left part: its name in whatever `right` (and the gap before it) leaves. */ + const named = (right: string): Row => [ + ...lead, + ...nameRuns(session.agent, title, width - leadWidth - (right ? widthOf(right) + 1 : 0), column), ] /** - * Finished, and nothing under it still working: one row, its numbers on the right. After more - * than one round its time is the last round's, so it says how many rounds — giving up the number - * of calls first, then the rounds, before the title is left too little room to be read (the - * pane's header says both). + * Finished, and nothing under it still working: one row, how long it ran on the right and the + * name in what that leaves. Only the time: `5 calls · 16s` beside a running row's title cut it to + * twelve cells, and the calls and rounds are in the pane's header and the full screen. */ if (state === "done" && !groupWorking(group)) { - const tail = count ? ` ${count}` : "" - const ladder = - rounds > 1 ? [`${took} · ${rounds} rounds${tail}`, `${elapsed(since)} · ${rounds} rounds${tail}`] : [] - const lead = widthOf(rowText(name.slice(0, 3))) - const numbers = - ladder.find((text) => width - widthOf(text) - 1 - lead >= TITLE_ROOM) ?? `${took}${tail}` - lines.push({ id, row: spread(name, [{ text: numbers, tone: "muted" }], width) }) + const numbers = `${elapsed(since)}${count ? ` ${count}` : ""}` + lines.push({ id, row: spread(named(numbers), [{ text: numbers, tone: "muted" }], width) }) continue } lines.push({ id, - /** The count stays whole at the edge; the task gives way to it. */ - row: count ? spread(name, [{ text: count, tone: "muted" }], width) : fit(name, width), + /** The count stays whole at the edge; the name gives way to it. */ + row: count ? spread(named(count), [{ text: count, tone: "muted" }], width) : fit(named(""), width), }) /** * The line describes one member; when more than one is at it, it says so — first, where the @@ -299,5 +352,5 @@ export function sidebarLines(input: SidebarInput): SidebarLine[] { .filter((entry) => !chosen.has(entry)) .reduce((sum, entry) => sum + entry.group.members.length, 0) if (hidden > 0) lines.push({ row: fit([{ text: ` ${moreText(hidden)}`, tone: "muted" }], width) }) - return lines + return [...lines, ...warnings] } diff --git a/packages/subagents/src/tui/index.tsx b/packages/subagents/src/tui/index.tsx index f62fb57b..9a77db80 100644 --- a/packages/subagents/src/tui/index.tsx +++ b/packages/subagents/src/tui/index.tsx @@ -1,12 +1,22 @@ /** @jsxImportSource @opentui/solid */ +import { defaultKeys } from "@opencode-cockpit/client/catalog" import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client/feature" import { bindingLookup, dualTui, type Host, type Layer, onPaste } from "@opencode-cockpit/client/host" -import { sidebarOrder } from "@opencode-cockpit/client/sidebar" +import { noticeText } from "@opencode-cockpit/client/settings" import type { BoxRenderable } from "@opentui/core" import { createSignal } from "solid-js" +import { loadSubagents, type SubagentsConfig } from "../core/config.ts" import type { Change } from "../core/model/changes.ts" -import { applyAll, emptyModel, type Node, rootOf, type Session, subagentsOf } from "../core/model/model.ts" +import { + applyAll, + emptyModel, + type Node, + rootOf, + type Session, + subagentsOf, + titleOf, +} from "../core/model/model.ts" import { createScreenCache, type Screen, screenRows } from "../core/view/screen.ts" import { type SidebarLine, sidebarLines } from "../core/view/sidebar.ts" import { createRowPool, type RowPool, solidSurface } from "./render.ts" @@ -16,30 +26,10 @@ import { SidebarBlock } from "./view/sidebar.tsx" const SUBAGENTS_PACKAGE = "@opencode-cockpit/subagents" -const DEFAULT_KEYS = { - "cockpit.subagents.open": "w", -} +const DEFAULT_KEYS = defaultKeys("subagents") -export interface SubagentsTuiOptions { - /** Subagents shown in the sidebar before the rest fold into a count. */ - sidebarRows?: number - /** - * Minutes a finished subagent stays in the sidebar; unset keeps it for the conversation. It is only - * out of the sidebar — `/subagents` and the pane's `[` `]` still reach it, and it comes back if it - * works again. - */ - hideFinishedAfter?: number - /** - * Seconds a finished *nested* subagent — one a subagent launched, an advisor it asks again and - * again — stays in the sidebar; 30 unless set, a negative number keeps them. As with - * `hideFinishedAfter` it is only out of the sidebar: the heading still counts it and the pane's - * `[` `]` still reach it. Seconds rather than minutes because these come and go in seconds. - */ - hideNestedAfter?: number - /** Where the block sits among sidebar blocks; lower draws first (Shell 150, statusline 200). */ - sidebarOrder?: number - keybinds?: Record -} +/** The `subagents` section of the config files, then the plugin entry's options (core/config.ts). */ +export type SubagentsTuiOptions = SubagentsConfig /** The pane's state: which subagent, and how it is being looked at. */ interface Surface { @@ -52,6 +42,8 @@ interface Surface { closed: Set thinking: boolean details: boolean + /** `?`: every key, in the body's place. */ + keys?: boolean /** Half the window, or all of it. Remembered. */ full: boolean /** A message being typed, at the foot of the pane. */ @@ -97,7 +89,14 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st return } api.lifecycle.onDispose(() => claim.release()) - const options = (rawOptions ?? {}) as SubagentsTuiOptions + const { config: options, order, notices } = loadSubagents(api.state.path.directory, rawOptions) + for (const notice of notices) log.warn("settings", { file: notice.file, notice: notice.text }) + if (!options.enabled) { + log.info("off in the settings") + return + } + /** Said in the block for the session, one `!` row each, until the file is fixed. */ + const warnings = notices.map(noticeText) const keys = bindingLookup({ ...DEFAULT_KEYS, ...options.keybinds }) const model = emptyModel() @@ -171,7 +170,7 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st // --- painting ---------------------------------------------------------------------------------- /** Milliseconds a finished nested subagent stays in the sidebar; undefined keeps it. */ - const nested = typeof options.hideNestedAfter === "number" ? options.hideNestedAfter : 30 + const nested = options.hideNestedAfterSeconds ?? 30 const fadeAfter = nested >= 0 ? nested * 1000 : undefined /** A finished nested one that has yet to leave: the clock has to keep drawing until it does. */ const fading = () => @@ -187,7 +186,7 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st const now = Date.now() const list = nodes() drawnAt = sidebarWidth() - const after = options.hideFinishedAfter + const after = options.hideFinishedAfterMinutes const listed = typeof after === "number" && after >= 0 ? list.filter( @@ -201,8 +200,10 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st width: drawnAt, now, frame, - limit: options.sidebarRows ?? 6, + limit: options.sidebarRows, ...(fadeAfter !== undefined ? { fadeAfter } : {}), + hideWhenEmpty: options.hideWhenEmpty, + notices: warnings, }) /** Only when they changed: new rows rebuild every line of the block, and a scroll is many paints. */ const said = JSON.stringify(next) @@ -238,6 +239,7 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st closed: surface.closed, thinking: surface.thinking, details: surface.details, + keys: surface.keys === true, whole: surface.whole, yours: (entry) => yours.has(`${session.id}:${entry.text}`), reveal: surface.reveal === true, @@ -359,7 +361,7 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st if (working()) { frame++ draw() - } else if (options.hideFinishedAfter !== undefined || fading()) draw() // finished ones age out with nothing running + } else if (options.hideFinishedAfterMinutes !== undefined || fading()) draw() // finished ones age out with nothing running }, 1000) let fast: ReturnType | undefined @@ -395,7 +397,7 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st notice: undefined, stopping: undefined, draft: undefined, - ...(same ? {} : { top: undefined, selected: undefined, details: false }), + ...(same ? {} : { top: undefined, selected: undefined, details: false, keys: false }), }) takeKeys() fast ??= setInterval(() => { @@ -557,7 +559,7 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st const how = api.v1 ? "task_id" : "sessionID" const text = [ "[Cockpit notification — information, not a request. Nothing to do unless the user asks.]", - `The user messaged your ${session.agent} subagent "${session.title}" (${how} ${id}) directly.`, + `The user messaged your ${session.agent} subagent "${titleOf(session)}" (${how} ${id}) directly.`, `They asked: ${pending.question}`, session.status === "failed" ? `It failed: ${session.error ?? "no reason given"}` @@ -818,6 +820,12 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st run: () => set({ details: !surface.details, top: undefined }), }, { name: "cockpit.subagents.width", title: "Half or full width", run: () => width() }, + { + name: "cockpit.subagents.keys", + title: "Show every key", + /** From the top of the list going in; back where the run was coming out. */ + run: () => set({ keys: !surface.keys, top: surface.keys ? undefined : 0 }), + }, { name: "cockpit.subagents.pageDown", title: "Scroll down", run: () => scroll(10) }, { name: "cockpit.subagents.pageUp", title: "Scroll up", run: () => scroll(-10) }, { @@ -829,8 +837,13 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st { name: "cockpit.subagents.close", title: "Close", - /** Esc first lets go of the cursor, then closes. */ - run: () => (surface.selected ? set({ selected: undefined, top: undefined }) : close()), + /** Esc first hides the keys, then lets go of the cursor, then closes. */ + run: () => + surface.keys + ? set({ keys: false, top: undefined }) + : surface.selected + ? set({ selected: undefined, top: undefined }) + : close(), }, ], bindings: [ @@ -848,6 +861,7 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st { key: "t", cmd: "cockpit.subagents.thinking" }, { key: "i", cmd: "cockpit.subagents.details" }, { key: "w", cmd: "cockpit.subagents.width" }, + { key: "?,shift+/", cmd: "cockpit.subagents.keys" }, { key: "d,pagedown", cmd: "cockpit.subagents.pageDown" }, { key: "u,pageup", cmd: "cockpit.subagents.pageUp" }, { key: "shift+g,end", cmd: "cockpit.subagents.follow" }, @@ -902,8 +916,8 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st }) api.slots.register({ - /** Between the statusline (140) and the shells (170) by default; lower draws first. */ - order: sidebarOrder("subagents", 150, options.sidebarOrder, { directory: api.state.path.directory }), + /** Where the top-level `sidebar` list puts it: under Status and above Shells by default. */ + order, slots: { sidebar_content: () => ( props.api.theme.current + /** + * No rows (`hideWhenEmpty`), no box: an empty box still took the row of space the host puts + * between blocks (seen on OpenCode 1). + */ return ( - props.onReady?.(box)}> - - {(line) => ( - { - if (line.id) props.onOpen(line.id) - }} - > - - - {(run) => { - const style = { - fg: toneColour(theme(), run.tone), - ...(run.fill && run.fill !== "none" ? { bg: fillColour(theme(), run.fill) } : {}), - } - return run.bold ? ( - - {run.text} - - ) : run.faint ? ( - - {run.text} - - ) : ( - {run.text} - ) - }} - - - - )} - - + 0}> + props.onReady?.(box)}> + + {(line) => ( + { + if (line.id) props.onOpen(line.id) + }} + > + + + {(run) => { + const style = { + fg: toneColour(theme(), run.tone), + ...(run.fill && run.fill !== "none" ? { bg: fillColour(theme(), run.fill) } : {}), + } + return run.bold ? ( + + {run.text} + + ) : run.faint ? ( + + {run.text} + + ) : ( + {run.text} + ) + }} + + + + )} + + + ) } diff --git a/packages/subagents/test/agent.test.ts b/packages/subagents/test/agent.test.ts index 4b90bcf7..13f9cd02 100644 --- a/packages/subagents/test/agent.test.ts +++ b/packages/subagents/test/agent.test.ts @@ -186,6 +186,14 @@ describe("the guidance names only the options the tool has (load test #1)", () = expect(text).toContain("task_id") }) + test("its own tools are named as each OpenCode calls them: tools.x in OpenCode 2's Code Mode", () => { + const v2 = subagentsGuidance({ version: 2, background: true }) + expect(v2).toContain("call tools.subagents_wait") + expect(v2).toContain("tools.subagents_list gives") + expect(v2).toContain("read it with tools.subagents_read") + expect(subagentsGuidance({ version: 1, background: true })).not.toContain("tools.") + }) + test("with it, background is a boolean, and subagents_wait is the way to block", () => { const text = subagentsGuidance({ version: 2, background: true }) expect(text).toContain("background: true, a boolean") diff --git a/packages/subagents/test/settings.test.ts b/packages/subagents/test/settings.test.ts new file mode 100644 index 00000000..022437ca --- /dev/null +++ b/packages/subagents/test/settings.test.ts @@ -0,0 +1,178 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { loadSubagents } from "../src/core/config.ts" +import { applyAll, emptyModel, firstWords, subagentsOf, titleOf } from "../src/core/model/model.ts" +import { SAMPLE_NOW, SAMPLE_ROOT, sample } from "../src/core/sample.ts" +import { rowText, widthOf } from "../src/core/view/rows.ts" +import { SUBAGENT_KEYS, screenRows } from "../src/core/view/screen.ts" + +/** + * Subagents reads the files now, both halves, through the loader every bay shares: the `subagents` + * section, then the plugin entry. Before 0.9 it read no file, and its time keys carried no unit. + */ + +const dirs: string[] = [] +const temp = () => { + const dir = mkdtempSync("/tmp/ck-sub-") + dirs.push(dir) + return dir +} +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + +/** A global file and a project file, as written; the env points the loader at the global one. */ +function files(global: string | object | undefined, project?: string | object) { + const home = temp() + const directory = temp() + const text = (value: string | object) => (typeof value === "string" ? value : JSON.stringify(value)) + if (global !== undefined) { + mkdirSync(join(home, "opencode-cockpit"), { recursive: true }) + writeFileSync(join(home, "opencode-cockpit", "config.json"), text(global)) + } + if (project !== undefined) writeFileSync(join(directory, ".cockpit.json"), text(project)) + return { directory, env: { XDG_CONFIG_HOME: home } } +} + +describe("settings", () => { + test("the `subagents` section of either file, and the entry's options over both", () => { + const { directory, env } = files( + `{ // comments are fine + "subagents": { "sidebarRows": 3, "hideNestedAfterSeconds": 10, "guidance": false, }, + }`, + { subagents: { sidebarRows: 4, hideWhenEmpty: true } }, + ) + const { config, notices } = loadSubagents(directory, { hideFinishedAfterMinutes: 15 }, env) + expect(config.sidebarRows).toBe(4) + expect(config.hideWhenEmpty).toBe(true) + expect(config.hideNestedAfterSeconds).toBe(10) + expect(config.hideFinishedAfterMinutes).toBe(15) + expect(config.guidance).toBe(false) + expect(notices).toEqual([]) + }) + + test("defaults: six rows, present when empty, nested ones leave after 30 s, finished ones stay", () => { + const { directory, env } = files(undefined) + const { config } = loadSubagents(directory, undefined, env) + expect(config).toMatchObject({ + sidebarRows: 6, + hideWhenEmpty: false, + hideNestedAfterSeconds: 30, + guidance: true, + }) + expect(config.hideFinishedAfterMinutes).toBeUndefined() + expect(config.enabled).toBe(true) + }) + + test("old names are not read: each is a notice naming the new one", () => { + const { directory, env } = files({ subagents: { hideFinishedAfter: 5, sidebarOrder: 2 } }) + const { config, notices } = loadSubagents(directory, { hideNestedAfter: 9 }, env) + expect(config.hideFinishedAfterMinutes).toBeUndefined() + expect(config.hideNestedAfterSeconds).toBe(30) + expect(notices.map((notice) => [notice.old, notice.new])).toEqual( + expect.arrayContaining([ + ["subagents.hideFinishedAfter", "subagents.hideFinishedAfterMinutes"], + ["subagents.sidebarOrder", "sidebar"], + ["hideNestedAfter", "subagents.hideNestedAfterSeconds"], + ]), + ) + }) + + test("a value of the wrong kind is the default, with a notice; the order is the `sidebar` list's", () => { + const { directory, env } = files({ + sidebar: ["shell", "subagents"], + subagents: { sidebarRows: "8", hideFinishedAfterMinutes: "ten" }, + }) + const { config, order, notices } = loadSubagents(directory, undefined, env) + expect(config.sidebarRows).toBe(6) + expect(config.hideFinishedAfterMinutes).toBeUndefined() + expect(notices.some((notice) => notice.old === "subagents.sidebarRows")).toBe(true) + /** Second in the list: 110, then 120 (client/settings `orderOf`). */ + expect(order).toBe(120) + }) + + test("`features.subagents: false` in a file turns both halves off", () => { + const { directory, env } = files({ features: { subagents: false } }) + expect(loadSubagents(directory, undefined, env).config.enabled).toBe(false) + }) +}) + +describe("a name, always", () => { + const session = (title: string, task?: string) => { + const m = applyAll(emptyModel(), [ + { type: "session", id: "c", parentID: "p", agent: "general", title, at: 1 }, + ...(task ? [{ type: "prompt" as const, id: "c", key: "u", text: task, at: 1 }] : []), + ]) + return subagentsOf(m, "p")[0]?.session as NonNullable[number]>["session"] + } + + test("its title; with none, or only OpenCode's placeholder, its task's first words", () => { + expect(titleOf(session("Map the auth flow", "Read every file"))).toBe("Map the auth flow") + expect(titleOf(session("", "\n Find every caller of exportCsv\nthen list them"))).toBe( + "Find every caller of exportCsv", + ) + expect(titleOf(session("Child session - 2026-10-03T10:00:00.000Z", "Check the tests"))).toBe( + "Check the tests", + ) + expect(titleOf(session(" ", undefined))).toBe("subagent") + }) + + test("first words are a name, not the task", () => { + expect(firstWords("one two three four five six seven eight nine ten")).toBe( + "one two three four five six seven eight…", + ) + expect(firstWords(undefined)).toBe("") + }) +}) + +describe("[?] Keys", () => { + const nodes = subagentsOf(applyAll(emptyModel(), sample()), SAMPLE_ROOT) + const session = nodes[0]?.session + if (!session) throw new Error("the sample has no subagent") + const input = (width: number, height: number, keys: boolean) => ({ + session, + nodes, + width, + height, + now: SAMPLE_NOW, + frame: 0, + open: new Set(), + closed: new Set(), + thinking: false, + details: false, + keys, + }) + + test("the footer offers it, and on it the only key is the way back", () => { + const run = screenRows(input(120, 40, false)) + .rows.map(rowText) + .join("\n") + expect(run).toContain("[?] Keys") + const keys = screenRows(input(120, 40, true)) + const text = keys.rows.map(rowText).join("\n") + expect(text).toContain("KEYS") + for (const line of SUBAGENT_KEYS) expect(text).toContain(line.does.split(" ").slice(0, 3).join(" ")) + expect(text).toContain("[esc] Hide Keys") + expect(text).not.toContain("[m] Message") + /** Nothing on it is an item: j/k and enter have nothing to land on. */ + expect(keys.keys).toEqual([]) + }) + + test("every row its width, every screen its height; cut short, it says how much is below", () => { + for (const [width, height] of [ + [72, 16], + [72, 40], + [140, 40], + ] as const) { + const screen = screenRows(input(width, height, true)) + expect(screen.rows).toHaveLength(height) + for (const row of screen.rows) expect(widthOf(rowText(row))).toBe(width) + } + expect( + screenRows(input(72, 16, true)) + .rows.map(rowText) + .join("\n"), + ).toMatch(/↓ \d+ more lines — \[d\] scrolls/) + }) +}) diff --git a/packages/subagents/test/sidebar.test.ts b/packages/subagents/test/sidebar.test.ts index 0f6f9ede..80a0ff40 100644 --- a/packages/subagents/test/sidebar.test.ts +++ b/packages/subagents/test/sidebar.test.ts @@ -10,13 +10,15 @@ import { finishedSample, LATE_ROOT, lateSample, + NAMES_ROOT, + namesSample, SAMPLE_NOW, SAMPLE_ROOT, sample, } from "../src/core/sample.ts" -import { rowText } from "../src/core/view/rows.ts" +import { rowText, widthOf } from "../src/core/view/rows.ts" import { rowWidth } from "../src/core/view/screen.ts" -import { type SidebarInput, sidebarLines } from "../src/core/view/sidebar.ts" +import { agentColumn, nameRuns, type SidebarInput, sidebarLines } from "../src/core/view/sidebar.ts" /** * The sidebar's order and its nesting: working first, children under their parent, an advisor asked @@ -113,7 +115,7 @@ describe("an advisor asked again and again", () => { /** The newest one still at it is the one described, and the one a click opens. */ expect(advisor?.lead.id).toBe("ses_advisor6") const lines = sidebarLines({ nodes, width: 40, now: SAMPLE_NOW, frame: 0 }) - const row = lines.findIndex((line) => rowText(line.row).includes("advisor")) + const row = lines.findIndex((line) => rowText(line.row).includes("advisor ")) expect(rowText(lines[row]?.row ?? []).trimEnd()).toMatch(/advisor Review the migration.* ×6$/) expect(rowText(lines[row + 1]?.row ?? [])).toContain("â”” 2 running · read") expect(lines[row]?.id).toBe("ses_advisor6") @@ -342,7 +344,9 @@ describe("everything ended (the screenshots)", () => { test("a finished subagent is one row; one you stopped keeps its row that says so", () => { const lines = text(input(60)) expect(lines.some((line) => line.includes("â”” done"))).toBe(false) - expect(lines.find((line) => line.includes("Prod DB forensics"))).toMatch(/94 calls · 13m23s$/) + expect(lines.find((line) => line.includes("Prod DB forensics"))).toMatch( + /Prod DB forensics COM-1736 +13m23s$/, + ) const at = lines.findIndex((line) => line.includes("ContractDetails")) expect(lines[at + 1]).toMatch(/â”” stopped +8 calls · 24s$/) }) @@ -354,10 +358,142 @@ describe("everything ended (the screenshots)", () => { } }) - test("`general` is not named on every row; any other agent is, quietly", () => { - const lines = sidebarLines(input(60)) - expect(lines.some((line) => rowText(line.row).includes("general"))).toBe(false) - const advisor = lines.find((line) => rowText(line.row).includes("orchestrator")) - expect(advisor?.row.find((run) => run.text.startsWith("orchestrator"))?.tone).toBe("muted") + /** + * `general` used to be left out to save width, so a row with an agent and one without read as two + * kinds of thing. Every row names its agent now, the same way: muted, before the title. + */ + test("every row names its agent, muted, before its title — `general` too", () => { + const lines = sidebarLines(input(80)) + const entries = lines.filter((line) => line.id && !rowText(line.row).includes("â””")) + expect(entries.length).toBeGreaterThan(0) + for (const line of entries) { + const agent = line.row.find((run) => /^(general|orchest…) +$/.test(run.text)) + expect(agent?.tone).toBe("muted") + const at = line.row.indexOf(agent as (typeof line.row)[number]) + expect(line.row[at + 1]?.tone).toBe("text") + } + }) +}) + +describe("names", () => { + const nodes = subagentsOf(applyAll(emptyModel(), namesSample()), NAMES_ROOT) + const rows = (width: number) => + sidebarLines({ nodes, width, now: SAMPLE_NOW, frame: 0 }).map((line) => rowText(line.row)) + + test("the agent fills the block's column: cut with `…` past it, padded short of it", () => { + expect(nameRuns("general", "Write a plan", 40, 5).map((run) => run.text)).toEqual([ + "gene… ", + "Write a plan", + ]) + expect(nameRuns("qa", "Write a plan", 40, 5).map((run) => run.text)).toEqual(["qa ", "Write a plan"]) + expect(nameRuns("build", "Write a plan", 40, 5).map((run) => run.text)).toEqual([ + "build ", + "Write a plan", + ]) + /** Short of room, the title is cut; the column never moves. */ + expect(nameRuns("general", "Write a plan", 12, 5).map((run) => run.text)).toEqual(["gene… ", "Write…"]) + /** No column, or no room after it: the title, which tells two rows apart. */ + expect(nameRuns("general", "Write a plan", 40, 0).map((run) => run.text)).toEqual(["Write a plan"]) + expect(nameRuns("general", "Write a plan", 3, 5).map((run) => run.text)).toEqual(["Wr…"]) + for (const room of [0, 1, 3, 6, 12, 16, 40]) + expect( + widthOf(rowText(nameRuns("security-reviewer", "Review the export", room, 5))), + ).toBeLessThanOrEqual(room) + }) + + test("the column is the longest agent shown, at most eight cells, and none when titles would starve", () => { + expect(agentColumn(["qa", "plan"], 34)).toBe(4) + expect(agentColumn(["qa", "explore"], 34)).toBe(7) + expect(agentColumn(["general", "security-reviewer"], 34)).toBe(8) + expect(agentColumn(["explore"], 14)).toBe(7) + expect(agentColumn(["explore"], 13)).toBe(0) + }) + + /** Live at 36 columns, one title began at column 9 and the next at column 7. */ + test("every top-level title starts in the same column, at every width", () => { + /** Each block's column: `security-reviewer` and `orchestrator` cut to eight, `general` whole. */ + const fixtures = [ + [subagentsOf(applyAll(emptyModel(), namesSample()), NAMES_ROOT), 11], + [subagentsOf(applyAll(emptyModel(), finishedSample()), FINISHED_ROOT), 11], + [subagentsOf(applyAll(emptyModel(), advisorSample()), ADVISOR_ROOT), 10], + ] as const + for (const [fixtureNodes, column] of fixtures) { + for (const width of [24, 30, 36, 50, 80]) { + const starts = sidebarLines({ nodes: fixtureNodes, width, now: SAMPLE_NOW, frame: 0 }) + .filter((line) => line.id && !rowText(line.row).includes("â””") && !rowText(line.row).startsWith(" ")) + .map((line) => { + const at = line.row.findIndex((run) => run.tone === "text") + return widthOf(rowText(line.row.slice(0, at))) + }) + expect(starts.length).toBeGreaterThan(1) + expect(new Set(starts).size).toBe(1) + expect(starts[0]).toBe(column) + } + } + }) + + test("a finished row says only how long it ran; the calls are the pane's", () => { + for (const width of [24, 30, 36, 50]) { + for (const row of rows(width)) expect(widthOf(row)).toBe(width) + const plan = rows(width).find((row) => row.includes("Write")) + expect(plan).toMatch(/ 2m15s$/) + expect(plan).not.toContain("call") + } + expect(rows(36).find((row) => row.includes("Write"))).toBe("â—� general Write a long plan … 2m15s") + /** A running row keeps its calls, on its second row. */ + expect(rows(36).join("\n")).toContain("4 calls · 4m50s") + }) + + test("a subagent with no title is named by its task's first words, never by nothing", () => { + const bare = rows(50).find((row) => row.includes("Find every")) + expect(bare).toContain("general Find every caller of exportCsv") + /** OpenCode's placeholder is no title either. */ + const auto = rows(80).find((row) => row.includes("Check the export")) + expect(auto).toContain("explore Check the export's tests for flakiness across the…") + expect(rows(80).join("\n")).not.toContain("Child session") + }) +}) + +describe("present when empty", () => { + const NOTICE = 'settings: "subagents.hideFinishedAfter" is no longer read — run /cockpit-setup' + const empty = (extra: Partial = {}) => + sidebarLines({ nodes: [], width: 30, now: SAMPLE_NOW, frame: 0, ...extra }).map((line) => + rowText(line.row), + ) + + test("no subagents: the heading, and `none yet` in the row the first one will take", () => { + const rows = empty() + expect(rows.map((row) => row.trimEnd())).toEqual(["Subagents", "", "none yet"]) + for (const row of rows) expect(widthOf(row)).toBe(30) + const lines = sidebarLines({ nodes: [], width: 30, now: SAMPLE_NOW, frame: 0 }) + expect(lines.at(-1)?.row[0]?.tone).toBe("muted") + expect(lines.every((line) => line.id === undefined)).toBe(true) + }) + + test("one finished subagent takes the `none yet` row: the block is as tall as it was", () => { + const m = applyAll(emptyModel(), sub("a", "p", 1, 2, "Only one")) + const one = sidebarLines({ nodes: subagentsOf(m, "p"), width: 30, now: SAMPLE_NOW, frame: 0 }) + expect(one).toHaveLength(empty().length) + expect(rowText(one[2]?.row ?? [])).toContain("Only one") + }) + + test("`hideWhenEmpty` draws nothing — unless a setting needs fixing", () => { + expect(empty({ hideWhenEmpty: true })).toEqual([]) + const rows = empty({ hideWhenEmpty: true, notices: [NOTICE] }) + expect(rows[0]?.trimEnd()).toBe("Subagents") + expect(rows.join(" ")).toContain("/cockpit-setup") + }) + + test("a settings notice is said in the block, wrapped, its fix kept, at every width", () => { + const nodes = subagentsOf(applyAll(emptyModel(), sample()), SAMPLE_ROOT) + for (const width of [24, 30, 36, 50]) { + const rows = sidebarLines({ nodes, width, now: SAMPLE_NOW, frame: 0, notices: [NOTICE] }).map((line) => + rowText(line.row), + ) + for (const row of rows) expect(widthOf(row)).toBe(width) + const at = rows.findIndex((row) => row.startsWith("! settings")) + expect(at).toBeGreaterThan(2) + expect(rows.slice(at).join(" ")).toContain("/cockpit-setup") + } }) }) diff --git a/packages/subagents/test/truth.test.ts b/packages/subagents/test/truth.test.ts index fa499912..07c20c6b 100644 --- a/packages/subagents/test/truth.test.ts +++ b/packages/subagents/test/truth.test.ts @@ -142,14 +142,17 @@ describe("the tools say what the interface draws", () => { } }) - test("the sidebar's row carries the same title, calls and duration", async () => { + test("the sidebar's row carries the same title and duration, and a working row its calls", async () => { const ui = applyAll(emptyModel(), states()) const lines = sidebarLines({ nodes: subagentsOf(ui, "root"), width: 80, now: NOW, frame: 0, limit: 20 }) const text = lines.map((line) => rowText(line.row)).join("\n") const answered = ui.sessions.get("a") if (!answered) throw new Error("a") expect(text).toContain(titleOf(answered)) - expect(text).toContain(`1 call · ${elapsed(60_000)}`) + expect(lines.map((line) => rowText(line.row)).find((row) => row.includes(titleOf(answered)))).toMatch( + new RegExp(` ${elapsed(60_000)}\\s*$`), + ) + expect(text).toContain(`1 call · ${elapsed(600_000)}`) expect(runPhrase(answered, NOW)).toBe(`done in ${elapsed(60_000)}`) }) }) @@ -206,16 +209,15 @@ describe("a subagent continued the next day (two rounds, a day apart)", () => { for (const text of [header, list, read, waited]) expect(text).not.toContain("24h") }) - test("the sidebar says the same time, and how many rounds while there is room", () => { + /** Finished, the sidebar says only how long it ran; the rounds are the pane header's (above). */ + test("the sidebar says the same time: the last round's", () => { const row = (width: number) => sidebarLines({ nodes, width, now: SAMPLE_NOW, frame: 0 }) .map((line) => rowText(line.row)) .find((text) => text.includes("Review")) ?.trimEnd() - expect(row(60)).toEndWith("17 calls · 4m00s · 2 rounds") - /** Narrower, the calls give way to the rounds, then the rounds to the title. */ - expect(row(40)).toEndWith("â—� Review the export qu… 4m00s · 2 rounds") - expect(row(30)).toEndWith("17 calls · 4m00s") + expect(row(60)).toMatch(/^â—� general Review the export query +4m00s$/) + expect(row(30)).toBe("â—� general Review the ex… 4m00s") for (const width of [30, 40, 60]) expect(row(width)).not.toContain("24h") }) diff --git a/packages/subagents/test/view.test.ts b/packages/subagents/test/view.test.ts index fa031c4e..42d8e4c6 100644 --- a/packages/subagents/test/view.test.ts +++ b/packages/subagents/test/view.test.ts @@ -61,7 +61,7 @@ for (const version of [1, 2] as const) { const id = nodes[0]?.session.id expect(lines.map((line) => line.id)).toEqual([undefined, undefined, id]) expect(rowText(lines[0]?.row ?? []).trimEnd()).toMatch(/^Subagents +1 done$/) - expect(rowText(lines[2]?.row ?? []).trimEnd()).toMatch(/^â—� explore .+ \d+ calls · \d+s$/) + expect(rowText(lines[2]?.row ?? []).trimEnd()).toMatch(/^â—� explore .+ \d+s$/) }) test("the pane: exactly its height, every row exactly its width, in every state", async () => { diff --git a/packages/trail/LICENSE b/packages/trail/LICENSE new file mode 100644 index 00000000..e48d0fa8 --- /dev/null +++ b/packages/trail/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Codestz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/trail/README.md b/packages/trail/README.md new file mode 100644 index 00000000..afe55fff --- /dev/null +++ b/packages/trail/README.md @@ -0,0 +1,131 @@ +# @opencode-cockpit/trail + +**What a conversation made.** The pull requests, tickets, pages and deploys your agent created or +changed — kept per conversation, grouped by the ticket they were for, and one click from the page. +And the other way round: which conversation opened PR #33, and a jump back into it. + +Part of [opencode-cockpit](https://github.com/Codestz/opencode-cockpit). Install it on its own, or +through the bundle, where it is on by default (`features.trail: false` turns it off). Works on +OpenCode 1.18+ and 2.0.15+. + +```sh +opencode plugin @opencode-cockpit/trail@0.9.0 --global --force # OpenCode 1 +opencode plugin add @opencode-cockpit/trail@0.9.0 # OpenCode 2 +``` + +**Setup: none.** No account, no token, no list of tools to configure. The agent already knows what +it just did, with whatever it uses — `gh`, an MCP server, a company CLI — so the agent writes the +trail, and Trail keeps it. It has no GitHub or Jira client and stores no credentials. + +## How things get into the trail + +- **The agent records them** with `trail_add`, right after it creates or changes something outside + the repository's files. It is told so in its system prompt on every request, subagents included. +- **A safety net, never automatic.** When the output of something the agent *ran* — a shell command, + an MCP call — holds a PR or issue link it has not recorded, its next request says so, as a choice: + *seen in output — record it if you created or changed it*. Links in files it read or pages it + fetched are ignored. Nothing is ever added without the agent or you. +- **You add one** with `/link` (the palette's "Add a link to this conversation's trail"), then + paste the link, and a note if you like. + +The system comes from the link, not from a list: github.com is GitHub, `…atlassian.net/browse` is +Jira, `…/wiki` Confluence, claude.ai Claude, linear.app Linear, anything else its domain. Query +parameters that look like secrets (`token`, `sig`, signed-URL parameters…) are dropped before +anything is stored, and only `http(s)` links are ever opened. + +## In the sidebar + +On by default, after Shells: + +``` +Trail 9 + +COM-1801 + a1b2c3d Bump the prot… 12m ago + ENG-42 Retry the soc… 15m ago ↗ +COM-1736 Bundle desync 2h ago ↗ + PR #33 0.8: Trust, o… 1h ago ↗ + PR #12 Landing: Trus… 2h ago ↗ ++ 4 more · /trail +``` + +What this conversation made, grouped by what it was for, newest work first. A row is the thing's +ref (a record without one gives its title that column), its title, its system, what this +conversation last did and when (`now`, `12m ago`; a narrow sidebar drops the system first, then the +"ago") — history, not a status: a +PR's state belongs to GitHub, and a trail that said "open" for a merged PR would be worse than none. +**Click a row with `↗` to open the page**; one without a page opens `/trail` on it; `+ N more` opens +`/trail`. Empty, the block says `none yet`. + +## `/trail` + +`/trail`, `ctrl+x f` or the palette: **This conversation** and **All conversations** in this project +(`tab`), grouped the same way, with every conversation that touched a thing listed under it. + +| key | | +| --- | --- | +| `enter` | open the page | +| `g` | go to the conversation that made it (its root, naming the subagent that did) | +| `c` | copy the link | +| `x` | remove it from the trail | +| `m` | copy the trail as a markdown list — for a PR description, a standup, a ticket | +| `/` | search title, ref, kind and system (`jira`, `COM-1736`) | +| `esc` | close | + +A conversation since deleted keeps its records, marked as such, under the title it had. + +## The agent's tools + +| tool | | +| --- | --- | +| `trail_add` | `title`, and `url` or `ref`; optional `kind`, `action`, `for`, `note`, all free text. The same link again updates the record — its actions become a history (`created → updated`) — never a second row. | +| `trail_list` | This conversation's trail, or with `all` every conversation in the project and which one made each; `query` filters. The same facts and order as `/trail`. | + +What the conversation produced is rebuilt into its system prompt on every request from the trail +itself, so the agent still knows after the conversation is compacted. + +## Settings + +In `~/.config/opencode-cockpit/config.json`, or a project's `.cockpit.json`: + +```jsonc +{ + "trail": { + "enabled": true, // the off switch; features.trail: false works too + "sidebar": true, // draw the block + "sidebarRows": 5, // records before "+ N more" + "hideWhenEmpty": false, // no block at all until there is something to show + "keybinds": { "cockpit.trail.open": "f" } + } +} +``` + +Where the block sits is the top-level `"sidebar"` list's to say +(`["status", "subagents", "shell", "trail", "trust"]` by default). A setting Trail cannot use is +drawn in the block as a `!` row with what to do. + +## Team conventions (optional) + +Trail works with nothing in your repository. If your team names things a certain way, a few lines in +`AGENTS.md` shape the records — they do not make them happen. `/cockpit-setup`'s second phase offers +your ticket prefix, read from your branch names and commits, and writes it there for you; or by hand: + +```md +## Trail +- Tickets are Jira keys like COM-1234: pass the PR's ticket as `for`. +- Put the Confluence space in a page's title: "WEB · Release notes 0.8". +- Record deploys with kind "deploy" and the environment in the title. +``` + +## Where it keeps things + +One append-only file per project, outside it: +`~/.local/share/opencode-cockpit/trail/-/events.ndjson` (`$XDG_DATA_HOME`, or +`$COCKPIT_HOME`). Every window and the agent append to it; nothing is ever rewritten. + +## See it without OpenCode + +```sh +bunx @opencode-cockpit/trail preview # the sidebar and /trail, from sample trails +bunx @opencode-cockpit/trail preview --text # and what the agent reads +``` diff --git a/packages/trail/measure/agent.ts b/packages/trail/measure/agent.ts new file mode 100644 index 00000000..2a139a98 --- /dev/null +++ b/packages/trail/measure/agent.ts @@ -0,0 +1,240 @@ +#!/usr/bin/env bun +/** + * Trail's fourth layer, the measurement (docs/roadmap/v0.9/trail.md, "Making sure the agent + * records"): a real agent turn opens a pull request with a fake `gh` that prints its link, and the + * turn must end with the agent having called `trail_add` for it — without the prompt saying a word + * about the trail. Only the guidance, the tool's description and the "seen in output" line can make + * it happen, so a wording change that stops it working cannot ship silently. + * + * bun packages/trail/measure/agent.ts OpenCode on PATH, this checkout + * OPENCODE=~/.opencode/bin/opencodeold bun packages/trail/measure/agent.ts + * bun packages/trail/measure/agent.ts --plugin --runs 3 --keep + * bun packages/trail/measure/agent.ts --runs 3 --pass 2 two of three is a pass + * + * `--plugin` is the server half to load: a package directory (an install's + * `node_modules/@opencode-cockpit/trail`, or the bundle's) — by default this checkout's + * `packages/trail`, built (`bun run build`). Everything runs in a folder of its own: OpenCode's + * config, state, data and cache, Cockpit's home, the project (a git repository on a branch). Needs + * the network and a free OpenCode Zen model, so it stays out of CI — like `AGENT=1 smoke:tui`. + * + * Exits 0 when at least `--pass` runs recorded the PR (every run, unset), 1 otherwise, and prints + * what each run did. A free model misses about one turn in six, so the smoke asks two of three. + */ + +import { mkdtempSync, realpathSync, rmSync } from "node:fs" +import { join, resolve } from "node:path" +import { trailPaths } from "../src/core/paths.ts" +import { parseLines } from "../src/core/store.ts" + +const args = process.argv.slice(2) +const value = (flag: string) => { + const at = args.indexOf(flag) + return at >= 0 ? args[at + 1] : undefined +} +const opencode = process.env.OPENCODE ?? Bun.which("opencode") +if (!opencode) { + console.error("opencode binary not found: set OPENCODE") + process.exit(2) +} +const plugin = resolve(value("--plugin") ?? join(import.meta.dir, "..")) +const runs = Number(value("--runs")) || 1 +/** Runs that must record the PR; every run when unset, and never more than there are. */ +const pass = Math.min(runs, Number(value("--pass")) || runs) +const model = value("--model") ?? "opencode/space-bunny-free" +const keep = args.includes("--keep") +const PR = "https://github.com/acme/web/pull/417" +/** + * Nothing about the trail: the agent has to get there on its own. Direct about the PR, because what + * is measured is what happens *after* a link is printed — a careful model that checks the branch + * first, or asks before opening anything, measures its caution, not Trail. + */ +export const PROMPT = + "Open the pull request for this branch now: run `gh pr create --fill` straight away (a private repository; I reviewed the change, the branch is pushed and gh is logged in — nothing needs checking first), then reply with the PR's link." + +const version = Bun.spawnSync([opencode, "--version"]).stdout.toString().trim() +const v2 = version.replace(/^opencode\s+v?/, "").startsWith("2") + +const run = (cmd: string[], cwd: string) => { + const result = Bun.spawnSync(cmd, { cwd, stdout: "pipe", stderr: "pipe" }) + if (result.exitCode !== 0) throw new Error(`$ ${cmd.join(" ")}\n${result.stderr}`) +} + +interface Outcome { + ok: boolean + ghRan: boolean + /** `trail_add` calls that completed, with what was sent. */ + calls: unknown[] + recorded: boolean + said: string +} + +/** Every tool call of a `--format json` run, Code Mode's inner calls included (v2 lists them on `execute`). */ +function toolCalls(stdout: string): { tool: string; status?: string; input?: unknown }[] { + const out: { tool: string; status?: string; input?: unknown }[] = [] + for (const line of stdout.split("\n")) { + if (!line.startsWith("{")) continue + const event = JSON.parse(line) as { type?: string; part?: Record } + if (event.type !== "tool_use" || !event.part) continue + const part = event.part as { + tool: string + state: { + status?: string + input?: unknown + metadata?: { toolCalls?: unknown[]; metadata?: { toolCalls?: unknown[] } } + } + } + out.push({ tool: part.tool, status: part.state.status, input: part.state.input }) + const inner = part.state.metadata?.toolCalls ?? part.state.metadata?.metadata?.toolCalls ?? [] + for (const call of inner as { tool: string; status?: string; input?: unknown }[]) out.push(call) + } + return out +} + +async function once(index: number): Promise { + const work = mkdtempSync(`/tmp/acme-web-${index}-`) + try { + const project = join(work, "project") + const log = join(work, "gh.log") + /** + * A branch with one commit, pushed — as far as git can tell without a network: the remote-tracking + * refs are written by hand. A careful model checks before it opens a PR, and stops at an unpushed + * branch or a missing remote. + */ + await Bun.write(join(project, "README.md"), "# web\n") + const branch = "feat/checkout-retry" + for (const cmd of [ + ["git", "init", "-q", "-b", "main"], + ["git", "config", "user.email", "measure@example.com"], + ["git", "config", "user.name", "Measure"], + ["git", "add", "-A"], + ["git", "commit", "-qm", "init"], + ["git", "remote", "add", "origin", "https://github.com/acme/web.git"], + ["git", "update-ref", "refs/remotes/origin/main", "HEAD"], + ["git", "checkout", "-qb", branch], + ]) + run(cmd, project) + /** A change that matches its commit message: a model that finds a one-liner under a big message stops to ask. */ + await Bun.write( + join(project, "checkout.ts"), + [ + "/** Retry the checkout request after a dropped connection, up to three times, backing off. */", + "export async function checkout(send: () => Promise, retries = 3): Promise {", + " for (let attempt = 0; ; attempt++) {", + " try {", + " return await send()", + " } catch (error) {", + " if (attempt >= retries) throw error", + " await new Promise((done) => setTimeout(done, 200 * 2 ** attempt))", + " }", + " }", + "}", + "", + ].join("\n"), + ) + for (const cmd of [ + ["git", "add", "-A"], + ["git", "commit", "-qm", "Retry the checkout request after a dropped connection"], + ["git", "update-ref", `refs/remotes/origin/${branch}`, "HEAD"], + ["git", "config", `branch.${branch}.remote`, "origin"], + ["git", "config", `branch.${branch}.merge`, `refs/heads/${branch}`], + ]) + run(cmd, project) + /** + * The fake `gh`, copied without its comments: a model that looks at what `gh` is (some do, + * `which gh` and `cat`) and finds a script calling itself fake stops and says so. + */ + const bin = join(work, "bin") + const shim = await Bun.file(join(import.meta.dir, "bin", "gh")).text() + await Bun.write( + join(bin, "gh"), + shim + .split("\n") + .filter((line, at) => at === 0 || !line.startsWith("#")) + .join("\n"), + ) + run(["chmod", "+x", join(bin, "gh")], work) + await Bun.write( + join(work, "config", "opencode", "opencode.json"), + JSON.stringify({ [v2 ? "plugins" : "plugin"]: [plugin], model }), + ) + const env = { + ...process.env, + XDG_CONFIG_HOME: join(work, "config"), + XDG_STATE_HOME: join(work, "state"), + XDG_DATA_HOME: join(work, "data"), + XDG_CACHE_HOME: join(work, "cache"), + COCKPIT_HOME: join(work, "cockpit"), + TRAIL_MEASURE_LOG: log, + TRAIL_MEASURE_PR: PR, + PATH: `${bin}:${process.env.PATH ?? ""}`, + /** v2 places the session in $PWD's directory, not the spawn's cwd (trail-interface.md). */ + PWD: project, + } + const cmd = [opencode as string, "run", ...(v2 ? ["--standalone", "--auto"] : []), "-m", model] + /** stdin must not be an open pipe, or `opencode run` waits forever (trail-server.md). */ + const result = Bun.spawnSync([...cmd, "--format", "json", PROMPT], { + cwd: project, + env, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + timeout: 300_000, + }) + const stdout = result.stdout.toString() + const calls = toolCalls(stdout).filter((call) => call.tool === "trail_add" && call.status === "completed") + const said = stdout + .split("\n") + .filter((line) => line.startsWith('{"type":"text"')) + .map((line) => (JSON.parse(line) as { part: { text: string } }).part.text) + .join("\n") + /** OpenCode names the project by its real path: on macOS /tmp is /private/tmp. */ + const file = Bun.file(trailPaths(realpathSync(project), { COCKPIT_HOME: join(work, "cockpit") }).events) + const events = (await file.exists()) ? parseLines(await file.text()).events : [] + const recorded = events.some((event) => event.type === "recorded" && event.url === PR) + const ghRan = (await Bun.file(log).exists()) && (await Bun.file(log).text()).includes("pr create") + if (process.env.MEASURE_DEBUG) console.log(stdout.slice(-4000), result.stderr.toString().slice(-2000)) + return { + ok: ghRan && calls.length > 0 && recorded, + ghRan, + calls: calls.map((c) => c.input), + recorded, + said, + } + } finally { + if (!keep) rmSync(work, { recursive: true, force: true }) + else console.log(`kept ${work}`) + } +} + +console.log( + `Trail measurement: ${version} (${opencode}), plugin ${plugin}, ${model}, ${runs} run(s), ${pass} to pass`, +) +let passed = 0 +let ran = 0 +/** + * A turn in which the model declined to open the PR at all measures nothing about Trail — a free + * model on OpenCode 2 refused `gh pr create` in 2 of 4 runs as "public-facing". Such a run is tried + * again, up to `ATTEMPTS` times; only a turn that opened the PR and then did not record it fails. + */ +const ATTEMPTS = 3 +for (let i = 1; i <= runs; i++) { + /** Enough have passed: the rest would measure nothing more. */ + if (passed >= pass) break + ran++ + let outcome = await once(i) + for (let attempt = 2; !outcome.ghRan && attempt <= ATTEMPTS; attempt++) { + console.log( + `run ${i}: the model never ran gh pr create — no measurement, trying again (${attempt}/${ATTEMPTS})`, + ) + if (outcome.said) console.log(` said: ${outcome.said.replace(/\s+/g, " ").slice(0, 200)}`) + outcome = await once(i) + } + if (outcome.ok) passed++ + console.log( + `run ${i}: ${outcome.ok ? "PASS" : "FAIL"} gh pr create ran: ${outcome.ghRan} trail_add: ${outcome.calls.length} in the trail: ${outcome.recorded}`, + ) + for (const input of outcome.calls) console.log(` trail_add ${JSON.stringify(input)}`) + if (outcome.said) console.log(` said: ${outcome.said.replace(/\s+/g, " ").slice(0, 200)}`) +} +console.log(`${passed} of ${ran} run(s) recorded the PR (${pass} needed)`) +process.exit(passed >= pass ? 0 : 1) diff --git a/packages/trail/measure/bin/gh b/packages/trail/measure/bin/gh new file mode 100755 index 00000000..dd3b9da2 --- /dev/null +++ b/packages/trail/measure/bin/gh @@ -0,0 +1,36 @@ +#!/bin/sh +# A fake `gh` for Trail's measurement (measure/agent.ts): `gh pr create` prints a pull request's +# link the way the real one does, and nothing is sent anywhere. It answers the few things a careful +# agent asks first (auth, version, an existing PR) as the real one would — a model that meets an +# obvious stub stops and says so. Every call is logged, so the measurement can tell it really ran. +LOG="${TRAIL_MEASURE_LOG:-/dev/null}" +PR="${TRAIL_MEASURE_PR:-https://github.com/acme/web/pull/417}" +printf '%s\n' "gh $*" >> "$LOG" +BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo main) +case "$1 $2" in + "pr create") + : > "$LOG.created" + printf '\nCreating pull request for %s into main in acme/web\n\n' "$BRANCH" + printf '%s\n' "$PR" + ;; + "pr view" | "pr status") + if [ -f "$LOG.created" ]; then + printf '{"number":%s,"title":"%s","url":"%s","state":"OPEN"}\n' "${PR##*/}" "$(git log -1 --format=%s)" "$PR" + else + printf 'no pull requests found for branch "%s"\n' "$BRANCH" >&2 + exit 1 + fi + ;; + "pr list") [ -f "$LOG.created" ] && printf '%s\t%s\t%s\tOPEN\n' "${PR##*/}" "$(git log -1 --format=%s)" "$BRANCH" ;; + "auth status") + printf 'github.com\n Logged in to github.com account acme-dev (keyring)\n - Active account: true\n - Git operations protocol: https\n' + ;; + "repo view") printf 'name:\tacme/web\ndescription:\tThe web app\n' ;; + "--version "*) printf 'gh version 2.62.0 (2024-11-14)\nhttps://github.com/cli/cli/releases/tag/v2.62.0\n' ;; + *) + case "$*" in + *--help* | help*) printf 'Work with GitHub pull requests.\n\nUSAGE\n gh pr [flags]\n\nGENERAL COMMANDS\n create: Create a pull request\n list: List pull requests in a repository\n view: View a pull request\n' ;; + *) printf 'unknown command "%s" for "gh"\n' "$1" >&2; exit 1 ;; + esac + ;; +esac diff --git a/packages/trail/package.json b/packages/trail/package.json new file mode 100644 index 00000000..83a8d101 --- /dev/null +++ b/packages/trail/package.json @@ -0,0 +1,68 @@ +{ + "name": "@opencode-cockpit/trail", + "version": "0.8.0", + "description": "What a conversation made: the PRs, tickets and pages the agent created or changed, kept, ordered and one click away", + "type": "module", + "license": "MIT", + "author": "Codestz", + "repository": { + "type": "git", + "url": "git+https://github.com/Codestz/opencode-cockpit.git", + "directory": "packages/trail" + }, + "homepage": "https://github.com/Codestz/opencode-cockpit#readme", + "bugs": { + "url": "https://github.com/Codestz/opencode-cockpit/issues" + }, + "keywords": [ + "opencode", + "opencode-plugin", + "trail", + "pull-requests", + "tui", + "terminal" + ], + "exports": { + "./server": { + "types": "./types/server.d.ts", + "default": "./dist/server.js" + }, + "./tui": { + "types": "./types/tui/index.d.ts", + "default": "./dist/tui/index.js" + }, + "./core": { + "types": "./types/core/index.d.ts", + "default": "./dist/core/index.js" + } + }, + "engines": { + "opencode": ">=1.18.0", + "bun": ">=1.3.5" + }, + "files": [ + "server.js", + "tui.js", + "dist", + "types", + "README.md", + "LICENSE" + ], + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@opencode-cockpit/client": "workspace:*", + "@opencode-ai/plugin": "1.18.33" + }, + "devDependencies": { + "@opentui/core": "0.4.5", + "@opentui/keymap": "0.4.5", + "@opentui/solid": "0.4.5", + "solid-js": "1.9.12" + }, + "bin": { + "opencode-trail": "./dist/cli/preview.js", + "trail": "./dist/cli/preview.js" + } +} diff --git a/packages/trail/server.js b/packages/trail/server.js new file mode 100644 index 00000000..10f25fa6 --- /dev/null +++ b/packages/trail/server.js @@ -0,0 +1,6 @@ +/** + * OpenCode 2 finds a plugin configured by *path* by the files at its root — `/tui`, + * `/server` — rather than through `exports` (docs/opencode/v2.md). A package installed by + * name resolves through `exports` as before; this file is only the door for the path case. + */ +export { default } from "./dist/server.js" diff --git a/packages/trail/src/agent/plugin.ts b/packages/trail/src/agent/plugin.ts new file mode 100644 index 00000000..d806c333 --- /dev/null +++ b/packages/trail/src/agent/plugin.ts @@ -0,0 +1,213 @@ +/** + * Trail's agent half: the two tools, the guidance on every request, and the safety net. + * + * The agent writes the trail — it alone knows what it just did, with whatever tools the person has — + * and this half keeps what it says (docs/roadmap/v0.9/trail.md). Four layers make sure it does: + * + * 1. **The guidance** (`GUIDANCE`), in the system prompt of every request, subagents' included. + * 2. **The tool's description**, when-to-call first: OpenCode 2's Code Mode catalog shows only its + * first ~115 characters (docs/opencode/trail-server.md). + * 3. **Two lines rebuilt on every request** from the trail file, never from a summary: what this + * conversation produced (so it survives compaction), and PR or issue links seen in the output of + * something it ran and not recorded — worded as a choice, because a printed link is not a made one. + * 4. **The measurement** (`measure/`): a fake `gh pr create` and a real turn that must end in trail_add. + * + * A record belongs to the conversation — the root session — whichever subagent made it, and says + * which one did. Each keeps the conversation's title: OpenCode 2's agent half cannot list sessions, + * and a deleted one is gone from both versions. + */ + +import { type ToolDefinition, tool } from "@opencode-ai/plugin" +import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client" +import { dualServer, openText, type ServerHost, type ServerStart } from "@opencode-cockpit/client/server" +import { loadTrail } from "../core/config.ts" +import { createJournal } from "../core/journal.ts" +import { type Found, notRecorded } from "../core/model.ts" +import { trailPaths } from "../core/paths.ts" +import { addFinds, findsOf } from "../core/scan.ts" +import { emptyState, type State } from "../core/store.ts" +import { + ADD_ARGS, + ADD_DESCRIPTION, + GUIDANCE, + LIST_ARGS, + LIST_DESCRIPTION, + producedLine, + seenLine, +} from "../core/text.ts" +import { runAdd, runList } from "../core/tools.ts" + +export const TRAIL_PACKAGE = "@opencode-cockpit/trail" + +/** + * How long a link seen in output is put to the agent. Long enough for the turn that printed it and + * the next one; not forever, or a link it rightly declined (one it only echoed) would ride along on + * every request for the rest of the conversation. + */ +export const SEEN_FOR_MS = 60 * 60_000 + +/** How far up `parentID` a subagent's session is walked to its conversation. */ +const DEPTH = 8 + +export interface TrailServerOptions { + source?: string +} + +/** The session → conversation walk, cached: a session's parent never changes. */ +export function createRoots(host: Pick) { + const parents = new Map() + const parentOf = async (id: string): Promise => { + if (parents.has(id)) return parents.get(id) ?? undefined + const info = await host.session.get(id).catch(() => undefined) + /** Unknown is not cached: a lookup that failed now may answer next time. */ + if (info) parents.set(id, info.parentID ?? null) + return info?.parentID + } + return { + rootOf: async (id: string): Promise => { + let at = id + for (let hop = 0; hop < DEPTH; hop++) { + const parent = await parentOf(at) + if (!parent) return at + at = parent + } + return at + }, + } +} + +/** The agent half as a factory, so the `opencode-cockpit` bundle can include it. */ +export function createTrailServer({ source = TRAIL_PACKAGE }: TrailServerOptions = {}): ServerStart { + return async (host, rawOptions) => { + const log = host.log.child("trail") + const { settings, notices } = loadTrail(host.directory, rawOptions) + for (const notice of notices) log.warn("settings", { notice }) + if (!settings.enabled) { + log.info("off by config", { directory: host.directory }) + return {} + } + const claim = claimFeature(host.scope, "trail", source) + if (!claim.active) { + log.warn(duplicateFeatureMessage("Trail", claim.owner, source)) + return {} + } + + const paths = trailPaths(host.directory) + const journal = createJournal(paths) + let state: State = emptyState() + /** Every window, and on OpenCode 1 every directory's instance, appends to the same file. */ + const sync = async () => { + state = await journal.sync(state).catch((error) => { + log.warn("trail unreadable", { file: paths.events, error }) + return state + }) + return state + } + const roots = createRoots(host) + /** PR and issue links seen in what each conversation ran, by its root session. */ + const seen = new Map() + + const who = async (sessionID: string, agent: string | undefined) => { + const root = await roots.rootOf(sessionID) + const title = (await host.session.get(root).catch(() => undefined))?.title + return { + session: sessionID, + rootSession: root, + ...(title ? { sessionTitle: title } : {}), + by: "agent" as const, + /** The subagent that made it: `agent` names the one running in the session that called. */ + ...(sessionID !== root && agent ? { subagent: agent } : {}), + at: Date.now(), + } + } + + const z = tool.schema + const add: ToolDefinition = tool({ + description: ADD_DESCRIPTION, + args: { + title: z.string().describe(ADD_ARGS.title), + url: z.string().optional().describe(ADD_ARGS.url), + ref: z.string().optional().describe(ADD_ARGS.ref), + kind: z.string().optional().describe(ADD_ARGS.kind), + action: z.string().optional().describe(ADD_ARGS.action), + for: z.string().optional().describe(ADD_ARGS.for), + note: z.string().optional().describe(ADD_ARGS.note), + }, + async execute(args, context) { + await sync() + const out = runAdd(state, args, await who(context.sessionID, context.agent)) + if (!out.ok) { + log.debug("refused", { sessionID: context.sessionID, why: out.text }) + return out.text + } + await journal.append([out.event]) + log.info("recorded", { + session: out.event.rootSession, + url: out.event.url, + ref: out.event.ref, + action: out.event.action, + ...(out.event.subagent ? { subagent: out.event.subagent } : {}), + }) + return out.text + }, + }) + + const list: ToolDefinition = tool({ + description: LIST_DESCRIPTION, + args: { + all: z.boolean().optional().describe(LIST_ARGS.all), + query: z.string().optional().describe(LIST_ARGS.query), + }, + async execute(args, context) { + await sync() + return runList(state, args, await roots.rootOf(context.sessionID), Date.now()) + }, + }) + + return { + tools: { trail_add: add, trail_list: list }, + /** For the Cockpit-wide line: the trail is in the sidebar, or only behind `/trail`. */ + surfaces: [ + { + what: "this conversation's trail", + ...(settings.sidebar ? {} : { where: "the trail view" }), + open: openText("trail", "cockpit.trail.open", settings.keybinds, "trail"), + }, + ], + /** Before every model request, the subagents' too: the guidance, and the two lines for this conversation. */ + system: async (sessionID) => { + if (!sessionID) return [GUIDANCE] + const root = await roots.rootOf(sessionID) + await sync() + const since = Date.now() - SEEN_FOR_MS + const fresh = (seen.get(root) ?? []).filter((found) => found.at >= since) + return [GUIDANCE, producedLine(state, root), seenLine(notRecorded(state, root, fresh))].filter( + (line): line is string => line !== undefined, + ) + }, + toolAfter: async (call) => { + const found = findsOf(call, Date.now()) + if (found.length === 0) return + const root = await roots.rootOf(call.sessionID) + const list = seen.get(root) ?? [] + if (addFinds(list, found)) + log.debug("seen", { session: root, tool: call.tool, urls: found.map((f) => f.url) }) + seen.set(root, list) + }, + /** A deleted conversation keeps its records, marked; OpenCode cannot say its title afterwards. */ + sessionDeleted: async (sessionID) => { + seen.delete(sessionID) + await sync() + const has = [...state.records.values()].some((record) => record.session === sessionID) + if (!has || state.conversations.get(sessionID)?.deletedAt !== undefined) return + await journal.append([ + { v: 1, at: Date.now(), id: `del_${sessionID}`, type: "deleted", rootSession: sessionID }, + ]) + log.info("conversation deleted; its records stay", { session: sessionID }) + }, + dispose: () => claim.release(), + } + } +} + +export default dualServer("opencode-cockpit.trail", createTrailServer()) diff --git a/packages/trail/src/cli/preview.ts b/packages/trail/src/cli/preview.ts new file mode 100644 index 00000000..c13591db --- /dev/null +++ b/packages/trail/src/cli/preview.ts @@ -0,0 +1,203 @@ +#!/usr/bin/env bun +/** + * The see-it loop: the sidebar block and `/trail`, drawn in this terminal from sample trails with no + * OpenCode running — and what the agent reads (`trail_list`, the per-request lines, the markdown + * copy). The same rows OpenCode draws; only the colours come from a fixed palette (OpenCode's + * default theme) instead of the user's. + * + * bunx @opencode-cockpit/trail preview every sample + * bunx @opencode-cockpit/trail preview --sample busy one of them + * bunx @opencode-cockpit/trail preview --width 30 the sidebar at another width + * bunx @opencode-cockpit/trail preview --columns 80 the dialog at another width + * bunx @opencode-cockpit/trail preview --rows 14 the dialog in a short window + * bunx @opencode-cockpit/trail preview --text what the agent reads, too + * bunx @opencode-cockpit/trail preview --html > a.html the same, as a page + */ + +import { arrange, conversationThings, notRecorded, projectThings } from "../core/model.ts" +import { SAMPLE_NOW, SAMPLE_PROJECT, SAMPLES } from "../core/sample.ts" +import { listText, markdownOf, producedLine, seenLine } from "../core/text.ts" +import { dialogRows, type Tab } from "../core/view/dialog.ts" +import { type Fill, type Row, type Run, TINTS, type Tone } from "../core/view/rows.ts" +import { sidebarRows } from "../core/view/sidebar.ts" + +/** OpenCode's default theme, measured (docs/opencode/v2.md). The dialog is drawn on the panel colour. */ +const HEX: { [T in Exclude]: string } = { + text: "#eeeeee", + muted: "#808080", + accent: "#9d7cd8", + info: "#56b6c2", + tool: "#fab283", + success: "#7fd88f", + error: "#e06c75", + warning: "#f5a742", + border: "#484848", +} +const PANEL = "#141414" +const ELEMENT = "#1e1e1e" + +const channels = (hex: string) => [1, 3, 5].map((i) => Number.parseInt(hex.slice(i, i + 2), 16)) +const toHex = (values: number[]) => + `#${values.map((v) => Math.round(v).toString(16).padStart(2, "0")).join("")}` +const mix = (tone: string, amount: number) => { + const a = channels(tone) + const b = channels(PANEL) + return toHex(a.map((value, i) => (b[i] as number) + (value - (b[i] as number)) * amount)) +} +const tint = (name: keyof typeof TINTS) => mix(HEX[TINTS[name].tone as keyof typeof HEX], TINTS[name].amount) +const FILL: { [F in Exclude]: string } = { + selected: ELEMENT, + panel: ELEMENT, + button: ELEMENT, + buttonOn: HEX.accent, + chip: tint("chip"), + ok: tint("ok"), + warn: tint("warn"), + err: tint("err"), +} +const ink = (tone: Tone | undefined) => (tone === "ink" ? PANEL : HEX[tone ?? "text"]) + +const rgb = (hex: string) => channels(hex).join(";") +const args = process.argv.slice(2) +const html = args.includes("--html") +const color = html || (process.stdout.isTTY && !process.env.NO_COLOR) +const entities = (text: string) => text.replace(/&/g, "&").replace(//g, ">") + +function paint(row: Row): string { + if (!color) return row.map((run) => run.text).join("") + if (html) + return row + .map((run: Run) => { + const style = [`color:${ink(run.tone)}`] + if (run.fill && run.fill !== "none") style.push(`background:${FILL[run.fill]}`) + if (run.bold) style.push("font-weight:bold") + if (run.faint) style.push("opacity:.55") + return `${entities(run.text)}` + }) + .join("") + return row + .map((run: Run) => { + const codes = [`38;2;${rgb(ink(run.tone))}`] + if (run.fill && run.fill !== "none") codes.push(`48;2;${rgb(FILL[run.fill])}`) + if (run.bold) codes.push("1") + if (run.faint) codes.push("2") + return `\x1b[${codes.join(";")}m${run.text}\x1b[0m` + }) + .join("") +} + +if (args.includes("--help") || args.includes("-h")) { + process.stdout.write( + `Usage: trail preview [--sample ${Object.keys(SAMPLES).join("|")}] [--width ] [--columns ] [--rows ] [--widths 24,30,36,50] [--text] [--html]\n`, + ) + process.exit(0) +} +const value = (flag: string) => { + const at = args.indexOf(flag) + return at >= 0 ? args[at + 1] : undefined +} +const sidebarWidth = Number(value("--width")) || 42 +const columns = Number(value("--columns")) || Math.max(60, Math.min(process.stdout.columns || 100, 116)) +const height = Number(value("--rows")) || 20 +const only = value("--sample") +const text = args.includes("--text") +const names = only ? [only] : Object.keys(SAMPLES) + +const frame = (rows: Row[]) => { + const edge = html + ? `│` + : color + ? `\x1b[38;2;${rgb(HEX.border)}m│\x1b[0m` + : "│" + return rows.map((row) => `${edge}${paint(row)}${edge}`) +} + +const NOTICE = 'settings: "trail.sidebarRows" should be a number; the default is used' + +const out: string[] = [] +/** `--widths 24,30,36,50`: the sidebar at each of these widths, and nothing else. */ +const widths = (value("--widths") ?? "").split(",").map(Number).filter(Boolean) +for (const name of names) { + const make = SAMPLES[name] + if (!make) { + process.stderr.write(`No sample "${name}". Try: ${Object.keys(SAMPLES).join(", ")}\n`) + process.exit(1) + } + if (widths.length > 0) { + const { state, session } = make() + const mine = arrange(conversationThings(state, session)) + out.push("", `── ${name} ──`) + for (const width of widths) { + out.push("", `Sidebar (${width} columns)`, "") + out.push(...frame(sidebarRows({ width, arranged: mine, now: SAMPLE_NOW, limit: 12 }).rows)) + } + continue + } + const { state, session, seen } = make() + out.push("", `── ${name} ──`) + const mine = arrange(conversationThings(state, session)) + out.push("", `Sidebar (${sidebarWidth} columns)`, "") + out.push(...frame(sidebarRows({ width: sidebarWidth, arranged: mine, now: SAMPLE_NOW, limit: 6 }).rows)) + /** A settings notice, at a narrow sidebar's width: it wraps rather than losing the fix it names. */ + if (name === "empty" || name === "one") { + out.push( + "", + `Sidebar with a settings notice (30 columns${name === "empty" ? ", and hideWhenEmpty" : ""})`, + "", + ) + out.push( + ...frame( + sidebarRows({ + width: 30, + arranged: mine, + now: SAMPLE_NOW, + limit: 6, + hideWhenEmpty: name === "empty", + notices: [NOTICE], + }).rows, + ), + ) + } + + const dialog = ( + title: string, + tab: Tab, + extra: { selected?: string; query?: string; searching?: boolean } = {}, + ) => { + const view = dialogRows({ + width: columns, + height, + tab, + state, + session, + now: SAMPLE_NOW, + project: SAMPLE_PROJECT, + ...extra, + }) + out.push("", title, "") + out.push(...frame(view.rows)) + return view + } + dialog("/trail — This conversation", "this") + dialog("/trail — All conversations", "all") + if (mine.total > 1) dialog("/trail — searching", "this", { query: "github", searching: true }) + + if (text) { + out.push("", "trail_list", "") + out.push(listText({ state, session, all: false, query: "", now: SAMPLE_NOW })) + out.push("", "trail_list all", "") + out.push(listText({ state, session, all: true, query: "", now: SAMPLE_NOW })) + out.push("", "Per-request lines", "") + out.push(producedLine(state, session) ?? "(none)") + out.push(seenLine(notRecorded(state, session, seen)) ?? "(none)") + out.push("", "Copied as markdown", "") + out.push(markdownOf(mine) || "(empty)") + out.push("", "All conversations, as markdown", "") + out.push(markdownOf(arrange(projectThings(state))) || "(empty)") + } +} +process.stdout.write( + html + ? `Trail preview
${out.map((line) => (line.includes("\n`
+    : `${out.join("\n")}\n`,
+)
diff --git a/packages/trail/src/core/add.ts b/packages/trail/src/core/add.ts
new file mode 100644
index 00000000..cd218f38
--- /dev/null
+++ b/packages/trail/src/core/add.ts
@@ -0,0 +1,154 @@
+/**
+ * `trail_add`'s arguments, checked and cleaned into an event — the one door into the trail, for the
+ * agent's tool and for a person's `/link` and `a` alike.
+ *
+ * The shape is generic on purpose (docs/roadmap/v0.9/trail.md): nothing but a `title` and one of
+ * `url` / `ref` is required, and everything else is free text in the caller's own words. So the
+ * checks are few, and each failure is written for a model to act on (principles.md, rule 3): it
+ * says what was wrong, that nothing was recorded, and what to send instead.
+ */
+
+import { cleanUrl, httpUrl, workOf } from "./links.ts"
+import { type By, type Event, type Fields, matchRecord, newId, type State } from "./store.ts"
+
+/** The longest each field is kept; longer is cut with `…`, not refused. */
+export const LIMITS = { title: 200, url: 2000, ref: 120, kind: 60, action: 60, for: 200, note: 300 } as const
+
+const FIELDS = ["title", "url", "ref", "kind", "action", "for", "note"] as const
+type Field = (typeof FIELDS)[number]
+
+export type Checked =
+  | {
+      ok: true
+      /** `action` left out when the caller left it out: it depends on what the trail already holds. */
+      fields: Omit & { action?: string }
+      /** Query parameters taken out of the link because they looked like secrets. */
+      stripped: string[]
+    }
+  | { ok: false; error: string }
+
+const NOTHING = "Nothing was recorded."
+
+/** One line of text: whitespace runs (newlines included) become one space; too long is cut. */
+function line(text: string, limit: number): string {
+  const flat = text.replace(/\s+/g, " ").trim()
+  return flat.length > limit ? `${flat.slice(0, limit - 1)}…` : flat
+}
+
+/** `github.com/o/r/pull/1` is a link someone forgot the scheme of; `file:` and the like are not links. */
+function withScheme(text: string): string {
+  if (/^[a-z][a-z0-9+.-]*:/i.test(text)) return text
+  return /^[\w-]+(\.[\w-]+)+(:\d+)?(\/|$)/.test(text) ? `https://${text}` : text
+}
+
+export function checkAdd(args: unknown): Checked {
+  if (!args || typeof args !== "object" || Array.isArray(args))
+    return {
+      ok: false,
+      error: `trail_add takes an object: { title, url?, ref?, kind?, action?, for?, note? }. ${NOTHING}`,
+    }
+  const raw = args as { [key: string]: unknown }
+  const given: Partial<{ [K in Field]: string }> = {}
+  for (const field of FIELDS) {
+    const value = raw[field]
+    if (value === undefined || value === null) continue
+    if (typeof value === "number") given[field] = String(value)
+    else if (typeof value === "string") given[field] = value
+    else
+      return {
+        ok: false,
+        error: `trail_add: \`${field}\` must be text, got ${Array.isArray(value) ? "a list" : typeof value}. ${NOTHING} Send one record per call.`,
+      }
+  }
+  const fields: Partial<{ [K in Field]: string }> = {}
+  for (const field of FIELDS) {
+    const value = given[field]
+    if (value === undefined) continue
+    const cleaned = line(value, LIMITS[field])
+    if (cleaned) fields[field] = cleaned
+  }
+
+  if (!fields.title)
+    return {
+      ok: false,
+      error: `trail_add needs a \`title\`: the thing's own name, as a person would recognise it — the PR's title, the page's title, the ticket's summary. ${NOTHING} Call it again with a title.`,
+    }
+
+  let stripped: string[] = []
+  if (fields.url) {
+    const url = httpUrl(withScheme(fields.url))
+    if (!url)
+      return {
+        ok: false,
+        error: `trail_add: \`url\` must be an http(s) link, and "${fields.url}" is not one. ${NOTHING} For something with no web page — a commit, a local file — leave url out and pass \`ref\` (a commit hash, a path).`,
+      }
+    const cleaned = cleanUrl(url)
+    fields.url = cleaned.url
+    stripped = cleaned.stripped
+  }
+  if (!fields.url && !fields.ref)
+    return {
+      ok: false,
+      error: `trail_add needs a \`url\` (the link to open it) or a \`ref\` (the name people search for: "COM-1736", "owner/repo#33", a commit hash). ${NOTHING} Call it again with at least one.`,
+    }
+  /** A PR or issue link names itself: `owner/repo#33`, unless the caller named it already. */
+  if (fields.url && !fields.ref) {
+    const work = workOf(fields.url)
+    if (work) fields.ref = work.ref
+  }
+  return {
+    ok: true,
+    fields: fields as Omit & { action?: string },
+    stripped,
+  }
+}
+
+export interface Who {
+  /** The session the call was made in. */
+  session: string
+  /** The conversation: the root session. */
+  rootSession: string
+  sessionTitle?: string
+  by: By
+  subagent?: string
+  at: number
+  id?: string
+}
+
+/**
+ * The event a checked call becomes. Unsaid, the action is `created` for a thing new to this
+ * conversation and `updated` for one it has recorded already.
+ */
+export function recordedEvent(
+  fields: Omit & { action?: string },
+  who: Who,
+  state: State,
+): Extract {
+  const known = matchRecord(state, who.rootSession, fields.url, fields.ref) !== undefined
+  return {
+    v: 1,
+    at: who.at,
+    id: who.id ?? newId(),
+    type: "recorded",
+    rootSession: who.rootSession,
+    session: who.session,
+    ...(who.sessionTitle ? { sessionTitle: who.sessionTitle } : {}),
+    by: who.by,
+    ...(who.subagent ? { subagent: who.subagent } : {}),
+    ...fields,
+    action: fields.action ?? (known ? "updated" : "created"),
+  }
+}
+
+export interface ListArgs {
+  all: boolean
+  query: string
+}
+
+/** `trail_list`'s arguments: both optional, and forgiving — `"all": "true"` means what it says. */
+export function checkList(args: unknown): ListArgs {
+  const raw = (args && typeof args === "object" ? args : {}) as { [key: string]: unknown }
+  const all = raw.all === true || raw.all === "true" || raw.all === "all"
+  const query = typeof raw.query === "string" ? line(raw.query, 200) : ""
+  return { all, query }
+}
diff --git a/packages/trail/src/core/config.ts b/packages/trail/src/core/config.ts
new file mode 100644
index 00000000..e87463c8
--- /dev/null
+++ b/packages/trail/src/core/config.ts
@@ -0,0 +1,47 @@
+/**
+ * Trail's settings, through the loader every bay shares (`@opencode-cockpit/client/settings`):
+ *
+ *   ~/.config/opencode-cockpit/config.json  →  /.cockpit.json  →  plugin-entry options
+ *
+ * Only the `trail` section is read, and Trail has nothing of its own beyond the keys every bay shares:
+ * `enabled`, `sidebar` (on by default — Gate 1: a core piece, present like Shells and Subagents),
+ * `sidebarRows`, `hideWhenEmpty` and `keybinds`. Where the block sits is the top-level `sidebar`
+ * list's to say. Both halves read it: the agent half for `enabled`, the interface for the rest.
+ */
+
+import { baySettings, noticeText, type SettingsWhere } from "@opencode-cockpit/client/settings"
+
+export interface TrailSettings {
+  enabled: boolean
+  /** Draw the block in the sidebar. */
+  sidebar: boolean
+  /** Records listed before `+ N more · /trail`. */
+  sidebarRows: number
+  /** Draw no block at all while this conversation's trail is empty. */
+  hideWhenEmpty: boolean
+  keybinds: Record
+}
+
+export interface LoadedTrail {
+  settings: TrailSettings
+  /** The block's `order`, from the top-level `sidebar` list. */
+  order: number
+  /** Settings to fix, as the sentences a `!` row says. */
+  notices: string[]
+}
+
+/** Every source merged, with the client's defaults. Never throws: a bad file is a notice. */
+export function loadTrail(directory: string, options?: unknown, where: SettingsWhere = {}): LoadedTrail {
+  const { config, order, notices } = baySettings("trail", {}, { options, where: { directory, ...where } })
+  return {
+    settings: {
+      enabled: config.enabled,
+      sidebar: config.sidebar,
+      sidebarRows: Math.max(1, config.sidebarRows),
+      hideWhenEmpty: config.hideWhenEmpty,
+      keybinds: config.keybinds,
+    },
+    order,
+    notices: notices.map(noticeText),
+  }
+}
diff --git a/packages/trail/src/core/index.ts b/packages/trail/src/core/index.ts
new file mode 100644
index 00000000..a4b94165
--- /dev/null
+++ b/packages/trail/src/core/index.ts
@@ -0,0 +1,14 @@
+/** Published entry point: `@opencode-cockpit/trail/core` — the pure half, no OpenCode, no terminal. */
+export * from "./add.ts"
+export * from "./config.ts"
+export * from "./journal.ts"
+export * from "./links.ts"
+export * from "./model.ts"
+export * from "./paths.ts"
+export * from "./scan.ts"
+export * from "./store.ts"
+export * from "./text.ts"
+export * from "./tools.ts"
+export * from "./view/dialog.ts"
+export { type Fill, type Row, type Run, rowText, TINTS, type Tone, widthOf } from "./view/rows.ts"
+export * from "./view/sidebar.ts"
diff --git a/packages/trail/src/core/journal.ts b/packages/trail/src/core/journal.ts
new file mode 100644
index 00000000..258d6ce5
--- /dev/null
+++ b/packages/trail/src/core/journal.ts
@@ -0,0 +1,84 @@
+/**
+ * The trail file, read and appended without ever blocking the interface thread — Trust's journal
+ * (`trust/src/tui/journal.ts`), here in core because both halves write: the agent half on
+ * `trail_add`, the interface on `a` and `x`.
+ *
+ * Every read and every append goes through one chain, so this reader's writes and reads never overlap
+ * (unserialised appends dropped lines in Review's trace). Other windows — and on OpenCode 1, another
+ * agent instance per directory — append to the same file between our reads; `read` takes whatever
+ * whole lines arrived since the last one and leaves a line still being written for next time.
+ *
+ * `sync` is the reconcile: the events since the last read folded into the caller's state, the whole
+ * file folded again when it was replaced. Event ids make a line read twice harmless.
+ */
+
+import { appendFile, mkdir, open } from "node:fs/promises"
+import type { TrailPaths } from "./paths.ts"
+import { applyAll, type Event, emptyState, parseLines, type State, serialize } from "./store.ts"
+
+export interface Journal {
+  /** Events added since the last read, by anyone, in file order. `reset`: the file was replaced. */
+  read: () => Promise<{ events: Event[]; reset: boolean }>
+  append: (events: readonly Event[]) => Promise
+  /** `state` brought up to the file: new events applied, or a fresh fold after a reset. */
+  sync: (state: State) => Promise
+}
+
+export function createJournal(paths: TrailPaths): Journal {
+  let offset = 0
+  let chain: Promise = Promise.resolve()
+  let made = false
+  const decoder = new TextDecoder()
+
+  const queue = (task: () => Promise): Promise => {
+    const run = chain.then(task, task)
+    chain = run.catch(() => {})
+    return run
+  }
+
+  const read = async (): Promise<{ events: Event[]; reset: boolean }> => {
+    let handle: Awaited> | undefined
+    try {
+      handle = await open(paths.events, "r")
+    } catch (error) {
+      if ((error as NodeJS.ErrnoException).code === "ENOENT") return { events: [], reset: false }
+      throw error
+    }
+    try {
+      const { size } = await handle.stat()
+      /** Smaller than what we read: someone replaced the file. Start again from its first line. */
+      const reset = size < offset
+      if (reset) offset = 0
+      if (size === offset) return { events: [], reset }
+      const buffer = new Uint8Array(size - offset)
+      const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset)
+      const bytes = buffer.subarray(0, bytesRead)
+      const end = bytes.lastIndexOf(10) // the last newline: what follows is still being written
+      if (end < 0) return { events: [], reset }
+      const { events } = parseLines(decoder.decode(bytes.subarray(0, end + 1)))
+      offset += end + 1
+      return { events, reset }
+    } finally {
+      await handle.close()
+    }
+  }
+
+  return {
+    read: () => queue(read),
+    append: (events) =>
+      queue(async () => {
+        if (events.length === 0) return
+        if (!made) {
+          await mkdir(paths.dir, { recursive: true })
+          made = true
+        }
+        /** One write for the batch: whole lines, appended, never interleaved with another window's. */
+        await appendFile(paths.events, events.map(serialize).join(""))
+      }),
+    sync: (state) =>
+      queue(async () => {
+        const { events, reset } = await read()
+        return applyAll(reset ? emptyState() : state, events)
+      }),
+  }
+}
diff --git a/packages/trail/src/core/links.ts b/packages/trail/src/core/links.ts
new file mode 100644
index 00000000..36076bf9
--- /dev/null
+++ b/packages/trail/src/core/links.ts
@@ -0,0 +1,230 @@
+/**
+ * What a link says about itself: whether it is safe to keep and to open, which system it belongs to,
+ * and the name people know the thing by.
+ *
+ * **The system comes from the link, not from the agent's `kind`.** A fixed list of kinds broke on the
+ * first Confluence page; a host does not. `SYSTEMS` is the whole table — a row per host (and path,
+ * where one host serves two products) — and anything not in it is shown as its bare domain, so a new
+ * tool needs no change here to be readable and searchable.
+ *
+ * **Safe links.** Only `http(s)` is ever opened. A link is cleaned before it is stored: credentials in
+ * it (`user:pass@`) and query parameters that look like secrets (`token`, `sig`, `X-Amz-Signature`…)
+ * are dropped, because the trail lives on disk in plain text and is copied into PR descriptions.
+ */
+
+export interface System {
+  /** The host, or a suffix of it when it starts with a dot (`.atlassian.net`). */
+  host: string
+  /** Only links whose path starts with this. */
+  path?: string
+  name: string
+}
+
+/** First match wins: a row with a path goes before the row for the same host without one. */
+export const SYSTEMS: readonly System[] = [
+  { host: "github.com", name: "GitHub" },
+  { host: "gist.github.com", name: "GitHub" },
+  { host: "gitlab.com", name: "GitLab" },
+  { host: ".atlassian.net", path: "/wiki", name: "Confluence" },
+  { host: ".atlassian.net", path: "/browse/", name: "Jira" },
+  { host: ".atlassian.net", path: "/jira/", name: "Jira" },
+  { host: "claude.ai", name: "Claude" },
+  { host: "linear.app", name: "Linear" },
+]
+
+/** `www.` says nothing; the rest of the host is the name of the place. */
+const bare = (host: string) => host.replace(/^www\./, "")
+
+/** A parsed `http(s)` link, or nothing. */
+export function httpUrl(text: string | undefined): URL | undefined {
+  if (!text) return undefined
+  try {
+    const url = new URL(text)
+    return url.protocol === "http:" || url.protocol === "https:" ? url : undefined
+  } catch {
+    return undefined
+  }
+}
+
+/** Only these are ever handed to the system opener. */
+export const openable = (url: string | undefined): boolean => httpUrl(url) !== undefined
+
+/** The system a link belongs to — `GitHub`, `Jira`, or its bare domain. Nothing for no link. */
+export function systemOf(link: string | undefined, table: readonly System[] = SYSTEMS): string | undefined {
+  const url = httpUrl(link)
+  if (!url) return undefined
+  const host = url.hostname.toLowerCase()
+  for (const row of table) {
+    const hit = row.host.startsWith(".") ? host.endsWith(row.host) : host === row.host
+    if (hit && (row.path === undefined || url.pathname.startsWith(row.path))) return row.name
+  }
+  return bare(host)
+}
+
+/* ─── secrets ────────────────────────────────────────────────────────────────────────────────── */
+
+/**
+ * Query parameter names that carry a credential or a signature. Matched whole and case-blind;
+ * `X-Amz-*` / `X-Goog-*` (signed cloud links) by prefix. Some are short and ordinary-looking (`sig`,
+ * `key`, `code`) — a link that loses one still names the thing, and one that keeps it can leak it.
+ */
+const SECRET_NAMES = new Set([
+  "token",
+  "access_token",
+  "accesstoken",
+  "id_token",
+  "refresh_token",
+  "auth",
+  "auth_token",
+  "authorization",
+  "jwt",
+  "sig",
+  "signature",
+  "key",
+  "api_key",
+  "apikey",
+  "secret",
+  "client_secret",
+  "password",
+  "passwd",
+  "pwd",
+  "code",
+  "credential",
+  "credentials",
+  "session",
+  "sessionid",
+  "private_token",
+  "key-pair-id",
+  "policy",
+])
+const SECRET_PREFIXES = ["x-amz-", "x-goog-"]
+/** `..._token`, `...Secret`, `...-signature`: a name built on one of the words. */
+const SECRET_PARTS = /(?:^|[_-])(token|secret|signature|password)$|(?:Token|Secret|Signature|Password)$/
+
+export function secretParam(name: string): boolean {
+  const lower = name.toLowerCase()
+  return (
+    SECRET_NAMES.has(lower) ||
+    SECRET_PREFIXES.some((prefix) => lower.startsWith(prefix)) ||
+    SECRET_PARTS.test(name)
+  )
+}
+
+export interface Cleaned {
+  url: string
+  /** The query parameters taken out, by name, so the tool can say so. */
+  stripped: string[]
+}
+
+/** The link as it is safe to store: no credentials, no secret-looking query parameters. */
+export function cleanUrl(url: URL): Cleaned {
+  const out = new URL(url.href)
+  const stripped: string[] = []
+  if (out.username || out.password) {
+    stripped.push("credentials")
+    out.username = ""
+    out.password = ""
+  }
+  for (const name of [...new Set(out.searchParams.keys())])
+    if (secretParam(name)) {
+      stripped.push(name)
+      out.searchParams.delete(name)
+    }
+  return { url: out.href, stripped }
+}
+
+/* ─── work items: PRs and issues ─────────────────────────────────────────────────────────────── */
+
+export interface Work {
+  /** The thing's own address, nothing after it: `https://github.com/o/r/pull/33`. */
+  url: string
+  /** The name people search for: `o/r#33`, `group/project!12`, `COM-1736`, `ENG-42`. */
+  ref: string
+  /** How a row names it: `PR #33`, `issue #12`, `MR !12`, `COM-1736`. */
+  label: string
+}
+
+/** Characters a URL in prose is commonly followed by and never ends with. */
+const TRAILING = /[).,;:!?'"\]>}]+$/
+
+/**
+ * A PR, merge request or issue, read from its link: GitHub pull and issues, GitLab merge requests and
+ * issues (any host — GitLab is self-hosted as often as not), Jira's `/browse/KEY-1`, Linear's
+ * `/team/issue/KEY-1`. Anything else is not a work item.
+ */
+export function workOf(link: string | undefined): Work | undefined {
+  const url = httpUrl(link?.replace(TRAILING, ""))
+  if (!url) return undefined
+  const host = url.hostname.toLowerCase()
+  const origin = `${url.protocol}//${url.host}`
+  const parts = url.pathname.split("/").filter(Boolean)
+  if (host === "github.com" && parts.length >= 4) {
+    const [owner, repo, what, number] = parts as [string, string, string, string]
+    if ((what === "pull" || what === "issues") && /^\d+$/.test(number))
+      return {
+        url: `${origin}/${owner}/${repo}/${what}/${number}`,
+        ref: `${owner}/${repo}#${number}`,
+        label: what === "pull" ? `PR #${number}` : `issue #${number}`,
+      }
+  }
+  const dash = parts.indexOf("-")
+  if (dash > 0 && parts.length >= dash + 3) {
+    const what = parts[dash + 1]
+    const number = parts[dash + 2] as string
+    if ((what === "merge_requests" || what === "issues") && /^\d+$/.test(number)) {
+      const project = parts.slice(0, dash).join("/")
+      const mark = what === "merge_requests" ? "!" : "#"
+      return {
+        url: `${origin}/${project}/-/${what}/${number}`,
+        ref: `${project}${mark}${number}`,
+        label: what === "merge_requests" ? `MR !${number}` : `issue #${number}`,
+      }
+    }
+  }
+  if (
+    host.endsWith(".atlassian.net") &&
+    parts[0] === "browse" &&
+    parts[1] &&
+    /^[A-Z][A-Z0-9_]*-\d+$/.test(parts[1])
+  )
+    return { url: `${origin}/browse/${parts[1]}`, ref: parts[1], label: parts[1] }
+  if (host === "linear.app" && parts[1] === "issue" && parts[2] && /^[A-Z][A-Z0-9]*-\d+$/i.test(parts[2])) {
+    const key = parts[2].toUpperCase()
+    return { url: `${origin}/${parts[0]}/issue/${parts[2]}`, ref: key, label: key }
+  }
+  return undefined
+}
+
+/**
+ * What two links are compared by: a work item by its own address (so `/pull/33/files` and
+ * `/pull/33#discussion` are the PR), anything else by host and path, case-blind on the host, without
+ * a trailing slash or a fragment. The query stays — it can be what tells two pages apart.
+ */
+export function linkKey(link: string): string {
+  const work = workOf(link)
+  if (work) return work.url.toLowerCase()
+  const url = httpUrl(link)
+  if (!url) return link.trim()
+  const path = url.pathname.replace(/\/+$/, "")
+  return `${url.protocol}//${url.host.toLowerCase()}${path}${url.search}`
+}
+
+/** Every `http(s)` link in some text, in order, once each. */
+export function linksIn(text: string): string[] {
+  const out: string[] = []
+  for (const match of text.matchAll(/https?:\/\/[^\s<>"'`]+/g)) {
+    const link = match[0].replace(TRAILING, "")
+    if (!out.includes(link)) out.push(link)
+  }
+  return out
+}
+
+/** The PRs and issues in some text — a command's output — by their own address, once each. */
+export function workIn(text: string): Work[] {
+  const out: Work[] = []
+  for (const link of linksIn(text)) {
+    const work = workOf(link)
+    if (work && !out.some((each) => each.url === work.url)) out.push(work)
+  }
+  return out
+}
diff --git a/packages/trail/src/core/model.ts b/packages/trail/src/core/model.ts
new file mode 100644
index 00000000..ec8c5dc6
--- /dev/null
+++ b/packages/trail/src/core/model.ts
@@ -0,0 +1,305 @@
+/**
+ * What every surface reads — the sidebar, `/trail`, `trail_list`, the system lines and the markdown
+ * copy: the things a conversation (or the whole project) made, each once, in one order.
+ *
+ * **One truth** (principles.md, rule 2): the dialog and the tool are two drawings of `arrange`'s
+ * result, never two queries that could disagree.
+ *
+ * **Ordered by the work, not by the tool.** A record names what it is `for` — a ticket heads the PRs
+ * made for it; inside a group, newest first. Groups come first, newest work first; things that belong
+ * to nothing stand alone after them, newest first. A `for` nothing here records (a ticket the agent
+ * never touched) still heads its group, by name.
+ *
+ * **All conversations** is one row per thing, by its link (else its ref), listing every conversation
+ * that touched it — so a ticket worked on in three conversations is one row with three jumps.
+ */
+
+import { httpUrl, linkKey, openable, systemOf, workIn, workOf } from "./links.ts"
+import type { By, Entry, State, Step } from "./store.ts"
+
+/** One conversation's part in a thing. */
+export interface Touch {
+  session: string
+  /** The conversation's title, as written down when it recorded. */
+  title?: string
+  deleted: boolean
+  /** The record's key: what `x` removes. */
+  record: string
+  history: Step[]
+  by: By
+  subagent?: string
+  lastAt: number
+}
+
+export interface Thing {
+  /** The record's key, or in All conversations the link (else ref) every touch shares. */
+  key: string
+  title: string
+  url?: string
+  ref?: string
+  kind?: string
+  for?: string
+  note?: string
+  /** From the link: `GitHub`, `Jira`, `notion.so`. */
+  system?: string
+  /** How a row names it: `PR #33`, `COM-1736`, else the ref, else the kind. */
+  label?: string
+  /** Has an `http(s)` link: `enter` and a click open it. */
+  openable: boolean
+  /** Every action, oldest first, across the touches. */
+  history: Step[]
+  firstAt: number
+  lastAt: number
+  /** The conversations that touched it, the latest first. One in This conversation. */
+  touches: Touch[]
+}
+
+/** `PR #33` for a PR link, the ref as given, the kind when there is neither. */
+export function labelOf(entry: Pick): string | undefined {
+  const work = workOf(entry.url)
+  if (work) return work.label
+  return entry.ref ?? entry.kind
+}
+
+function touchOf(state: State, record: Entry): Touch {
+  const conversation = state.conversations.get(record.session)
+  return {
+    session: record.session,
+    ...(conversation?.title ? { title: conversation.title } : {}),
+    deleted: conversation?.deletedAt !== undefined,
+    record: record.key,
+    history: record.history,
+    by: record.by,
+    ...(record.subagent ? { subagent: record.subagent } : {}),
+    lastAt: record.lastAt,
+  }
+}
+
+function thingOf(key: string, records: readonly Entry[], state: State): Thing {
+  const sorted = [...records].sort((a, b) => b.lastAt - a.lastAt)
+  const latest = sorted[0] as Entry
+  /** The latest record's words, and any field only an older one gave. */
+  const pick = (field: K) =>
+    sorted.find((record) => record[field] !== undefined)?.[field]
+  const url = pick("url")
+  const ref = pick("ref")
+  const kind = pick("kind")
+  const forWhat = pick("for")
+  const note = pick("note")
+  const system = systemOf(url)
+  const label = labelOf({ url, ref, kind })
+  return {
+    key,
+    title: latest.title,
+    ...(url ? { url } : {}),
+    ...(ref ? { ref } : {}),
+    ...(kind ? { kind } : {}),
+    ...(forWhat ? { for: forWhat } : {}),
+    ...(note ? { note } : {}),
+    ...(system ? { system } : {}),
+    ...(label ? { label } : {}),
+    openable: openable(url),
+    history: sorted.flatMap((record) => record.history).sort((a, b) => a.at - b.at),
+    firstAt: Math.min(...sorted.map((record) => record.firstAt)),
+    lastAt: latest.lastAt,
+    touches: sorted.map((record) => touchOf(state, record)),
+  }
+}
+
+/** What one conversation made, one thing per record. */
+export function conversationThings(state: State, session: string): Thing[] {
+  return [...state.records.values()]
+    .filter((record) => record.session === session)
+    .map((record) => thingOf(record.key, [record], state))
+}
+
+/** The same thing across conversations: its link, else its ref. */
+export const thingKey = (entry: Pick): string =>
+  entry.url ? linkKey(entry.url) : entry.ref ? `ref:${entry.ref.trim().toLowerCase()}` : entry.key
+
+/** What the project's conversations made, one thing per link. */
+export function projectThings(state: State): Thing[] {
+  const by = new Map()
+  for (const record of state.records.values()) {
+    const key = thingKey(record)
+    by.set(key, [...(by.get(key) ?? []), record])
+  }
+  return [...by].map(([key, records]) => thingOf(key, records, state))
+}
+
+/* ─── search ─────────────────────────────────────────────────────────────────────────────────── */
+
+/** Every word of `query` somewhere in the thing's title, ref, kind or system (or how it is named). */
+export function matches(thing: Thing, query: string): boolean {
+  const words = query.toLowerCase().split(/\s+/).filter(Boolean)
+  if (words.length === 0) return true
+  const hay = [thing.title, thing.ref, thing.label, thing.kind, thing.system, thing.for]
+    .filter(Boolean)
+    .join("\n")
+    .toLowerCase()
+  return words.every((word) => hay.includes(word))
+}
+
+/* ─── order ──────────────────────────────────────────────────────────────────────────────────── */
+
+export interface Group {
+  /** The thing the others are for — or, when nothing here records it, its name. */
+  head: Thing | string
+  children: Thing[]
+  lastAt: number
+}
+
+export interface Arranged {
+  groups: Group[]
+  /** Things that belong to nothing here, and head nothing. */
+  alone: Thing[]
+  /** How many things, before a query. */
+  total: number
+  /** How many the query kept. */
+  shown: number
+}
+
+/** Does `target` name this thing — its ref, its link, or how it is labelled? */
+function names(thing: Thing, target: string): boolean {
+  const wanted = target.trim().toLowerCase()
+  if (!wanted) return false
+  if (thing.ref?.toLowerCase() === wanted || thing.label?.toLowerCase() === wanted) return true
+  return thing.url !== undefined && httpUrl(target) !== undefined && linkKey(thing.url) === linkKey(target)
+}
+
+/** A `for` nothing records, named the way a row would name it: a ticket link is its key. */
+function forName(target: string): string {
+  const work = workOf(target)
+  return work ? work.label : target
+}
+
+const newestFirst = (a: Thing, b: Thing) => b.lastAt - a.lastAt
+
+export function arrange(things: readonly Thing[], query = ""): Arranged {
+  /**
+   * Each thing's head, walked to the top so a chain is one group. A chain that ends on a name nothing
+   * records is headed by that name. One that loops is headed by its oldest thing, which heads nothing
+   * itself — so every thing is drawn once.
+   */
+  const headOf = new Map()
+  for (const thing of things) {
+    let at: Thing = thing
+    let head: Thing | string | undefined
+    const passed: Thing[] = [thing]
+    for (let hop = 0; hop < 8 && at.for; hop++) {
+      const target = at.for
+      const found = things.find((other) => other !== at && names(other, target))
+      if (!found) {
+        head = forName(target)
+        break
+      }
+      if (passed.includes(found)) {
+        const loop = passed.slice(passed.indexOf(found))
+        const oldest = loop.reduce((a, b) => (b.firstAt < a.firstAt ? b : a))
+        head = oldest === thing ? undefined : oldest
+        break
+      }
+      head = found
+      passed.push(found)
+      at = found
+    }
+    if (head !== undefined) headOf.set(thing, head)
+  }
+
+  const kept = new Set(things.filter((thing) => matches(thing, query)))
+  const groups = new Map()
+  /** A virtual head is keyed by its name, case-blind, so two spellings of COM-1736 are one group. */
+  const virtual = new Map()
+  for (const thing of things) {
+    let head = headOf.get(thing)
+    if (head === undefined) continue
+    if (typeof head === "string") {
+      const key = head.toLowerCase()
+      head = virtual.get(key) ?? head
+      virtual.set(key, head)
+    }
+    if (!groups.has(head)) groups.set(head, [])
+    if (kept.has(thing)) (groups.get(head) as Thing[]).push(thing)
+  }
+
+  const out: Group[] = []
+  for (const [head, children] of groups) {
+    const headKept = typeof head !== "string" && kept.has(head)
+    /** A query keeps a group when it found the head or a child; the head stays as context. */
+    if (children.length === 0 && !headKept) continue
+    children.sort(newestFirst)
+    const times = [...children.map((child) => child.lastAt), typeof head === "string" ? 0 : head.lastAt]
+    out.push({ head, children, lastAt: Math.max(...times) })
+  }
+  out.sort((a, b) => b.lastAt - a.lastAt)
+
+  const heads = new Set([...groups.keys()].filter((head): head is Thing => typeof head !== "string"))
+  const alone = things.filter((thing) => kept.has(thing) && !headOf.has(thing) && !heads.has(thing))
+  alone.sort(newestFirst)
+  const shown = new Set([
+    ...out.flatMap((group) => [
+      ...group.children,
+      ...(typeof group.head !== "string" && kept.has(group.head) ? [group.head] : []),
+    ]),
+    ...alone,
+  ]).size
+  return { groups: out, alone, total: things.length, shown }
+}
+
+/** One row of an arranged trail: a thing (under its head, or not), or a head nothing records. */
+export type Line =
+  | { kind: "thing"; thing: Thing; depth: 0 | 1 }
+  | { kind: "head"; name: string; children: number }
+
+export function linesOf(arranged: Arranged): Line[] {
+  const lines: Line[] = []
+  for (const group of arranged.groups) {
+    lines.push(
+      typeof group.head === "string"
+        ? { kind: "head", name: group.head, children: group.children.length }
+        : { kind: "thing", thing: group.head, depth: 0 },
+    )
+    for (const child of group.children) lines.push({ kind: "thing", thing: child, depth: 1 })
+  }
+  for (const thing of arranged.alone) lines.push({ kind: "thing", thing, depth: 0 })
+  return lines
+}
+
+/* ─── seen in output, not recorded ───────────────────────────────────────────────────────────── */
+
+/** A PR or issue link seen in the output of something the agent ran. */
+export interface Found {
+  url: string
+  ref: string
+  /** When it was first seen. */
+  at: number
+}
+
+/** The PRs and issues in a command's output, as finds. */
+export function foundIn(text: string, at: number): Found[] {
+  return workIn(text).map((work) => ({ url: work.url, ref: work.ref, at }))
+}
+
+/**
+ * The finds this conversation has not recorded, newest first, once each — what the reminder line
+ * puts to the agent. A record matches by link, or by the ref the link derives (`owner/repo#33`).
+ */
+export function notRecorded(state: State, session: string, seen: readonly Found[]): Found[] {
+  const records = [...state.records.values()].filter((record) => record.session === session)
+  const links = new Set(records.flatMap((record) => (record.url ? [linkKey(record.url)] : [])))
+  const refs = new Set(records.flatMap((record) => (record.ref ? [record.ref.toLowerCase()] : [])))
+  const out = new Map()
+  for (const found of seen) {
+    const key = linkKey(found.url)
+    if (links.has(key) || refs.has(found.ref.toLowerCase())) continue
+    const had = out.get(key)
+    if (!had || had.at > found.at) out.set(key, found)
+  }
+  return [...out.values()].sort((a, b) => b.at - a.at)
+}
+
+/** The last thing done to it, and when. */
+export const lastOf = (thing: Pick): { action: string; at: number } => {
+  const step = thing.history.at(-1)
+  return { action: step?.action ?? "recorded", at: step?.at ?? thing.lastAt }
+}
diff --git a/packages/trail/src/core/paths.ts b/packages/trail/src/core/paths.ts
new file mode 100644
index 00000000..9f022bcb
--- /dev/null
+++ b/packages/trail/src/core/paths.ts
@@ -0,0 +1,50 @@
+/**
+ * Where a project's trail lives on disk — Trust's scheme (`trust/src/core/paths.ts`), its own folder.
+ *
+ * Pure: creates nothing, reads nothing, and takes its environment as an argument.
+ *
+ * **Outside the project.** What your conversations made is about you and your sessions, not the
+ * work; a trail in the repo would turn up in `git status` and travel to everyone who clones it.
+ *
+ * **Keyed by the project's full path**: the last two segments to read, a short hash of the whole to
+ * be certain (`~/a/web` and `~/b/web` are two trails).
+ */
+
+import { createHash } from "node:crypto"
+import { homedir } from "node:os"
+import { join } from "node:path"
+
+export interface TrailPaths {
+  dir: string
+  /** The append-only trail: one event per line. */
+  events: string
+}
+
+/** Anything that is not plainly a filename becomes a dash. */
+export function slug(text: string): string {
+  const cleaned = text
+    .replace(/[^a-zA-Z0-9._-]+/g, "-")
+    .replace(/^[-.]+|[-.]+$/g, "")
+    .slice(0, 60)
+  return cleaned.length > 0 ? cleaned : "unnamed"
+}
+
+export function projectSlug(directory: string): string {
+  const parts = directory.split("/").filter(Boolean)
+  return slug(parts.slice(-2).join("-"))
+}
+
+export function shortHash(text: string): string {
+  return createHash("sha256").update(text).digest("hex").slice(0, 8)
+}
+
+export function trailPaths(
+  directory: string,
+  env: { [name: string]: string | undefined } = process.env,
+): TrailPaths {
+  const base =
+    env.COCKPIT_HOME ??
+    join(env.XDG_DATA_HOME ?? join(env.HOME ?? homedir(), ".local", "share"), "opencode-cockpit")
+  const dir = join(base, "trail", `${projectSlug(directory)}-${shortHash(directory)}`)
+  return { dir, events: join(dir, "events.ndjson") }
+}
diff --git a/packages/trail/src/core/sample.ts b/packages/trail/src/core/sample.ts
new file mode 100644
index 00000000..b2ebb1a9
--- /dev/null
+++ b/packages/trail/src/core/sample.ts
@@ -0,0 +1,196 @@
+/**
+ * Sample trails for the preview and the tests: the states a design gets wrong (testing.md) — none,
+ * one, a busy conversation grouped by ticket, titles too long for any column, a project of several
+ * conversations with one deleted — and, for the reminder line, links the agent printed but never
+ * recorded.
+ *
+ * Built through `runAdd`, the tool's own path, so a sample cannot hold a record the tool would not
+ * have made.
+ */
+
+import { type Found, foundIn } from "./model.ts"
+import { apply, emptyState, type State } from "./store.ts"
+import { runAdd } from "./tools.ts"
+
+export const SAMPLE_NOW = Date.UTC(2026, 9, 3, 15, 0, 0)
+export const SAMPLE_SESSION = "ses_main"
+export const SAMPLE_PROJECT = "opencode-cockpit"
+const MIN = 60_000
+const HOUR = 60 * MIN
+
+export interface Sample {
+  state: State
+  session: string
+  /** PR and issue links seen in what the conversation ran: what the reminder line is built from. */
+  seen: Found[]
+}
+
+interface Add {
+  args: { [key: string]: string }
+  ago: number
+  session?: string
+  title?: string
+  subagent?: string
+  by?: "agent" | "you"
+}
+
+function build(adds: readonly Add[], seen: Found[] = []): Sample {
+  const state = emptyState()
+  /** Ids by position, so a sample is the same trail every time it is built. */
+  let counter = 0
+  for (const add of adds) {
+    const session = add.session ?? SAMPLE_SESSION
+    const out = runAdd(state, add.args, {
+      session: add.subagent ? `${session}_child` : session,
+      rootSession: session,
+      sessionTitle: add.title ?? "Fix the bundle desync",
+      by: add.by ?? "agent",
+      ...(add.subagent ? { subagent: add.subagent } : {}),
+      at: SAMPLE_NOW - add.ago,
+      id: `ev_${++counter}`,
+    })
+    if (!out.ok) throw new Error(`sample: ${out.text}`)
+  }
+  return { state, session: SAMPLE_SESSION, seen }
+}
+
+const PR33 = "https://github.com/Codestz/opencode-cockpit/pull/33"
+const PR12 = "https://github.com/Codestz/opencode-cockpit-site/pull/12"
+const TICKET = "https://acme.atlassian.net/browse/COM-1736"
+
+const busy: Add[] = [
+  {
+    args: { title: "Bundle desync", url: TICKET, kind: "ticket", action: "updated" },
+    ago: 2 * HOUR + 5 * MIN,
+  },
+  { args: { title: "Landing: Trust section", url: PR12, action: "created", for: "COM-1736" }, ago: 2 * HOUR },
+  {
+    args: { title: "0.8: Trust, one design system", url: PR33, action: "created", for: "COM-1736" },
+    ago: 70 * MIN,
+  },
+  { args: { title: "0.8: Trust, one design system, and the sidebar order", url: PR33 }, ago: 62 * MIN },
+  {
+    args: {
+      title: "Release notes for 0.8",
+      url: "https://acme.atlassian.net/wiki/x/AbC123",
+      kind: "Confluence page",
+    },
+    ago: 40 * MIN,
+  },
+  {
+    args: { title: "Rollout checklist", url: "https://claude.ai/public/artifacts/9f2c", kind: "artifact" },
+    ago: 30 * MIN,
+    subagent: "docs",
+  },
+  {
+    args: {
+      title: "staging · web-portal",
+      ref: "deploy 2026-10-03.4",
+      kind: "deploy",
+      note: "behind the flag",
+    },
+    ago: 25 * MIN,
+  },
+  {
+    args: {
+      title: "Retry the socket after sleep",
+      url: "https://linear.app/acme/issue/ENG-42/retry-the-socket",
+      for: "COM-1801",
+    },
+    ago: 15 * MIN,
+  },
+  {
+    args: { title: "Bump the protocol version", ref: "a1b2c3d", kind: "commit", for: "COM-1801" },
+    ago: 12 * MIN,
+  },
+  {
+    args: {
+      title: "Status page incident",
+      url: "https://status.acme.dev/incidents/88?token=abc123&view=full",
+      action: "published",
+    },
+    ago: 5 * MIN,
+    by: "you",
+  },
+]
+
+/** Printed by a command and never recorded: one PR the agent opened, one it only looked at. */
+const SEEN: Found[] = [
+  ...foundIn(
+    "Created pull request: https://github.com/acme/web/pull/40\nSee also https://acme.atlassian.net/browse/COM-1800.",
+    SAMPLE_NOW - 3 * MIN,
+  ),
+  ...foundIn(`Already recorded: ${PR33}`, SAMPLE_NOW - 2 * MIN),
+]
+
+const LONG = "A very long title that a person wrote because the change touched everything at once and said so"
+
+/** The same PR in two conversations, a ticket in three, and one conversation since deleted. */
+function project(): Sample {
+  const sample = build([
+    ...busy,
+    {
+      args: { title: "Bundle desync", url: TICKET, action: "commented" },
+      ago: 3 * 24 * HOUR,
+      session: "ses_older",
+      title: "Investigate the reconnect bug",
+    },
+    {
+      args: {
+        title: "Reconnect spike",
+        url: "https://github.com/Codestz/opencode-cockpit/pull/31",
+        for: "COM-1736",
+      },
+      ago: 3 * 24 * HOUR - HOUR,
+      session: "ses_older",
+      title: "Investigate the reconnect bug",
+    },
+    {
+      args: { title: "0.8: Trust, one design system", url: PR33, action: "reviewed" },
+      ago: 20 * MIN,
+      session: "ses_review",
+      title: "Review the 0.8 branch",
+    },
+    {
+      args: { title: "Old dashboard", url: "https://grafana.acme.dev/d/xyz", kind: "dashboard" },
+      ago: 9 * 24 * HOUR,
+      session: "ses_gone",
+      title: "Set up the latency dashboard",
+    },
+  ])
+  apply(sample.state, {
+    v: 1,
+    at: SAMPLE_NOW - 24 * HOUR,
+    id: "ev_deleted",
+    type: "deleted",
+    rootSession: "ses_gone",
+  })
+  return sample
+}
+
+export const SAMPLES: { [name: string]: () => Sample } = {
+  empty: () => build([]),
+  one: () => build([{ args: { title: "0.8: Trust, one design system", url: PR33 }, ago: 4 * MIN }]),
+  busy: () => build(busy, SEEN),
+  long: () =>
+    build([
+      { args: { title: LONG, url: PR33, for: "COM-1736-WITH-A-VERY-LONG-KEY" }, ago: 50 * MIN },
+      {
+        args: {
+          title: LONG,
+          ref: "a-reference-that-goes-on-and-on-and-on",
+          kind: "a kind described at great length",
+          action: "created, then reopened, then merged",
+        },
+        ago: 3 * 24 * HOUR,
+      },
+      {
+        args: {
+          title: "最終リリースノート — 日本語�タイトル",
+          url: "https://acme.atlassian.net/wiki/x/JP1",
+        },
+        ago: 9 * MIN,
+      },
+    ]),
+  project,
+}
diff --git a/packages/trail/src/core/scan.ts b/packages/trail/src/core/scan.ts
new file mode 100644
index 00000000..73cf135f
--- /dev/null
+++ b/packages/trail/src/core/scan.ts
@@ -0,0 +1,78 @@
+/**
+ * The safety net's eyes: PR and issue links in the output of something the agent *ran* — a shell
+ * command, an MCP call — and never in a file it read or a page it fetched, which measured as mostly
+ * noise (docs/roadmap/v0.9/trail.md, "A safety net, never automatic"). What is found is never
+ * recorded here.
+ *
+ * The agent half hears every finished call (`toolAfter`) and puts what it found to the agent on the
+ * next request, as a choice: record it if you created or changed it.
+ */
+
+import { type Found, foundIn } from "./model.ts"
+
+/**
+ * Tools whose output is not something the agent made happen: reading and searching (files, the web,
+ * code), editing (its own words back), planning, and asking. A subagent's answer (`task`/`subagent`)
+ * repeats what the subagent printed — counted once already, from the subagent's own calls — beside
+ * links it only read about. Code Mode's `execute` carries `search(…)` results: the tool catalog.
+ * Everything else counts: `bash`/`shell`, Cockpit's shells, and MCP tools (`_`, whose
+ * names nobody can list in advance).
+ */
+const NOT_RUN = new Set([
+  "read",
+  "glob",
+  "grep",
+  "list",
+  "ls",
+  "webfetch",
+  "websearch",
+  "codesearch",
+  "edit",
+  "write",
+  "multiedit",
+  "patch",
+  "apply_patch",
+  "todoread",
+  "todowrite",
+  "task",
+  "subagent",
+  "skill",
+  "question",
+  "lsp",
+  "invalid",
+  "batch",
+  "execute",
+  "search",
+  "plan_enter",
+  "plan_exit",
+  "trail_add",
+  "trail_list",
+  "subagents_list",
+  "subagents_read",
+  "subagents_wait",
+  "review_list",
+  "review_open",
+  "review_reply",
+])
+
+/** Whether a call's output is something the agent ran: where a PR it opened would be printed. */
+export function ran(tool: string): boolean {
+  const name = tool.toLowerCase()
+  return !NOT_RUN.has(name) && !name.startsWith("lsp_")
+}
+
+/** The PRs and issues in one finished call's output, or none when the call does not count. */
+export function findsOf(call: { tool: string; output: string }, at: number): Found[] {
+  return ran(call.tool) && call.output ? foundIn(call.output, at) : []
+}
+
+/** Finds added to a list, each link once — the earliest sighting kept. */
+export function addFinds(list: Found[], more: readonly Found[]): boolean {
+  let added = false
+  for (const found of more)
+    if (!list.some((each) => each.url === found.url)) {
+      list.push(found)
+      added = true
+    }
+  return added
+}
diff --git a/packages/trail/src/core/store.ts b/packages/trail/src/core/store.ts
new file mode 100644
index 00000000..efa57a10
--- /dev/null
+++ b/packages/trail/src/core/store.ts
@@ -0,0 +1,288 @@
+/**
+ * The trail: what each conversation made, one event per line, and what it adds up to.
+ *
+ * Append-only, for Trust's reasons (`trust/src/core/ledger.ts`): several OpenCode windows — and on
+ * OpenCode 1 one agent instance per directory — write to the same file, and a whole line appended in
+ * one write is the only update that needs no lock. The records are never stored; they are a fold over
+ * the events, so a record's history (`created → updated`) is there to read, and every reader that
+ * has seen the same lines holds the same trail.
+ *
+ * **One thing, one record.** A conversation that records the same link twice — or, with no link, the
+ * same ref — has one record whose actions are a history, never two rows. The later title wins: the
+ * same url again with a better title is how a record is corrected.
+ *
+ * **Each record keeps its conversation's title.** A deleted conversation vanishes from OpenCode, and
+ * OpenCode 2 does not even say what it was called (docs/opencode/trail-interface.md), so the title
+ * is written down when the record is, and a `deleted` event marks the records as orphans rather than
+ * removing them.
+ */
+
+import { linkKey } from "./links.ts"
+
+export type By = "agent" | "you"
+
+/** Every event: when, and an id so two readers of one line never apply it twice. */
+interface Base {
+  v: 1
+  at: number
+  id: string
+  /** The conversation it belongs to: the root session, whichever subagent made it. */
+  rootSession: string
+}
+
+/** The fields of a thing, as the agent or a person gave them (cleaned by `add.ts`). */
+export interface Fields {
+  title: string
+  url?: string
+  ref?: string
+  kind?: string
+  action: string
+  for?: string
+  note?: string
+}
+
+export type Event =
+  | (Base &
+      Fields & {
+        type: "recorded"
+        /** The session the call was made in: a subagent's own, or the conversation itself. */
+        session: string
+        sessionTitle?: string
+        by: By
+        /** The subagent that made it, when one did. */
+        subagent?: string
+      })
+  /** You took a record out of the trail (`x`). */
+  | (Base & { type: "removed"; record: string })
+  /** The conversation was deleted in OpenCode; its records stay, marked. */
+  | (Base & { type: "deleted"; sessionTitle?: string })
+
+export interface Step {
+  action: string
+  at: number
+  by: By
+  subagent?: string
+}
+
+export interface Entry {
+  /** The id of the event that first recorded it: stable for every reader of the file. */
+  key: string
+  /** The conversation (root session). */
+  session: string
+  title: string
+  url?: string
+  ref?: string
+  kind?: string
+  for?: string
+  note?: string
+  /** Who first recorded it. */
+  by: By
+  subagent?: string
+  /** Everything this conversation did to it, oldest first. */
+  history: Step[]
+  firstAt: number
+  lastAt: number
+}
+
+export interface Conversation {
+  session: string
+  title?: string
+  /** When it was deleted in OpenCode, if it was. */
+  deletedAt?: number
+  lastAt: number
+}
+
+export interface State {
+  records: Map
+  conversations: Map
+  /** Event ids already applied. */
+  seen: Set
+}
+
+export const emptyState = (): State => ({ records: new Map(), conversations: new Map(), seen: new Set() })
+
+const refKey = (ref: string) => ref.trim().toLowerCase()
+
+/**
+ * The record in `session` that a new mention of `url` / `ref` is about: the same link first, else the
+ * same ref. Nothing when it is new.
+ */
+export function matchRecord(
+  state: State,
+  session: string,
+  url: string | undefined,
+  ref: string | undefined,
+): Entry | undefined {
+  const records = [...state.records.values()].filter((record) => record.session === session)
+  if (url) {
+    const key = linkKey(url)
+    const hit = records.find((record) => record.url !== undefined && linkKey(record.url) === key)
+    if (hit) return hit
+  }
+  if (ref) {
+    const key = refKey(ref)
+    return records.find((record) => record.ref !== undefined && refKey(record.ref) === key)
+  }
+  return undefined
+}
+
+function conversation(state: State, session: string, at: number): Conversation {
+  let found = state.conversations.get(session)
+  if (!found) {
+    found = { session, lastAt: at }
+    state.conversations.set(session, found)
+  }
+  found.lastAt = Math.max(found.lastAt, at)
+  return found
+}
+
+export function apply(state: State, event: Event): State {
+  if (state.seen.has(event.id)) return state
+  state.seen.add(event.id)
+  const where = conversation(state, event.rootSession, event.at)
+  switch (event.type) {
+    case "recorded": {
+      if (event.sessionTitle) where.title = event.sessionTitle
+      const step: Step = {
+        action: event.action,
+        at: event.at,
+        by: event.by,
+        ...(event.subagent ? { subagent: event.subagent } : {}),
+      }
+      const found = matchRecord(state, event.rootSession, event.url, event.ref)
+      if (!found) {
+        state.records.set(event.id, {
+          key: event.id,
+          session: event.rootSession,
+          title: event.title,
+          ...(event.url ? { url: event.url } : {}),
+          ...(event.ref ? { ref: event.ref } : {}),
+          ...(event.kind ? { kind: event.kind } : {}),
+          ...(event.for ? { for: event.for } : {}),
+          ...(event.note ? { note: event.note } : {}),
+          by: event.by,
+          ...(event.subagent ? { subagent: event.subagent } : {}),
+          history: [step],
+          firstAt: event.at,
+          lastAt: event.at,
+        })
+        return state
+      }
+      /** What was said again replaces what was said before; what was left out is kept. */
+      found.title = event.title
+      for (const field of ["url", "ref", "kind", "for", "note"] as const) {
+        const value = event[field]
+        if (value) found[field] = value
+      }
+      found.history.push(step)
+      found.history.sort((a, b) => a.at - b.at)
+      found.lastAt = Math.max(found.lastAt, event.at)
+      return state
+    }
+    case "removed":
+      state.records.delete(event.record)
+      return state
+    case "deleted":
+      if (event.sessionTitle && !where.title) where.title = event.sessionTitle
+      where.deletedAt ??= event.at
+      return state
+  }
+}
+
+export function applyAll(state: State, events: readonly Event[]): State {
+  for (const event of events) apply(state, event)
+  return state
+}
+
+/** A record's actions as a person reads them: `created → updated`, a repeat said once. */
+export function historyText(record: Pick, joiner = " → "): string {
+  const said: string[] = []
+  for (const step of record.history) if (said.at(-1) !== step.action) said.push(step.action)
+  return said.join(joiner)
+}
+
+/**
+ * Conversations that have records but no longer exist in OpenCode: what a reader that just listed the
+ * sessions should mark `deleted`. Only roots the host was asked about can be judged, so the caller
+ * passes every live session it knows of.
+ */
+export function vanished(state: State, live: ReadonlySet): Conversation[] {
+  const out: Conversation[] = []
+  for (const conversation of state.conversations.values()) {
+    if (conversation.deletedAt !== undefined || live.has(conversation.session)) continue
+    if ([...state.records.values()].some((record) => record.session === conversation.session))
+      out.push(conversation)
+  }
+  return out
+}
+
+/** A fresh event id: unique across windows without coordinating. */
+export function newId(): string {
+  return `tr_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
+}
+
+/* ─── the file ───────────────────────────────────────────────────────────────────────────────── */
+
+const str = (value: unknown) => typeof value === "string"
+const optStr = (value: unknown) => value === undefined || typeof value === "string"
+
+function asEvent(value: unknown): Event | undefined {
+  if (!value || typeof value !== "object") return undefined
+  const raw = value as { [key: string]: unknown }
+  if (raw.v !== 1 || typeof raw.at !== "number" || !str(raw.id) || !str(raw.rootSession)) return undefined
+  switch (raw.type) {
+    case "recorded":
+      return str(raw.session) &&
+        str(raw.title) &&
+        str(raw.action) &&
+        (raw.by === "agent" || raw.by === "you") &&
+        (raw.url !== undefined || raw.ref !== undefined) &&
+        ["url", "ref", "kind", "for", "note", "sessionTitle", "subagent"].every((field) => optStr(raw[field]))
+        ? (raw as unknown as Event)
+        : undefined
+    case "removed":
+      return str(raw.record) ? (raw as unknown as Event) : undefined
+    case "deleted":
+      return optStr(raw.sessionTitle) ? (raw as unknown as Event) : undefined
+    default:
+      return undefined
+  }
+}
+
+function parseLine(line: string): Event | undefined {
+  try {
+    return asEvent(JSON.parse(line))
+  } catch {
+    /**
+     * A writer that died mid-line leaves no newline, so the next event is appended onto the remains:
+     * `{"v":1,"at":17…{"v":1,"at":18…}`. The last event start in the line is the whole one.
+     */
+    const at = line.lastIndexOf('{"v":1')
+    if (at <= 0) return undefined
+    try {
+      return asEvent(JSON.parse(line.slice(at)))
+    } catch {
+      return undefined
+    }
+  }
+}
+
+/**
+ * Events in `text`, and what is left after the last newline. The rest is not an error: it is a line
+ * another window is still writing, read again next time.
+ */
+export function parseLines(text: string): { events: Event[]; rest: string } {
+  const end = text.lastIndexOf("\n")
+  const whole = end < 0 ? "" : text.slice(0, end)
+  const rest = end < 0 ? text : text.slice(end + 1)
+  const events: Event[] = []
+  for (const line of whole.split("\n")) {
+    if (line.trim() === "") continue
+    const event = parseLine(line)
+    if (event) events.push(event)
+  }
+  return { events, rest }
+}
+
+/** One line of the file. Never contains a newline, whatever a title holds. */
+export const serialize = (event: Event): string => `${JSON.stringify(event)}\n`
diff --git a/packages/trail/src/core/text.ts b/packages/trail/src/core/text.ts
new file mode 100644
index 00000000..f189f361
--- /dev/null
+++ b/packages/trail/src/core/text.ts
@@ -0,0 +1,231 @@
+/**
+ * Everything the agent reads from Trail: the tools' descriptions, the guidance in its system prompt,
+ * the two per-conversation lines added on every request, and what `trail_add` and `trail_list`
+ * answer. Written to be read by a model (principles.md, rule 3) — what changed first, then what to
+ * do next — and drawn from the same `arrange` as the dialog (rule 2).
+ *
+ * Why the guidance carries the examples: OpenCode 2 shows a plugin tool's description cut to about
+ * 115 characters in its Code Mode catalog (docs/opencode/trail-server.md), so the description says
+ * *when* to call in its first sentence, and the examples live in the system prompt, which every
+ * request carries whole. It names `tools.trail_add`, so a model in Code Mode can call it without
+ * searching the catalog first.
+ */
+
+import {
+  type Arranged,
+  arrange,
+  conversationThings,
+  type Found,
+  lastOf,
+  linesOf,
+  projectThings,
+  type Thing,
+} from "./model.ts"
+import { historyText, type State } from "./store.ts"
+import { ago, cut } from "./view/rows.ts"
+
+/* ─── the tools ──────────────────────────────────────────────────────────────────────────────── */
+
+/** When to call, first: on OpenCode 2 the catalog shows only the first ~115 characters. */
+export const ADD_DESCRIPTION = `Call right after you create or change something outside this repo — a PR, ticket, doc page, deploy — to record it. \
+The trail is how the user finds, later, what this conversation made, and which conversation made it. \
+One call per thing; calling again with the same url updates its record (a better title fixes it). \
+Required: title, and url or ref.`
+
+export const ADD_ARGS = {
+  title: "The thing's own title, as a person would recognise it: the PR's title, the page's title. Required.",
+  url: "The link that opens it (http/https). Give url, ref, or both.",
+  ref: 'The name people search for: "COM-1736", "owner/repo#33", a commit hash. Read from GitHub PR and issue links when left out.',
+  kind: 'What it is, in your words: "pull request", "Confluence page", "deploy".',
+  action:
+    "What you did: created, updated, merged, published… Defaults to created, or updated when it is already in the trail.",
+  for: 'The ref or url of what this belongs to — the ticket a PR is for: "COM-1736".',
+  note: "One optional line worth keeping.",
+} as const
+
+export const LIST_DESCRIPTION = `What this conversation recorded with trail_add (PRs, tickets, pages, deploys) — or, with all, every conversation in this project, and which one made each. \
+query filters by title, ref, kind or system (GitHub, Jira…). Use it to answer "what did we make for COM-1736?" or "which conversation opened the auth PR?".`
+
+export const LIST_ARGS = {
+  all: "true: every conversation in this project, not only this one.",
+  query: 'Words that must all appear in the title, ref, kind or system, e.g. "COM-1736" or "jira".',
+} as const
+
+/** The system-prompt guidance: always loaded, subagents included. */
+export const GUIDANCE = `## Trail (opencode-cockpit)
+When you create or change something outside this repository's files — a pull request, a ticket, a doc page, an artifact, a deploy — record it right away with trail_add (in Code Mode: \`await tools.trail_add({...})\`). Use the thing's own title and its link; when the work is for a ticket or epic, put its key in \`for\` so the record groups under it. A subagent records what it makes itself.
+  tools.trail_add({ title: "Fix bundle desync on reconnect", url: "https://github.com/acme/web/pull/33", action: "created", for: "COM-1736" })
+  tools.trail_add({ title: "Release notes for 0.8", url: "https://acme.atlassian.net/wiki/x/AbC123", kind: "Confluence page", action: "updated" })
+Not for files in this repository, or for links you only read or printed. Before saying what this work produced, call trail_list (all, query) — don't answer from memory.`
+
+/* ─── naming a thing ─────────────────────────────────────────────────────────────────────────── */
+
+/** `PR #33 "Fix bundle desync"` — how a sentence names a thing. */
+export function named(thing: Pick, room = 80): string {
+  const title = `"${cut(thing.title, room)}"`
+  return thing.label ? `${thing.label} ${title}` : title
+}
+
+/** `by subagent explore`, `added by you`, or nothing for the agent itself. */
+function whoText(touch: { by: string; subagent?: string }): string {
+  if (touch.by === "you") return "added by you"
+  return touch.subagent ? `by subagent ${touch.subagent}` : ""
+}
+
+/* ─── trail_add's answer ─────────────────────────────────────────────────────────────────────── */
+
+export interface Added {
+  thing: Thing
+  /** It was already in this conversation's trail. */
+  merged: boolean
+  /** How many things this conversation's trail holds now. */
+  count: number
+  stripped: string[]
+  /** The `for` names nothing this conversation recorded. */
+  forMissing?: string
+}
+
+/** What `trail_add` answers: what changed, then anything to know. */
+export function addedText(added: Added): string {
+  const { thing } = added
+  const where = thing.system ? ` (${thing.system})` : ""
+  const lines = [
+    added.merged
+      ? `Updated the existing record of ${named(thing)}${where}: ${historyText(thing)}. Recording the same url again never makes a second row.`
+      : `Recorded ${named(thing)}${where} — ${lastOf(thing).action}.`,
+  ]
+  lines.push(
+    `This conversation's trail holds ${added.count} ${added.count === 1 ? "thing" : "things"}; the user sees it in the sidebar and /trail.`,
+  )
+  if (added.stripped.length > 0)
+    lines.push(
+      `Removed from the link before storing, because it looked like a secret: ${added.stripped.join(", ")}.`,
+    )
+  if (added.forMissing)
+    lines.push(
+      `"${added.forMissing}" is not in this conversation's trail itself; if this conversation created or changed it, record it too.`,
+    )
+  return lines.join("\n")
+}
+
+/* ─── trail_list's answer ────────────────────────────────────────────────────────────────────── */
+
+export interface ListInput {
+  state: State
+  /** The conversation asking. */
+  session: string
+  all: boolean
+  query: string
+  now: number
+}
+
+function thingLine(thing: Thing, depth: number, input: ListInput): string[] {
+  const last = lastOf(thing)
+  const facts = [
+    named(thing, 120),
+    ...(thing.ref && thing.ref !== thing.label ? [thing.ref] : []),
+    ...(thing.system ? [thing.system] : []),
+    ...(thing.kind && thing.kind !== thing.label ? [thing.kind] : []),
+  ]
+  const indent = "  ".repeat(depth)
+  const lines = [`${indent}- ${facts.join(" · ")}`]
+  if (!input.all) {
+    const touch = thing.touches[0]
+    const who = touch ? whoText(touch) : ""
+    lines[0] += ` · ${historyText(thing)} ${ago(input.now - last.at)}${who ? ` · ${who}` : ""}`
+  }
+  if (thing.url) lines.push(`${indent}  ${thing.url}`)
+  if (thing.note) lines.push(`${indent}  note: ${thing.note}`)
+  if (input.all)
+    for (const touch of thing.touches) {
+      const title = touch.title ? `"${cut(touch.title, 60)}"` : "untitled"
+      const marks = [touch.session, ...(touch.deleted ? ["deleted"] : [])].join(", ")
+      const who = whoText(touch)
+      lines.push(
+        `${indent}  in ${title} (${marks}) — ${historyText(touch)} ${ago(input.now - (touch.history.at(-1)?.at ?? touch.lastAt))}${who ? ` · ${who}` : ""}${touch.session === input.session ? " — this conversation" : ""}`,
+      )
+    }
+  return lines
+}
+
+/** The trail as `trail_list` answers it: the same groups and order as `/trail`. */
+export function listText(input: ListInput): string {
+  const things = input.all ? projectThings(input.state) : conversationThings(input.state, input.session)
+  const arranged = arrange(things, input.query)
+  const scope = input.all ? "this project's conversations" : "this conversation"
+  if (arranged.total === 0)
+    return input.all
+      ? "Nothing is in the trail for this project yet. Record what you create or change outside the repository with trail_add."
+      : "Nothing is in this conversation's trail yet. If it created or changed something outside the repository — a PR, a ticket, a page — record it with trail_add. trail_list with all: true looks across the project's other conversations."
+  if (arranged.shown === 0)
+    return `Nothing in ${scope} matches "${input.query}" (searched title, ref, kind and system). ${input.all ? "Try fewer words." : "Try all: true for every conversation, or fewer words."}`
+  const count = input.query
+    ? `${arranged.shown} of ${arranged.total} things`
+    : `${arranged.total} ${arranged.total === 1 ? "thing" : "things"}`
+  const order = arranged.shown > 1 ? ", grouped by what they are for, newest work first" : ""
+  const head = `${count} in ${scope}${input.query ? ` matching "${input.query}"` : ""}${order}:`
+  return [head, ...bodyLines(arranged, input)].join("\n")
+}
+
+function bodyLines(arranged: Arranged, input: ListInput): string[] {
+  const out: string[] = []
+  for (const line of linesOf(arranged))
+    if (line.kind === "head") out.push(`- ${line.name} (not recorded itself)`)
+    else out.push(...thingLine(line.thing, line.depth, input))
+  return out
+}
+
+/* ─── the lines added to every request ───────────────────────────────────────────────────────── */
+
+/** How many things the "produced" line names before `+ N more`. */
+export const PRODUCED_LIMIT = 6
+
+/**
+ * What this conversation produced, in one line, rebuilt from the trail on every request — so it
+ * survives compaction (a summary once said trail_add was never called when it had been). Nothing
+ * when nothing is recorded: the guidance already says what to do.
+ */
+export function producedLine(state: State, session: string): string | undefined {
+  const lines = linesOf(arrange(conversationThings(state, session))).flatMap((line) =>
+    line.kind === "thing" ? [line.thing] : [],
+  )
+  if (lines.length === 0) return undefined
+  const shown = lines.slice(0, PRODUCED_LIMIT).map((thing) => `${named(thing, 50)} (${historyText(thing)})`)
+  const more = lines.length - shown.length
+  return `Trail — this conversation produced: ${shown.join("; ")}${more > 0 ? `; + ${more} more (trail_list)` : ""}.`
+}
+
+/** How many links the reminder names at most. */
+export const SEEN_LIMIT = 5
+
+/**
+ * The safety net's reminder: PR and issue links in the output of something the agent ran, not yet
+ * recorded. Worded as a choice — both models declined to record a link they had only printed, and
+ * that is right; the trail is what the conversation made.
+ */
+export function seenLine(found: readonly Found[]): string | undefined {
+  if (found.length === 0) return undefined
+  const urls = found.slice(0, SEEN_LIMIT).map((each) => each.url)
+  const more = found.length - urls.length
+  return `Seen in output — record it with tools.trail_add if you created or changed it: ${urls.join(", ")}${more > 0 ? ` (+ ${more} more)` : ""}`
+}
+
+/* ─── copy as markdown ───────────────────────────────────────────────────────────────────────── */
+
+const escapeMd = (text: string) => text.replace(/([\\[\]])/g, "\\$1")
+
+function markdownItem(thing: Thing, depth: number): string {
+  const name = thing.label ? `${thing.label} — ${thing.title}` : thing.title
+  const link =
+    thing.openable && thing.url ? `[${escapeMd(name)}](${thing.url.replace(/\)/g, "%29")})` : escapeMd(name)
+  const facts = [...(thing.system ? [thing.system] : []), historyText(thing)]
+  return `${"  ".repeat(depth)}- ${link} · ${facts.join(" · ")}`
+}
+
+/** A trail as a markdown list — for a PR description, a standup, a ticket comment. */
+export function markdownOf(arranged: Arranged): string {
+  const out: string[] = []
+  for (const line of linesOf(arranged))
+    out.push(line.kind === "head" ? `- **${escapeMd(line.name)}**` : markdownItem(line.thing, line.depth))
+  return out.join("\n")
+}
diff --git a/packages/trail/src/core/tools.ts b/packages/trail/src/core/tools.ts
new file mode 100644
index 00000000..97f14f66
--- /dev/null
+++ b/packages/trail/src/core/tools.ts
@@ -0,0 +1,63 @@
+/**
+ * The two agent tools as plain functions of the trail, so either OpenCode's adapter is only wiring:
+ * read the file, call one of these, append what it returns.
+ */
+
+import { checkAdd, checkList, recordedEvent, type Who } from "./add.ts"
+import { workOf } from "./links.ts"
+import { conversationThings, type Thing } from "./model.ts"
+import { apply, type Event, matchRecord, type State } from "./store.ts"
+import { addedText, listText } from "./text.ts"
+
+export type AddOutcome =
+  | { ok: false; text: string }
+  /** `event` is what to append; `state` already holds it. */
+  | { ok: true; text: string; event: Extract; thing: Thing }
+
+/** `trail_add`: checked, folded into `state`, and answered. Nothing is written here. */
+export function runAdd(state: State, args: unknown, who: Who): AddOutcome {
+  const checked = checkAdd(args)
+  if (!checked.ok) return { ok: false, text: checked.error }
+  const merged = matchRecord(state, who.rootSession, checked.fields.url, checked.fields.ref) !== undefined
+  const event = recordedEvent(checked.fields, who, state)
+  apply(state, event)
+  const record = matchRecord(state, who.rootSession, event.url, event.ref)
+  const things = conversationThings(state, who.rootSession)
+  const thing = things.find((each) => each.key === record?.key) as Thing
+  const target = event.for
+  const forMissing =
+    target !== undefined &&
+    matchRecord(state, who.rootSession, /^https?:/.test(target) ? target : undefined, target) === undefined
+  return {
+    ok: true,
+    event,
+    thing,
+    text: addedText({
+      thing,
+      merged,
+      count: things.length,
+      stripped: checked.stripped,
+      ...(forMissing && target ? { forMissing: target } : {}),
+    }),
+  }
+}
+
+/**
+ * What a person typed into `/link` — ` [note]` — as `trail_add`'s arguments. A person adding a
+ * link by hand has no title to give, so the note is the title when there is one (it is what they
+ * will recognise), else a PR or issue's own name (`owner/repo#33`), else the link without its scheme.
+ * Everything else is `checkAdd`'s to judge, so a person and the agent are refused the same way.
+ */
+export function linkArgs(text: string): { title: string; url: string } | undefined {
+  const [url = "", ...rest] = text.trim().split(/\s+/)
+  if (!url) return undefined
+  const note = rest.join(" ").trim()
+  if (note) return { title: note, url }
+  const work = workOf(url)
+  return { title: work ? work.ref : url.replace(/^https?:\/\//i, "").replace(/\/+$/, ""), url }
+}
+
+/** `trail_list`: the trail as text, the same order as `/trail`. */
+export function runList(state: State, args: unknown, session: string, now: number): string {
+  return listText({ state, session, now, ...checkList(args) })
+}
diff --git a/packages/trail/src/core/view/dialog.ts b/packages/trail/src/core/view/dialog.ts
new file mode 100644
index 00000000..f0bf8e3e
--- /dev/null
+++ b/packages/trail/src/core/view/dialog.ts
@@ -0,0 +1,424 @@
+/**
+ * `/trail`: the whole trail, for this conversation or every conversation in the project.
+ *
+ *    Trail · opencode-cockpit         [tab]  This conversation   All conversations
+ *
+ *    COM-1736    Bundle desync                  Jira   updated             2h ago ↗
+ *      PR #33    0.8: Trust, one design s…    GitHub   created → updated   1h ago ↗
+ *    Rollout checklist · docs                 Claude   created            30m ago ↗
+ *
+ * A record with no ref gives its title the ref's column (`refOf`), and the time is when, said so
+ * (`since`), as in the sidebar.
+ *
+ *    [enter] Open   [g] Go   [c] Copy   [x] Remove   [/] Search   …   [esc] Close
+ *
+ * The same `arrange` as the sidebar and `trail_list`, so the three cannot disagree. *All
+ * conversations* lists, under a thing other conversations touched too, each conversation that did —
+ * a row of its own, so `g` on it goes there (to the root: a subagent's view has no sidebar,
+ * docs/opencode/trail-interface.md). A conversation since deleted keeps its row, marked.
+ *
+ * Keys with nothing to act on under the cursor are dimmed, not removed (design-system.md); narrow,
+ * a dimmed key gives way a little before a live one of the same rank.
+ */
+
+import { closeHint, fitHints, type Hint } from "@opencode-cockpit/client/design"
+import {
+  type Arranged,
+  arrange,
+  conversationThings,
+  lastOf,
+  linesOf,
+  projectThings,
+  type Thing,
+  type Touch,
+} from "../model.ts"
+import { historyText, type State } from "../store.ts"
+import { cursorRow, cut, fit, type Row, type Run, refOf, since, spread, widthOf } from "./rows.ts"
+
+export type Tab = "this" | "all"
+
+export const TAB_NAMES: Readonly<{ [T in Tab]: { long: string; short: string } }> = {
+  this: { long: "This conversation", short: "This" },
+  all: { long: "All conversations", short: "All" },
+}
+
+export interface DialogInput {
+  width: number
+  height: number
+  tab: Tab
+  state: State
+  /** The conversation on screen (its root session). */
+  session: string
+  now: number
+  /** The project's folder name, for the header. */
+  project: string
+  /** The key of the item under the cursor; the first when unset or gone. */
+  selected?: string
+  query?: string
+  /** `/` was pressed: the query is being typed. */
+  searching?: boolean
+}
+
+/** Something the cursor can sit on. */
+export type Item =
+  | { kind: "thing"; key: string; thing: Thing }
+  /** One conversation's part in a thing, under it in All conversations. */
+  | { kind: "touch"; key: string; thing: Thing; touch: Touch }
+
+/** What the keys do to the item under the cursor. Absent: nothing to do, and the key is dimmed. */
+export interface Target {
+  /** `enter`: the page to open (http(s) only). */
+  open?: string
+  /** `g`: the conversation to go to. */
+  go?: string
+  /** `c`: what to copy — the link, else the ref. */
+  copy?: string
+  /** `x`: the record to remove. */
+  remove?: string
+}
+
+export interface DialogView {
+  rows: Row[]
+  items: Item[]
+  item?: Item
+  target: Target
+  /** The rows a click selects. */
+  hits: { y: number; key: string }[]
+  arranged: Arranged
+}
+
+/** The fewest rows the dialog is drawn in: header, a row of air, a few rows, air, keys. */
+export const MIN_HEIGHT = 8
+const LABEL_MAX = 14
+const SYSTEM_MAX = 14
+const HISTORY_MAX = 26
+const HISTORY_USUAL = 17
+const LABEL_NARROW = 10
+/** At least this much of the row is the title before a column is let go for it. */
+const titleMin = (width: number) => Math.max(20, Math.floor(width * 0.32))
+const INDENT = 2
+
+const muted = (text: string): Run => ({ text, tone: "muted" })
+const keyRun = (name: string): Run => ({ text: `[${name}]`, tone: "accent", bold: true })
+const chip = (system: string): Run => ({ text: ` ${system} `, tone: "info", fill: "chip" })
+const pad = (text: string, room: number) => `${text}${" ".repeat(Math.max(0, room - widthOf(text)))}`
+const cell = (text: string, room: number) => pad(cut(text, room), room)
+
+export function targetOf(item: Item | undefined, tab: Tab, session: string): Target {
+  if (!item) return {}
+  const { thing } = item
+  const copy = thing.url ?? thing.ref ?? thing.title
+  const open = thing.openable ? thing.url : undefined
+  const touch = item.kind === "touch" ? item.touch : tab === "all" ? thing.touches[0] : undefined
+  const go = touch && touch.session !== session ? touch.session : undefined
+  /** In All conversations a thing several conversations touched is removed one conversation at a time. */
+  const remove =
+    item.kind === "touch"
+      ? item.touch.record
+      : thing.touches.length === 1
+        ? thing.touches[0]?.record
+        : undefined
+  return {
+    ...(open ? { open } : {}),
+    ...(go ? { go } : {}),
+    copy,
+    ...(remove ? { remove } : {}),
+  }
+}
+
+function hintsFor(target: Target, searching: boolean): Hint[] {
+  if (searching)
+    return [
+      { key: "enter", label: "Done", priority: 9 },
+      { key: "esc", label: "Cancel", close: true },
+    ]
+  /** A key with something to do under the cursor gives way after a dimmed one of about its rank. */
+  const hint = (key: string, label: string, priority: number, on: unknown): Hint =>
+    on ? { key, label, priority: priority + 3 } : { key, label, priority, off: true }
+  return [
+    hint("enter", "Open", 9, target.open),
+    hint("g", "Go", 6, target.go),
+    hint("c", "Copy", 7, target.copy),
+    hint("x", "Remove", 4, target.remove),
+    hint("/", "Search", 8, true),
+    hint("m", "Markdown", 1, true),
+    closeHint(),
+  ]
+}
+
+function headerRow(input: DialogInput, width: number): Row {
+  const left: Run[] = [
+    { text: " Trail", tone: "text", bold: true },
+    ...(input.project ? [muted(` · ${input.project}`)] : []),
+  ]
+  const tabs = (long: boolean): Run[] => [
+    keyRun("tab"),
+    { text: " " },
+    ...(["this", "all"] as const).flatMap((tab, at): Run[] => {
+      const name = long ? TAB_NAMES[tab].long : TAB_NAMES[tab].short
+      return [
+        ...(at > 0 ? [{ text: " " }] : []),
+        tab === input.tab
+          ? { text: ` ${name} `, tone: "text", fill: "chip", bold: true }
+          : { text: ` ${name} `, tone: "muted" },
+      ]
+    }),
+    { text: " " },
+  ]
+  const textOf = (runs: Run[]) => runs.map((run) => run.text).join("")
+  const long = tabs(true)
+  const room = width - widthOf(textOf(left)) - 2
+  return spread(left, widthOf(textOf(long)) <= room ? long : tabs(false), width)
+}
+
+interface Columns {
+  label: number
+  system: number
+  history: number
+  age: number
+}
+
+function columnsFor(
+  things: readonly { thing: Thing; depth: number }[],
+  input: DialogInput,
+  width: number,
+): Columns {
+  const most = (values: number[]) => Math.max(0, ...values)
+  const full: Columns = {
+    label: Math.min(
+      LABEL_MAX,
+      most(things.map(({ thing, depth }) => widthOf(refOf(thing) ?? "") + depth * INDENT)),
+    ),
+    system: Math.min(
+      SYSTEM_MAX,
+      most(things.map(({ thing }) => (thing.system ? widthOf(thing.system) + 2 : 0))),
+    ),
+    history: Math.min(HISTORY_MAX, most(things.map(({ thing }) => widthOf(historyText(thing))))),
+    age: most(things.map(({ thing }) => widthOf(since(input.now - lastOf(thing).at)))),
+  }
+  /** Margin, label and its gap; then each right column and its gap, the age, the mark and a margin. */
+  const titleRoom = (c: Columns) =>
+    width -
+    1 -
+    (c.label ? c.label + 2 : 0) -
+    (c.system ? c.system + 2 : 0) -
+    (c.history ? c.history + 2 : 0) -
+    c.age -
+    3
+  /** Narrower, the columns give way in turn: the history to its usual length, the ref's width, then
+   *  the history, then the system. The title keeps at least `titleMin` while anything can go. */
+  const usual = { ...full, history: Math.min(full.history, HISTORY_USUAL) }
+  const tries: Columns[] = [
+    full,
+    usual,
+    { ...usual, label: Math.min(usual.label, LABEL_NARROW) },
+    { ...usual, label: Math.min(usual.label, LABEL_NARROW), history: 0 },
+    { ...usual, label: Math.min(usual.label, LABEL_NARROW), history: 0, system: 0 },
+  ]
+  return tries.find((c) => titleRoom(c) >= titleMin(width)) ?? (tries.at(-1) as Columns)
+}
+
+function rightOf(
+  system: string | undefined,
+  history: string,
+  at: number,
+  openable: boolean,
+  columns: Columns,
+  now: number,
+): Run[] {
+  return [
+    ...(columns.system
+      ? [
+          { text: " ".repeat(Math.max(0, columns.system - (system ? widthOf(system) + 2 : 0))) },
+          ...(system ? [chip(cut(system, columns.system - 2))] : []),
+          { text: "  " },
+        ]
+      : []),
+    ...(columns.history ? [muted(cell(history, columns.history)), { text: "  " }] : []),
+    muted(since(now - at).padStart(columns.age)),
+    { text: openable ? " ↗ " : "   ", tone: "muted" },
+  ]
+}
+
+function thingRow(thing: Thing, depth: number, input: DialogInput, columns: Columns, width: number): Row {
+  const last = lastOf(thing)
+  const indent = " ".repeat(depth * INDENT)
+  const who = thing.touches[0]
+  const by =
+    input.tab === "this" && who
+      ? who.by === "you"
+        ? " · you"
+        : who.subagent
+          ? ` · ${who.subagent}`
+          : ""
+      : ""
+  /** With no ref, the title starts where the ref would; its kind is not repeated beside the chip. */
+  const ref = refOf(thing)
+  return spread(
+    [
+      { text: " " },
+      ...(columns.label && ref
+        ? [muted(cell(`${indent}${ref}`, columns.label)), { text: "  " }]
+        : [{ text: indent }]),
+      { text: thing.title, tone: "text" },
+      ...(by ? [muted(by)] : []),
+    ],
+    rightOf(thing.system, historyText(thing), last.at, thing.openable, columns, input.now),
+    width,
+  )
+}
+
+function touchRow(touch: Touch, depth: number, input: DialogInput, columns: Columns, width: number): Row {
+  const indent = " ".repeat((depth + 1) * INDENT)
+  const name = touch.title ? `“${touch.title}�` : "untitled conversation"
+  const marks = [
+    ...(touch.session === input.session ? ["this conversation"] : []),
+    ...(touch.deleted ? ["deleted"] : []),
+    ...(touch.subagent ? [touch.subagent] : []),
+    ...(touch.by === "you" ? ["you"] : []),
+  ]
+  const at = touch.history.at(-1)?.at ?? touch.lastAt
+  return spread(
+    [
+      { text: " " },
+      ...(columns.label ? [{ text: " ".repeat(columns.label + 2) }] : []),
+      muted(`${indent}↳ `),
+      { text: name, tone: touch.deleted ? "muted" : "text" },
+      ...(marks.length > 0 ? [muted(` · ${marks.join(" · ")}`)] : []),
+    ],
+    rightOf(undefined, historyText(touch), at, false, { ...columns, system: 0 }, input.now),
+    width,
+  )
+}
+
+/** Wrapped muted prose, a cell of margin each side: what an empty tab says. */
+function prose(text: string, width: number): Row[] {
+  const room = Math.max(8, width - 4)
+  const out: Row[] = []
+  let line = ""
+  for (const word of text.split(" ")) {
+    if (line && widthOf(`${line} ${word}`) > room) {
+      out.push(fit([muted(`   ${line}`)], width))
+      line = word
+    } else line = line ? `${line} ${word}` : word
+  }
+  if (line) out.push(fit([muted(`   ${line}`)], width))
+  return out
+}
+
+export function dialogRows(input: DialogInput): DialogView {
+  const width = Math.max(20, input.width)
+  const height = Math.max(MIN_HEIGHT, input.height)
+  const query = input.query ?? ""
+  const things =
+    input.tab === "all" ? projectThings(input.state) : conversationThings(input.state, input.session)
+  const arranged = arrange(things, query)
+  const lines = linesOf(arranged)
+
+  /** The body, each row with the item it selects. */
+  const body: { row: Row; item?: Item }[] = []
+  const placed = lines.flatMap((line) => (line.kind === "thing" ? [line] : []))
+  const columns = columnsFor(placed, input, width)
+
+  if (arranged.total === 0)
+    body.push(
+      {
+        row: fit(
+          [
+            {
+              text:
+                input.tab === "this"
+                  ? " Nothing recorded in this conversation yet."
+                  : " Nothing recorded in this project yet.",
+              tone: "text",
+            },
+          ],
+          width,
+        ),
+      },
+      ...prose(
+        "The agent records what it creates or changes outside the repository — a PR, a ticket, a page, a deploy — with trail_add. Add one yourself: /link, then paste the link (and a note).",
+        width,
+      ).map((row) => ({ row })),
+    )
+  else if (arranged.shown === 0)
+    body.push({ row: fit([{ text: ` Nothing matches “${query}�.`, tone: "text" }], width) })
+
+  for (const line of lines) {
+    if (line.kind === "head") {
+      body.push({
+        row: fit(
+          [{ text: " " }, { text: line.name, tone: "text", bold: true }, muted("  not recorded itself")],
+          width,
+        ),
+      })
+      continue
+    }
+    const { thing, depth } = line
+    body.push({
+      row: thingRow(thing, depth, input, columns, width),
+      item: { kind: "thing", key: thing.key, thing },
+    })
+    /** A thing only this conversation touched needs no row saying so: there is nowhere to go. */
+    const elsewhere = thing.touches.some((touch) => touch.session !== input.session)
+    if (input.tab === "all" && elsewhere)
+      for (const touch of thing.touches)
+        body.push({
+          row: touchRow(touch, depth, input, columns, width),
+          item: { kind: "touch", key: `${thing.key}\n${touch.session}`, thing, touch },
+        })
+  }
+
+  const items = body.flatMap((entry) => (entry.item ? [entry.item] : []))
+  const item = items.find((each) => each.key === input.selected) ?? items[0]
+  const target = targetOf(item, input.tab, input.session)
+
+  /* The frame: header, search, air, the body's window, air, keys. */
+  const rows: Row[] = [headerRow(input, width)]
+  if (input.searching || query) {
+    const count = query
+      ? muted(`${arranged.shown} of ${arranged.total} `)
+      : muted("title, ref, kind, system ")
+    rows.push(
+      spread(
+        [
+          { text: " " },
+          keyRun("/"),
+          { text: " " },
+          { text: query, tone: "text" },
+          ...(input.searching ? [{ text: "â–�", tone: "accent" as const }] : []),
+        ],
+        [count],
+        width,
+      ),
+    )
+  }
+  rows.push(fit([], width))
+  const room = height - rows.length - 2
+  const at = Math.max(
+    0,
+    body.findIndex((entry) => entry.item === item),
+  )
+  let start = 0
+  let end = body.length
+  if (body.length > room) {
+    /** Keep the cursor in view, a row of the list above it when there is one. */
+    start = Math.min(Math.max(0, at - 1), body.length - room)
+    end = start + room
+    if (start > 0) start++
+    if (end < body.length) end--
+  }
+  const hits: { y: number; key: string }[] = []
+  if (start > 0) rows.push(fit([muted(` ↑ ${start} more`)], width))
+  for (const entry of body.slice(start, end)) {
+    if (entry.item) hits.push({ y: rows.length, key: entry.item.key })
+    rows.push(entry.item && entry.item === item ? cursorRow(entry.row, width) : entry.row)
+  }
+  if (end < body.length) rows.push(fit([muted(` ↓ ${body.length - end} more`)], width))
+  while (rows.length < height - 2) rows.push(fit([], width))
+  rows.push(fit([], width))
+  const fitted = fitHints(hintsFor(target, input.searching === true), Math.max(0, width - 2))
+  rows.push(fit([{ text: " " }, ...fitted.runs], width))
+
+  return { rows, items, ...(item ? { item } : {}), target, hits, arranged }
+}
diff --git a/packages/trail/src/core/view/rows.ts b/packages/trail/src/core/view/rows.ts
new file mode 100644
index 00000000..6878a151
--- /dev/null
+++ b/packages/trail/src/core/view/rows.ts
@@ -0,0 +1,202 @@
+/**
+ * Rows of styled runs — every Trail surface, pure so a test can measure it.
+ *
+ * Tones name a meaning, never a colour; the interface's one render file is the only place that knows
+ * what a tone looks like, so the preview CLI and OpenCode draw the same rows
+ * (docs/building/terminal-ui.md). The helpers are Trust's (`core/view/rows.ts`), copied rather than
+ * imported: bays never import each other.
+ */
+
+export type Tone =
+  | "text"
+  | "muted"
+  | "accent"
+  | "info"
+  | "tool"
+  | "success"
+  | "error"
+  | "warning"
+  | "border"
+  /** Text cut out of a solid fill (a focused button): the colour of what the dialog is drawn on. */
+  | "ink"
+
+/**
+ * What sits behind a run. Two cover a whole row — the row under the cursor and the card's raised
+ * panel — and the rest a few cells: an agent's chip, a button, a focused button, and the tinted
+ * badges that say success, warning and error. A tint is the tone's colour faded into the dialog's
+ * own, so it follows the theme (`TINTS`; `tui/render.ts` mixes it).
+ */
+export type Fill = "none" | "selected" | "panel" | "chip" | "button" | "buttonOn" | "ok" | "warn" | "err"
+
+/** The fills that belong to a row rather than a run: what a row is padded with, and what a cursor replaces. */
+const ROW_FILLS: ReadonlySet = new Set(["selected", "panel"])
+const isRowFill = (fill: Fill | undefined): fill is Fill => fill !== undefined && ROW_FILLS.has(fill)
+
+/**
+ * The tinted fills: which tone each is made of, and how much of it is mixed into the dialog's
+ * background. Little enough that the tone's own text on it stays legible; enough to be seen.
+ */
+export const TINTS: Readonly> = {
+  chip: { tone: "info", amount: 0.12 },
+  ok: { tone: "success", amount: 0.16 },
+  warn: { tone: "warning", amount: 0.16 },
+  err: { tone: "error", amount: 0.16 },
+}
+
+export interface Run {
+  text: string
+  tone?: Tone
+  fill?: Fill
+  bold?: boolean
+  faint?: boolean
+}
+
+export type Row = Run[]
+
+export const rowText = (row: Row): string => row.map((run) => run.text).join("")
+
+/** Columns a string takes. Wide characters (CJK, most emoji) take two. */
+export function widthOf(text: string, limit = Number.POSITIVE_INFINITY): number {
+  let width = 0
+  for (const char of text) {
+    if (width > limit) return width
+    const code = char.codePointAt(0) ?? 0
+    width +=
+      code >= 0x1100 &&
+      (code <= 0x115f ||
+        (code >= 0x2e80 && code <= 0xa4cf) ||
+        (code >= 0xac00 && code <= 0xd7a3) ||
+        (code >= 0xf900 && code <= 0xfaff) ||
+        (code >= 0xfe30 && code <= 0xfe4f) ||
+        (code >= 0xff00 && code <= 0xff60) ||
+        (code >= 0xffe0 && code <= 0xffe6) ||
+        (code >= 0x1f300 && code <= 0x1faff) ||
+        (code >= 0x20000 && code <= 0x3fffd))
+        ? 2
+        : 1
+  }
+  return width
+}
+
+/** `text` in at most `width` columns, ending in `…` when cut. */
+export function cut(text: string, width: number): string {
+  if (width <= 0) return ""
+  if (widthOf(text, width) <= width) return text
+  let out = ""
+  let used = 0
+  for (const char of text) {
+    const w = widthOf(char)
+    if (used + w > width - 1) break
+    out += char
+    used += w
+  }
+  return `${out}…`
+}
+
+/**
+ * A row exactly `width` columns wide: runs cut where they overflow, padded in the last run's fill
+ * where they fall short. Every row every surface draws goes through here; the grid test holds it.
+ */
+export function fit(row: Row, width: number): Row {
+  const out: Row = []
+  let used = 0
+  for (const run of row) {
+    if (used >= width) break
+    const room = width - used
+    const w = widthOf(run.text, room)
+    if (w <= room) {
+      out.push(run)
+      used += w
+    } else {
+      const text = cut(run.text, room)
+      out.push({ ...run, text })
+      used += widthOf(text)
+      break
+    }
+  }
+  if (used < width) {
+    const fill = rowFillOf(out)
+    out.push({ text: " ".repeat(width - used), ...(fill ? { fill } : {}) })
+  }
+  return out
+}
+
+/** The fill a row is drawn on: the last row-wide fill among its runs. A chip at its end is not one. */
+export function rowFillOf(row: Row): Fill | undefined {
+  for (let i = row.length - 1; i >= 0; i--) {
+    const fill = row[i]?.fill
+    if (isRowFill(fill)) return fill
+  }
+  return undefined
+}
+
+/** Left and right parts on one row, the right part flush against the edge; the left gives way. */
+export function spread(left: Row, right: Row, width: number): Row {
+  const rightWidth = widthOf(rowText(right))
+  if (rightWidth >= width) return fit(right, width)
+  const leftRoom = width - rightWidth - 1
+  const shown = fit(left, leftRoom)
+  const fill = rowFillOf([...left, ...right])
+  return [...shown, { text: " ", ...(fill ? { fill } : {}) }, ...right]
+}
+
+/**
+ * A row on one surface — the row under the cursor, the card's panel. A run with a fill of its own
+ * (a chip, a badge, a button) keeps it: the surface is what is behind them.
+ */
+export const filled = (row: Row, fill: Fill): Row =>
+  row.map((run) =>
+    run.fill === undefined || run.fill === "none" || isRowFill(run.fill) ? { ...run, fill } : run,
+  )
+
+/**
+ * The row under the cursor: `▌` in its first cell — the margin every row keeps for it — and the
+ * selected fill to both edges (docs/building/design-system.md: `▌` is the cursor and nothing else).
+ */
+export function cursorRow(row: Row, width: number): Row {
+  const [first, ...rest] = row
+  const trimmed = !first
+    ? []
+    : widthOf(first.text) > 1
+      ? [{ ...first, text: [...first.text].slice(1).join("") }, ...rest]
+      : rest
+  return filled(fit([{ text: "▌", tone: "accent" }, ...trimmed], width), "selected")
+}
+
+/**
+ * How long ago, in the fewest characters that still read: `now`, `5m`, `2h`, `3d`. In a column of
+ * times, "ago" on every one says nothing.
+ */
+export function age(ms: number): string {
+  const s = Math.max(0, Math.floor(ms / 1000))
+  if (s < 60) return "now"
+  const m = Math.floor(s / 60)
+  if (m < 60) return `${m}m`
+  const h = Math.floor(m / 60)
+  if (h < 48) return `${h}h`
+  return `${Math.floor(h / 24)}d`
+}
+
+/** The same in a sentence: `just now`, `5m ago`. */
+export const ago = (ms: number): string => {
+  const said = age(ms)
+  return said === "now" ? "just now" : `${said} ago`
+}
+
+/**
+ * The right-hand column's time: `now`, `12m ago`, `2h ago`. Trail's column is when, not how long, and
+ * it sits under Shells and Subagents, whose column is how long a thing ran (`1m04s`): a bare `2h`
+ * there read as two hours of work. `short` drops the "ago" where a narrow sidebar has no room for it.
+ */
+export function since(ms: number, short = false): string {
+  const said = age(ms)
+  return said === "now" || short ? said : `${said} ago`
+}
+
+/**
+ * What the ref column shows: the ref, or what a link names (`PR #33`) — not the kind. A kind there
+ * was cut to `Confluenc…` beside a title cut short too, and the dialog's `Confluence` chip said it
+ * again. With none, the title takes the column.
+ */
+export const refOf = (thing: { label?: string; ref?: string; kind?: string }): string | undefined =>
+  thing.ref !== undefined || thing.label !== thing.kind ? thing.label : undefined
diff --git a/packages/trail/src/core/view/sidebar.ts b/packages/trail/src/core/view/sidebar.ts
new file mode 100644
index 00000000..925709a3
--- /dev/null
+++ b/packages/trail/src/core/view/sidebar.ts
@@ -0,0 +1,191 @@
+/**
+ * The sidebar block: what this conversation made, grouped by what it was for, one click from the page.
+ *
+ *   Trail                                            9
+ *
+ *   COM-1801
+ *     ENG-42   Retry the s…  Linear  created  15m ago ↗
+ *   COM-1736   Bundle desync   Jira  updated   2h ago ↗
+ *     PR #33   0.8: Trust,…  GitHub  updated   1h ago ↗
+ *   Status page incident     status…  published  now ↗
+ *   + 4 more · /trail
+ *
+ * (`COM-1801` heads its group by name: nothing here records the ticket itself.)
+ *
+ * A row: the ref muted, the title, the system, then what this conversation last did and when —
+ * `now`, `12m ago`: the column under Shells' and Subagents' durations says "ago" so it is not read as
+ * one. A record with no ref gives its title the ref's column. All muted, because it is history and not a state; nothing here goes stale, so nothing wears a
+ * state's colour. `↗` marks a row a click opens in the browser.
+ *
+ * **Present when empty** (Gate 1): the heading and a muted `none yet` in the slot the first record
+ * will take, so the first record replaces the line instead of pushing the blocks below down — the
+ * client's `emptyBlock`, the same two rows every bay draws.
+ *
+ * **A settings notice always speaks**: under the heading, `!` and the words wrapped to the column
+ * (`warnRows`), even with `hideWhenEmpty` — a typo in the config is never silent.
+ *
+ * Narrow, the row gives up its columns in order — the action, then the system, then the "ago", then
+ * the ref's width — before the title drops below a readable few cells; whatever is cut ends in `…`.
+ */
+
+import {
+  EMPTY_TEXT,
+  emptyBlock,
+  HEADING,
+  HEADING_GAP,
+  moreText,
+  type ToneRun,
+  warnRows,
+} from "@opencode-cockpit/client/design"
+import { type Arranged, type Line, lastOf, linesOf, type Thing } from "../model.ts"
+import { cut, fit, type Row, type Run, refOf, since, spread, widthOf } from "./rows.ts"
+
+export interface SidebarInput {
+  width: number
+  /** This conversation's trail, arranged. */
+  arranged: Arranged
+  now: number
+  /** Rows of records before `+ N more`. */
+  limit: number
+  /** Draw nothing at all while the trail is empty (`hideWhenEmpty`). */
+  hideWhenEmpty?: boolean
+  /** Settings to fix, as sentences (`noticeText`): `!` rows under the heading, wrapped to fit. */
+  notices?: readonly string[]
+}
+
+/** What a click on a row does: open a page, open `/trail` at a record, or open `/trail`. */
+export type SidebarHit =
+  | { y: number; kind: "open"; key: string; url: string }
+  | { y: number; kind: "select"; key: string }
+  | { y: number; kind: "more" }
+
+export interface SidebarView {
+  rows: Row[]
+  hits: SidebarHit[]
+}
+
+export const OPEN_MARK = "↗"
+/** The client's words for an empty block, so every bay says the same. */
+export { EMPTY_TEXT }
+
+/** The fewest cells a title is given before a column is dropped for it. */
+const TITLE_MIN = 12
+const LABEL_MAX = 10
+const SYSTEM_MAX = 12
+const ACTION_MAX = 10
+const INDENT = 2
+
+interface Columns {
+  label: number
+  system: number
+  action: number
+  age: number
+  /** `2h` rather than `2h ago`: a narrow sidebar, short of room for the title. */
+  short: boolean
+}
+
+const muted = (text: string): Run => ({ text, tone: "muted" })
+const pad = (text: string, room: number) => `${text}${" ".repeat(Math.max(0, room - widthOf(text)))}`
+
+function columnsFor(lines: readonly Line[], width: number, now: number): Columns {
+  const things = lines.flatMap((line) => (line.kind === "thing" ? [line] : []))
+  const most = (values: number[]) => Math.max(0, ...values)
+  const full: Columns = {
+    label: Math.min(
+      LABEL_MAX,
+      most(things.map(({ thing, depth }) => widthOf(refOf(thing) ?? "") + depth * INDENT)),
+    ),
+    system: Math.min(SYSTEM_MAX, most(things.map(({ thing }) => widthOf(thing.system ?? "")))),
+    action: Math.min(ACTION_MAX, most(things.map(({ thing }) => widthOf(lastOf(thing).action)))),
+    age: 0,
+    short: false,
+  }
+  /** The time column is as wide as its widest time, said the long or the short way. */
+  const timed = (c: Omit): Columns => ({
+    ...c,
+    age: most(things.map(({ thing }) => widthOf(since(now - lastOf(thing).at, c.short)))),
+  })
+  /** Two cells after the title; each column its width and two after it; the age; the mark's two. */
+  const right = (c: Columns) => 2 + (c.system ? c.system + 2 : 0) + (c.action ? c.action + 2 : 0) + c.age + 2
+  const titleRoom = (c: Columns) => width - (c.label ? c.label + 2 : 0) - right(c)
+  const bare = { ...full, action: 0, system: 0 }
+  const tries: Columns[] = [
+    full,
+    { ...full, action: 0 },
+    bare,
+    { ...bare, short: true },
+    { ...bare, short: true, label: Math.min(full.label, 6) },
+  ].map(timed)
+  return tries.find((c) => titleRoom(c) >= TITLE_MIN) ?? (tries.at(-1) as Columns)
+}
+
+function thingRow(thing: Thing, depth: 0 | 1, columns: Columns, width: number, now: number): Row {
+  const last = lastOf(thing)
+  const indent = " ".repeat(depth * INDENT)
+  const ref = refOf(thing)
+  /** With no ref, the title starts where the ref would. */
+  const label =
+    columns.label && ref
+      ? [muted(pad(cut(`${indent}${ref}`, columns.label), columns.label)), { text: "  " }]
+      : [{ text: indent }]
+  /** The system right-aligned, so a short name sits against the action like a column of numbers. */
+  const right: Run[] = [
+    { text: " " },
+    ...(columns.system
+      ? [
+          { text: cut(thing.system ?? "", columns.system).padStart(columns.system), tone: "info" as const },
+          { text: "  " },
+        ]
+      : []),
+    ...(columns.action ? [muted(pad(cut(last.action, columns.action), columns.action)), { text: "  " }] : []),
+    muted(since(now - last.at, columns.short).padStart(columns.age)),
+    { text: thing.openable ? ` ${OPEN_MARK}` : "  ", tone: "muted" },
+  ]
+  return spread([...label, { text: thing.title, tone: "text" }], right, width)
+}
+
+/** The client's rows are tone names and text, as ours are: each made exactly the width. */
+const asRow = (runs: readonly ToneRun[], width: number): Row => fit([...runs], width)
+
+export function sidebarRows(input: SidebarInput): SidebarView {
+  const { width, arranged, now } = input
+  const notices = input.notices ?? []
+  if (width < 8 || (input.hideWhenEmpty && arranged.total === 0 && notices.length === 0))
+    return { rows: [], hits: [] }
+  const warned = notices.flatMap((text) => warnRows(text, width).map((runs) => asRow(runs, width)))
+  if (arranged.total === 0) {
+    /** Hidden when empty, a notice still draws, under the heading: a failure always speaks. */
+    const [heading = [], ...rest] = emptyBlock("Trail", width).map((runs) => asRow(runs, width))
+    const air = rest.slice(0, HEADING_GAP)
+    const body = input.hideWhenEmpty ? [] : rest.slice(HEADING_GAP)
+    return { rows: [heading, ...air, ...warned, ...body], hits: [] }
+  }
+  const rows: Row[] = []
+  const hits: SidebarHit[] = []
+  rows.push(spread([{ text: "Trail", ...HEADING }], [muted(String(arranged.total))], width))
+  for (let gap = 0; gap < HEADING_GAP; gap++) rows.push(fit([], width))
+  rows.push(...warned)
+
+  const lines = linesOf(arranged)
+  const shown = lines.length > input.limit ? lines.slice(0, Math.max(1, input.limit)) : lines
+  const columns = columnsFor(shown, width, now)
+  for (const line of shown) {
+    const y = rows.length
+    if (line.kind === "head") {
+      rows.push(fit([{ text: line.name, tone: "text" }], width))
+      continue
+    }
+    rows.push(thingRow(line.thing, line.depth, columns, width, now))
+    hits.push(
+      line.thing.openable && line.thing.url
+        ? { y, kind: "open", key: line.thing.key, url: line.thing.url }
+        : { y, kind: "select", key: line.thing.key },
+    )
+  }
+  const hidden = lines.slice(shown.length).filter((line) => line.kind === "thing").length
+  if (hidden > 0) {
+    hits.push({ y: rows.length, kind: "more" })
+    rows.push(fit([muted(`${moreText(hidden)} · /trail`)], width))
+  }
+  return { rows, hits }
+}
diff --git a/packages/trail/src/server.ts b/packages/trail/src/server.ts
new file mode 100644
index 00000000..74a2823e
--- /dev/null
+++ b/packages/trail/src/server.ts
@@ -0,0 +1,2 @@
+/** Published entry point: `@opencode-cockpit/trail/server`. */
+export { createTrailServer, default, TRAIL_PACKAGE, type TrailServerOptions } from "./agent/plugin.ts"
diff --git a/packages/trail/src/tui/actions.ts b/packages/trail/src/tui/actions.ts
new file mode 100644
index 00000000..846399a0
--- /dev/null
+++ b/packages/trail/src/tui/actions.ts
@@ -0,0 +1,168 @@
+/**
+ * What the interface does: read and append the trail file (every window, and the agent half, append
+ * to it), open and copy a link, and `/link` — a link recorded by you, through the agent's own door.
+ */
+
+import type { Host } from "@opencode-cockpit/client/host"
+import type { Log } from "@opencode-cockpit/client/log"
+import type { Journal } from "../core/journal.ts"
+import { arrange, conversationThings } from "../core/model.ts"
+import type { TrailPaths } from "../core/paths.ts"
+import type { Event } from "../core/store.ts"
+import { markdownOf } from "../core/text.ts"
+import { linkArgs, runAdd } from "../core/tools.ts"
+import { openUrl } from "./open.ts"
+import type { Live } from "./paint.ts"
+import type { Sessions } from "./source.ts"
+
+export type Toast = (message: string, variant?: "info" | "success" | "warning" | "error") => void
+
+export interface Actions {
+  /** Reads what was appended since the last read. */
+  sync(): Promise
+  /** Appends, then reads; false when the file could not be written. */
+  write(events: readonly Event[]): Promise
+  /** Live titles for All conversations: written-down titles can be a conversation's first, placeholder one. */
+  listTitles(): Promise
+  toast: Toast
+  open(url: string): void
+  copy(text: string, what: string): void
+  /** `/link`: asks for the link, then records it. */
+  link(): Promise
+  /** The palette's "Copy this conversation's trail as markdown". */
+  copyConversation(): void
+}
+
+export function createActions(input: {
+  api: Host
+  log: Log
+  live: Live
+  journal: Journal
+  paths: TrailPaths
+  sessions: Sessions
+  draw: () => void
+}): Actions {
+  const { api, log, live, journal, paths, sessions, draw } = input
+
+  // --- the trail file ------------------------------------------------------------------------------
+
+  /** The host's live titles over the written ones: a conversation renamed since still reads right. */
+  const retitle = () => {
+    for (const [id, title] of live.titles) {
+      const conversation = live.state.conversations.get(id)
+      if (conversation) conversation.title = title
+    }
+  }
+  const sync = async () => {
+    try {
+      const before = live.state.seen.size
+      live.state = await journal.sync(live.state)
+      if (live.trouble?.startsWith("trail unreadable")) live.trouble = undefined
+      if (live.state.seen.size !== before) {
+        retitle()
+        draw()
+      }
+    } catch (error) {
+      log.error("trail unreadable", { file: paths.events, error })
+      live.trouble = "trail unreadable — see cockpit.log"
+      draw()
+    }
+  }
+  const write = async (events: readonly Event[]): Promise => {
+    try {
+      await journal.append(events)
+      if (live.trouble?.startsWith("trail not saved")) live.trouble = undefined
+      await sync()
+      /** `runAdd` folded the event in already, so the read finds nothing new to draw for: draw anyway. */
+      draw()
+      return true
+    } catch (error) {
+      log.error("trail not saved", { file: paths.events, error })
+      live.trouble = `trail not saved: ${(error as NodeJS.ErrnoException).code ?? "error"}`
+      draw()
+      return false
+    }
+  }
+  const listTitles = () =>
+    sessions
+      .list()
+      .then((list) => {
+        for (const each of list) if (each.title) live.titles.set(each.id, each.title)
+        retitle()
+        draw()
+      })
+      .catch((error) => log.debug("session list failed", { error }))
+
+  // --- acting --------------------------------------------------------------------------------------
+
+  const toast: Toast = (message, variant = "info") => api.ui.toast({ variant, title: "Trail", message })
+
+  const open = (url: string) => {
+    log.debug("open", { url })
+    openUrl(url, (why) => {
+      log.warn("open failed", { url, why })
+      toast(`Could not open the link (${why}). It is: ${url}`, "warning")
+    })
+  }
+
+  const copy = (text: string, what: string) => {
+    const ok = api.renderer.copyToClipboardOSC52?.(text) ?? false
+    if (ok) toast(`Copied ${what}.`, "success")
+    else toast(`This terminal refused the clipboard. ${what}: ${text}`, "warning")
+  }
+
+  /** Recorded by you, with `/link`, through the agent's own door, `runAdd`. */
+  const addByYou = async (args: { title: string; url: string }): Promise => {
+    if (!live.root) {
+      toast("Open a conversation first: a link belongs to the conversation it was made in.", "warning")
+      return
+    }
+    await sync()
+    const title = live.titles.get(live.root) ?? (await sessions.get(live.root))?.title
+    const out = runAdd(live.state, args, {
+      session: live.root,
+      rootSession: live.root,
+      ...(title ? { sessionTitle: title } : {}),
+      by: "you",
+      at: Date.now(),
+    })
+    if (!out.ok) {
+      toast(out.text, "warning")
+      return
+    }
+    if (!(await write([out.event]))) {
+      toast("The trail could not be saved — see cockpit.log.", "error")
+      return
+    }
+    log.info("added by you", { session: live.root, url: out.event.url })
+    toast(out.text.split("\n")[0] ?? "Recorded.", "success")
+  }
+
+  const link = async () => {
+    if (!live.root) {
+      toast("Open a conversation first: a link belongs to the conversation it was made in.", "warning")
+      return
+    }
+    const text = await api.ui.prompt({
+      title: "Add a link to this conversation's trail",
+      description: "The link, then a note if you like: https://… what it is",
+      placeholder: "https://github.com/acme/web/pull/40 the checkout fix",
+    })
+    if (text === undefined) return
+    const args = linkArgs(text)
+    if (!args) return toast("Nothing added: paste a link first, then a note if you like.", "warning")
+    await addByYou(args)
+  }
+
+  const copyConversation = () => {
+    const root = live.root
+    const arranged = arrange(root ? conversationThings(live.state, root) : [])
+    if (arranged.total === 0)
+      return toast(
+        root ? "Nothing to copy yet: this conversation's trail is empty." : "Open a conversation first.",
+      )
+    copy(markdownOf(arranged), `${arranged.total} ${arranged.total === 1 ? "thing" : "things"} as markdown`)
+  }
+
+  return { sync, write, listTitles, toast, open, copy, link, copyConversation }
+}
diff --git a/packages/trail/src/tui/dialog.tsx b/packages/trail/src/tui/dialog.tsx
new file mode 100644
index 00000000..f4d41c70
--- /dev/null
+++ b/packages/trail/src/tui/dialog.tsx
@@ -0,0 +1,210 @@
+/** @jsxImportSource @opentui/solid */
+
+/**
+ * `/trail`: opening it on the host's dialog, its keys, and what each does to the row under the cursor
+ * — open the page, go to the conversation, copy, remove, search.
+ */
+
+import type { Host, InterceptContext, Layer } from "@opencode-cockpit/client/host"
+import type { Log } from "@opencode-cockpit/client/log"
+import { markdownOf } from "../core/text.ts"
+import type { Actions } from "./actions.ts"
+import type { DialogState, Live, Painter } from "./paint.ts"
+import type { Sessions } from "./source.ts"
+import { Dialog } from "./view/dialog.tsx"
+
+export const closedDialog = (): DialogState => ({
+  open: false,
+  tab: "this",
+  selected: undefined,
+  query: "",
+  searching: false,
+})
+
+export interface TrailDialog {
+  open(at?: Partial): void
+  /** Every key while `/trail` is open, ahead of the keymap: see `intercept` in index.tsx. */
+  intercept(ctx: InterceptContext): void
+}
+
+export function createDialog(input: {
+  api: Host
+  log: Log
+  live: Live
+  dialog: DialogState
+  painter: Painter
+  actions: Actions
+  sessions: Sessions
+}): TrailDialog {
+  const { api, log, live, dialog, painter, actions, sessions } = input
+  const { draw, dialogLines } = painter
+
+  const move = (by: number) => {
+    const items = painter.shown()?.items ?? []
+    const at = Math.max(
+      0,
+      items.findIndex((item) => item.key === dialog.selected),
+    )
+    dialog.selected = items[Math.max(0, Math.min(items.length - 1, at + by))]?.key
+    draw()
+  }
+
+  const target = () => painter.shown()?.target ?? {}
+
+  const enter = () => {
+    const url = target().open
+    if (url) actions.open(url)
+  }
+
+  const go = () => {
+    const to = target().go
+    if (!to) return
+    /** Close first: both versions drew the switch cleanly that way (docs/opencode/trail-interface.md). */
+    api.ui.dialog.clear()
+    log.debug("go", { session: to })
+    sessions.navigate(to)
+  }
+
+  const remove = async () => {
+    const key = target().remove
+    const record = key ? live.state.records.get(key) : undefined
+    if (!key || !record) return
+    const ok = await actions.write([
+      {
+        v: 1,
+        at: Date.now(),
+        id: `rm_${key}_${Date.now().toString(36)}`,
+        type: "removed",
+        record: key,
+        rootSession: record.session,
+      },
+    ])
+    if (ok) actions.toast(`Removed "${record.title}" from the trail.`, "success")
+  }
+
+  const markdown = () => {
+    const arranged = painter.shown()?.arranged
+    if (!arranged || arranged.total === 0)
+      return actions.toast("Nothing to copy yet: the trail is empty.", "info")
+    actions.copy(
+      markdownOf(arranged),
+      `${arranged.shown} ${arranged.shown === 1 ? "thing" : "things"} as markdown`,
+    )
+  }
+
+  const switchTab = () => {
+    dialog.tab = dialog.tab === "this" ? "all" : "this"
+    dialog.selected = undefined
+    if (dialog.tab === "all") void actions.listTitles()
+    draw()
+  }
+
+  /** A click on a row takes the cursor; on the row already under it, `enter`. */
+  const click = (_x: number, y: number) => {
+    const hit = painter.shown()?.hits.find((each) => each.y === y)
+    if (!hit) return
+    if (hit.key === dialog.selected) return enter()
+    dialog.selected = hit.key
+    draw()
+  }
+
+  const run = (fn: () => unknown) => () => {
+    if (dialog.searching) return
+    void Promise.resolve(fn()).catch((error) => log.warn("action failed", { error }))
+  }
+
+  const dialogLayer = (): Layer => ({
+    priority: 100,
+    commands: [
+      { name: "cockpit.trail.down", title: "Next", run: run(() => move(1)) },
+      { name: "cockpit.trail.up", title: "Previous", run: run(() => move(-1)) },
+      { name: "cockpit.trail.tab", title: "This conversation, or all", run: run(() => switchTab()) },
+      { name: "cockpit.trail.enter", title: "Open the page", run: run(() => enter()) },
+      { name: "cockpit.trail.go", title: "Go to the conversation", run: run(() => go()) },
+      {
+        name: "cockpit.trail.copyLink",
+        title: "Copy the link",
+        run: run(() => target().copy && actions.copy(target().copy as string, "the link")),
+      },
+      { name: "cockpit.trail.remove", title: "Remove from the trail", run: run(() => remove()) },
+      { name: "cockpit.trail.markdown", title: "Copy as markdown", run: run(() => markdown()) },
+      {
+        name: "cockpit.trail.search",
+        title: "Search",
+        run: run(() => {
+          dialog.searching = true
+          draw()
+        }),
+      },
+      { name: "cockpit.trail.close", title: "Close", run: run(() => api.ui.dialog.clear()) },
+    ],
+    bindings: [
+      { key: "j,down", cmd: "cockpit.trail.down" },
+      { key: "k,up", cmd: "cockpit.trail.up" },
+      { key: "tab", cmd: "cockpit.trail.tab" },
+      { key: "return", cmd: "cockpit.trail.enter" },
+      { key: "g", cmd: "cockpit.trail.go" },
+      { key: "c", cmd: "cockpit.trail.copyLink" },
+      { key: "x", cmd: "cockpit.trail.remove" },
+      { key: "m", cmd: "cockpit.trail.markdown" },
+      { key: "/", cmd: "cockpit.trail.search" },
+      { key: "q", cmd: "cockpit.trail.close" },
+    ],
+  })
+
+  /** The search being typed gets every key, and `esc` clears a search before the host closes the dialog. */
+  const intercept = (ctx: InterceptContext) => {
+    if (!dialog.open) return
+    const event = ctx.event
+    if (dialog.searching) {
+      ctx.consume({ preventDefault: true, stopPropagation: true })
+      if (event.name === "escape") {
+        dialog.searching = false
+        dialog.query = ""
+      } else if (event.name === "return" || event.name === "enter") dialog.searching = false
+      else if (event.name === "backspace") dialog.query = dialog.query.slice(0, -1)
+      else if (event.sequence && !event.ctrl && !event.meta && event.sequence >= " ")
+        dialog.query += event.sequence
+      dialog.selected = undefined
+      return draw()
+    }
+    if (event.name !== "escape" || dialog.query === "") return
+    ctx.consume({ preventDefault: true, stopPropagation: true })
+    dialog.query = ""
+    dialog.selected = undefined
+    draw()
+  }
+
+  const open = (at: Partial = {}) => {
+    Object.assign(dialog, {
+      open: true,
+      /** With no conversation on screen there is no "this": the project's trail. */
+      tab: live.root ? (at.tab ?? "this") : "all",
+      selected: at.selected,
+      query: at.query ?? "",
+      searching: false,
+    })
+    void actions.sync()
+    if (dialog.tab === "all") void actions.listTitles()
+    painter.paint()
+    api.ui.dialog.replace(
+      () => (
+         move(by)}
+          onClick={(x, y) => click(x, y)}
+        />
+      ),
+      () => {
+        dialog.open = false
+        dialog.searching = false
+      },
+    )
+    api.ui.dialog.setSize("xlarge")
+    log.debug("trail: open", { tab: dialog.tab, items: painter.shown()?.items.length ?? 0 })
+  }
+
+  return { open, intercept }
+}
diff --git a/packages/trail/src/tui/index.tsx b/packages/trail/src/tui/index.tsx
new file mode 100644
index 00000000..27bce519
--- /dev/null
+++ b/packages/trail/src/tui/index.tsx
@@ -0,0 +1,209 @@
+/** @jsxImportSource @opentui/solid */
+
+/**
+ * Trail's interface half: the sidebar block — what this conversation made, one click from the page —
+ * `/trail`, and `/link` for a person to add one by hand.
+ *
+ * Everything that decides is in `core/`; this file wires it to the host: the conversation on screen
+ * found, the painter (`paint.ts`), the trail file and what a person does (`actions.ts`), `/trail`
+ * (`dialog.tsx`), the palette's commands and the sidebar block — both surfaces drawn from the same
+ * `arrange` as `trail_list`.
+ */
+
+import { defaultKeys } from "@opencode-cockpit/client/catalog"
+import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client/feature"
+import { bindingLookup, dualTui, type Host } from "@opencode-cockpit/client/host"
+import { loadTrail } from "../core/config.ts"
+import { createJournal } from "../core/journal.ts"
+import { trailPaths } from "../core/paths.ts"
+import { emptyState } from "../core/store.ts"
+import { createActions } from "./actions.ts"
+import { closedDialog, createDialog } from "./dialog.tsx"
+import { createPainter, type Live } from "./paint.ts"
+import { createSessions } from "./source.ts"
+import { Rows } from "./view/rows.tsx"
+
+const TRAIL_PACKAGE = "@opencode-cockpit/trail"
+
+const DEFAULT_KEYS = defaultKeys("trail")
+
+/** How often the route is looked at; the trail file is read every few of these. */
+const TICK_MS = 1_000
+const SYNC_EVERY = 3
+const DEPTH = 8
+
+/** Trail's interface half as a factory, so the `opencode-cockpit` bundle can include it. */
+export function createTrailTui({ source = TRAIL_PACKAGE }: { source?: string } = {}) {
+  return async (api: Host, rawOptions?: unknown) => {
+    const log = api.log.child("trail")
+    const directory = api.state.path.directory
+    const { settings, order, notices } = loadTrail(directory, rawOptions)
+    for (const notice of notices) log.warn("settings", { notice })
+    if (!settings.enabled) {
+      log.info("off by config", { directory })
+      return
+    }
+    const claim = claimFeature(api.renderer, "trail", source)
+    if (!claim.active) {
+      log.warn("configured twice", { owner: claim.owner, skipped: source })
+      api.ui.toast({
+        variant: "warning",
+        title: "Trail",
+        message: duplicateFeatureMessage("Trail", claim.owner, source),
+      })
+      return
+    }
+    api.lifecycle.onDispose(() => claim.release())
+
+    const keys = bindingLookup({ ...DEFAULT_KEYS, ...settings.keybinds })
+    const paths = trailPaths(directory)
+    const journal = createJournal(paths)
+    const sessions = createSessions(api, log)
+    const project = directory.split(/[\\/]/).filter(Boolean).at(-1) ?? ""
+    const live: Live = {
+      state: emptyState(),
+      root: undefined,
+      trouble: undefined,
+      inSidebar: settings.sidebar,
+      titles: new Map(),
+    }
+    const dialog = closedDialog()
+    const painter = createPainter({ api, log, settings, notices, project, live, dialog })
+    const { draw, sidebarLines } = painter
+    const actions = createActions({ api, log, live, journal, paths, sessions, draw })
+    const trail = createDialog({ api, log, live, dialog, painter, actions, sessions })
+
+    // --- the conversation on screen ----------------------------------------------------------------
+
+    const parents = new Map()
+    let routed: string | undefined
+    /** The conversation — the root session — of the session on screen; a subagent's view counts as its parent's. */
+    const resolve = async (id: string): Promise => {
+      let at = id
+      for (let hop = 0; hop < DEPTH; hop++) {
+        if (!parents.has(at)) {
+          const info = await sessions.get(at)
+          if (!info) return at
+          parents.set(at, info.parentID ?? null)
+          if (info.title && !info.parentID) live.titles.set(info.id, info.title)
+        }
+        const parent = parents.get(at)
+        if (!parent) return at
+        at = parent
+      }
+      return at
+    }
+    const follow = () => {
+      const route = api.route.current
+      const id = route.name === "session" ? (route.params?.sessionID as string | undefined) : undefined
+      if (id === routed) return
+      routed = id
+      if (!id) {
+        live.root = undefined
+        return draw()
+      }
+      void resolve(id).then((found) => {
+        if (routed !== id) return
+        live.root = found
+        draw()
+      })
+    }
+
+    /**
+     * Ahead of the keymap, because the host's dialog takes `esc` before any layer hears it (Trust's
+     * filter does the same).
+     */
+    api.lifecycle.onDispose(api.keymap.intercept((ctx) => trail.intercept(ctx), { priority: 10_000 }))
+
+    // --- the sidebar -------------------------------------------------------------------------------
+
+    /** A row with a page opens it; one without opens `/trail` on it; `+ N more` opens `/trail`. */
+    const sidebarClick = (y: number) => {
+      const hit = painter.sidebarView().hits.find((each) => each.y === y)
+      if (!hit) return
+      if (hit.kind === "open") actions.open(hit.url)
+      else if (hit.kind === "select") trail.open({ selected: hit.key })
+      else trail.open()
+    }
+
+    api.keymap.registerLayer({
+      commands: [
+        {
+          name: "cockpit.trail.open",
+          title: "Show what this conversation made",
+          category: "Cockpit · Trail",
+          namespace: "palette",
+          slashName: "trail",
+          run: () => trail.open(),
+        },
+        {
+          name: "cockpit.trail.link",
+          title: "Add a link to this conversation's trail",
+          desc: "a PR, a ticket, a page",
+          category: "Cockpit · Trail",
+          namespace: "palette",
+          slashName: "link",
+          run: () => void actions.link().catch((error) => log.warn("link failed", { error })),
+        },
+        {
+          name: "cockpit.trail.copyAll",
+          title: "Copy this conversation's trail as markdown",
+          category: "Cockpit · Trail",
+          namespace: "palette",
+          run: () => actions.copyConversation(),
+        },
+        {
+          name: "cockpit.trail.sidebar",
+          title: "Show or hide Trail in the sidebar",
+          desc: "for this session",
+          category: "Cockpit · Trail",
+          namespace: "palette",
+          run: () => {
+            live.inSidebar = !live.inSidebar
+            painter.paint()
+            /** Said as well as drawn: on the home screen there is no sidebar to show it in. */
+            actions.toast(live.inSidebar ? "Shown in the sidebar." : "Hidden from the sidebar.")
+          },
+        },
+      ],
+      bindings: keys.gather("cockpit", Object.keys(DEFAULT_KEYS)),
+    })
+
+    api.slots.register({
+      /** After Shells and before Trust by default; the top-level `sidebar` list moves it. */
+      order,
+      slots: {
+        sidebar_content: () => (
+           {
+              painter.setBlock(box)
+              draw()
+            }}
+            onRow={(y) => sidebarClick(y)}
+          />
+        ),
+      },
+    })
+
+    let ticks = 0
+    let ticking: ReturnType | undefined = setInterval(() => {
+      follow()
+      if (++ticks % SYNC_EVERY === 0) void actions.sync()
+      if (painter.resized()) draw()
+    }, TICK_MS)
+    api.lifecycle.onDispose(() => {
+      clearInterval(ticking)
+      ticking = undefined
+    })
+
+    follow()
+    await actions.sync()
+    draw()
+    log.info("ready", { trail: paths.events, sidebar: live.inSidebar, order })
+  }
+}
+
+/** One entry for both OpenCodes: v1 calls `tui`, v2 calls `setup` (docs/opencode/v2.md). */
+export default dualTui("opencode-cockpit.trail", createTrailTui())
diff --git a/packages/trail/src/tui/open.ts b/packages/trail/src/tui/open.ts
new file mode 100644
index 00000000..a6965554
--- /dev/null
+++ b/packages/trail/src/tui/open.ts
@@ -0,0 +1,30 @@
+/**
+ * A link opened in the browser from the interface thread, without stalling it: `spawn`, detached,
+ * output ignored, unreferenced — measured on both OpenCodes at a few milliseconds and no frame lost
+ * (docs/opencode/trail-interface.md, spike 5). Never `spawnSync` here: a synchronous spawn on the
+ * interface thread takes the renderer down with it (gotchas.md).
+ *
+ * Which program opens it is client's `openerFor`, shared with Review. Only `http(s)` is ever handed
+ * over. A missing opener arrives later, as an `error` event rather than a throw, so it has a listener;
+ * either way the person is told, with the link to open by hand.
+ */
+
+import { spawn } from "node:child_process"
+import { openerFor, systemOpenerWhere } from "@opencode-cockpit/client/opener"
+import { openable } from "../core/links.ts"
+
+/** Opens `url`, and calls `failed` with why if it could not be. Returns at once. */
+export function openUrl(url: string, failed: (why: string) => void): void {
+  const opener = openable(url) ? openerFor(url, systemOpenerWhere()) : undefined
+  if (!opener) {
+    failed(/^https?:\/\//i.test(url) ? "no program here opens links" : "only http(s) links are opened")
+    return
+  }
+  try {
+    const child = spawn(opener.command, opener.args, { detached: true, stdio: "ignore", windowsHide: true })
+    child.on("error", (error) => failed(error.message))
+    child.unref()
+  } catch (error) {
+    failed(error instanceof Error ? error.message : String(error))
+  }
+}
diff --git a/packages/trail/src/tui/paint.ts b/packages/trail/src/tui/paint.ts
new file mode 100644
index 00000000..ae0facdc
--- /dev/null
+++ b/packages/trail/src/tui/paint.ts
@@ -0,0 +1,158 @@
+/**
+ * Painting: the sidebar block's rows and `/trail`'s, from the trail as it is now, into the signals the
+ * views draw. `draw` asks for a paint and folds every change in one turn into it; `paint` paints now.
+ *
+ * What it paints from lives in `Live` and `DialogState`, which the rest of the interface changes.
+ */
+
+import type { Host } from "@opencode-cockpit/client/host"
+import type { Log } from "@opencode-cockpit/client/log"
+import type { BoxRenderable } from "@opentui/core"
+import { type Accessor, createSignal } from "solid-js"
+import type { TrailSettings } from "../core/config.ts"
+import { arrange, conversationThings } from "../core/model.ts"
+import type { State } from "../core/store.ts"
+import { type DialogView, dialogRows, type Tab } from "../core/view/dialog.ts"
+import type { Row } from "../core/view/rows.ts"
+import { type SidebarView, sidebarRows } from "../core/view/sidebar.ts"
+
+/** The host's dialog: as wide as xlarge allows (Shell's console measured it). */
+const DIALOG_COLUMNS = 116
+
+/** What the interface knows now, shared by everything that reads or changes it. */
+export interface Live {
+  state: State
+  /** The conversation — the root session — on screen. */
+  root: string | undefined
+  /** Something you should know about: drawn as a `!` row whatever else the block says. */
+  trouble: string | undefined
+  /** Not remembered across restarts: remembered UI state makes a command look dead (gotchas.md). */
+  inSidebar: boolean
+  /** Live conversation titles from the host's list, over the ones written down when recording. */
+  readonly titles: Map
+}
+
+/** `/trail`: which tab, what is under the cursor, and the search. */
+export interface DialogState {
+  open: boolean
+  tab: Tab
+  selected: string | undefined
+  query: string
+  searching: boolean
+}
+
+export interface Painter {
+  readonly sidebarLines: Accessor
+  readonly dialogLines: Accessor
+  /** The block the host laid out: its width is what the rows are cut to. */
+  setBlock(box: BoxRenderable): void
+  /** The sidebar was laid out, or resized, since the rows were drawn. */
+  resized(): boolean
+  paint(): void
+  draw(): void
+  sidebarView(): SidebarView
+  /** What `/trail` shows, while it is open. */
+  shown(): DialogView | undefined
+}
+
+export function createPainter(input: {
+  api: Host
+  log: Log
+  settings: TrailSettings
+  notices: readonly string[]
+  project: string
+  live: Live
+  dialog: DialogState
+}): Painter {
+  const { api, log, settings, notices, project, live, dialog } = input
+  const [sidebarLines, setLines] = createSignal([])
+  const [dialogLines, setDialogLines] = createSignal([])
+  let block: BoxRenderable | undefined
+  let drawnAt = 0
+  let said = ""
+  let sidebarView: SidebarView = { rows: [], hits: [] }
+  let shown: DialogView | undefined
+  const sidebarWidth = () => {
+    /** The container the host gave the block, as Subagents measures it: the block's own width follows its rows. */
+    const parent = (block?.parent as { width?: number } | null | undefined)?.width ?? 0
+    const own = block?.width ?? 0
+    const measured = parent >= 12 ? Math.min(parent, own >= 12 ? own : parent) : own
+    return measured >= 12 ? measured : Math.max(20, Math.min(40, Math.floor(api.renderer.width / 4) - 2))
+  }
+
+  const paint = () => {
+    drawnAt = sidebarWidth()
+    const now = Date.now()
+    const warnings = [...notices, ...(live.trouble ? [live.trouble] : [])]
+    const mine = arrange(live.root ? conversationThings(live.state, live.root) : [])
+    /** Hidden, the block says nothing — except a notice or trouble: a failure always speaks. */
+    sidebarView = live.inSidebar
+      ? sidebarRows({
+          width: drawnAt,
+          arranged: mine,
+          now,
+          limit: settings.sidebarRows,
+          hideWhenEmpty: settings.hideWhenEmpty,
+          notices: warnings,
+        })
+      : sidebarRows({
+          width: drawnAt,
+          arranged: arrange([]),
+          now,
+          limit: 0,
+          hideWhenEmpty: true,
+          notices: warnings,
+        })
+    /** Only when they changed: new rows rebuild every line of the block. */
+    const text = JSON.stringify(sidebarView.rows)
+    if (text !== said) {
+      said = text
+      setLines(sidebarView.rows)
+    }
+    if (dialog.open) {
+      const height = api.renderer.height
+      shown = dialogRows({
+        width: Math.max(40, Math.min(DIALOG_COLUMNS, api.renderer.width - 2)),
+        height: Math.max(11, height - Math.floor(height / 4) * 2),
+        tab: dialog.tab,
+        state: live.state,
+        session: live.root ?? "",
+        now,
+        project,
+        ...(dialog.selected !== undefined ? { selected: dialog.selected } : {}),
+        query: dialog.query,
+        searching: dialog.searching,
+      })
+      dialog.selected = shown.item?.key
+      setDialogLines(shown.rows)
+    }
+    api.renderer.requestRender()
+  }
+  /** Several changes in one turn are one paint (Shell's painter). */
+  let scheduled = false
+  const draw = () => {
+    if (scheduled) return
+    scheduled = true
+    setTimeout(() => {
+      scheduled = false
+      try {
+        paint()
+      } catch (error) {
+        log.error("paint failed", { error })
+      }
+    }, 0)
+  }
+
+  return {
+    sidebarLines,
+    dialogLines,
+    setBlock: (box) => {
+      block = box
+    },
+    resized: () => sidebarWidth() !== drawnAt,
+    paint,
+    draw,
+    sidebarView: () => sidebarView,
+    shown: () => shown,
+  }
+}
diff --git a/packages/trail/src/tui/render.ts b/packages/trail/src/tui/render.ts
new file mode 100644
index 00000000..69beb0f8
--- /dev/null
+++ b/packages/trail/src/tui/render.ts
@@ -0,0 +1,95 @@
+/**
+ * The only place that knows what a tone looks like: every colour from the user's OpenCode theme,
+ * through the host (so OpenCode 2's tokens arrive under the same names — client/host `themeFromV2`).
+ * Trust's (`trust/src/tui/render.ts`), copied rather than imported: bays never import each other.
+ */
+
+import type { Theme } from "@opencode-cockpit/client/host"
+import type { RGBA } from "@opentui/core"
+import { type Fill, TINTS, type Tone } from "../core/view/rows.ts"
+
+const opaque = (colour: RGBA | undefined): colour is RGBA => colour !== undefined && colour.a > 0
+
+/** What the dialog is drawn on: the panel colour, or the background when a theme leaves the panel clear. */
+const surface = (theme: Theme): RGBA | undefined =>
+  opaque(theme.backgroundPanel)
+    ? theme.backgroundPanel
+    : opaque(theme.background)
+      ? theme.background
+      : undefined
+
+export function toneColour(theme: Theme, tone: Tone | undefined): RGBA {
+  switch (tone) {
+    case "muted":
+      return theme.textMuted
+    case "accent":
+      return theme.accent
+    case "info":
+      return theme.info
+    case "tool":
+      return theme.primary
+    case "success":
+      return theme.success
+    case "error":
+      return theme.error
+    case "warning":
+      return theme.warning
+    case "border":
+      return theme.border
+    /** Text cut out of a solid accent: the surface's own colour, or the text's when there is none. */
+    case "ink":
+      return surface(theme) ?? theme.text
+    default:
+      return theme.text
+  }
+}
+
+const mixed = new WeakMap>>()
+
+/**
+ * `tone` faded into `behind`, `amount` of the way from it: a tint that follows the theme. Built from
+ * the colour's own class, as Review's soften does, so nothing of OpenTUI is imported.
+ */
+function mix(tone: RGBA, behind: RGBA, amount: number): RGBA | undefined {
+  const byBack = mixed.get(tone) ?? new WeakMap>()
+  mixed.set(tone, byBack)
+  const byAmount = byBack.get(behind) ?? new Map()
+  byBack.set(behind, byAmount)
+  const known = byAmount.get(amount)
+  if (known) return known
+  const Colour = tone.constructor as unknown as { clone?: (colour: RGBA) => RGBA }
+  if (typeof Colour.clone !== "function" || typeof behind.r !== "number") return undefined
+  const out = Colour.clone(behind)
+  out.r = behind.r + (tone.r - behind.r) * amount
+  out.g = behind.g + (tone.g - behind.g) * amount
+  out.b = behind.b + (tone.b - behind.b) * amount
+  byAmount.set(amount, out)
+  return out
+}
+
+/**
+ * What sits behind a run. The row-wide surfaces and the buttons are the theme's raised element; a
+ * focused button is the accent; a chip or a badge is its tone's tint, and — on a theme whose dialog
+ * has no colour to tint — no fill at all: the word keeps its tone and still says what it is.
+ */
+export function fillColour(theme: Theme, fill: Fill | undefined): RGBA | undefined {
+  switch (fill) {
+    case "selected":
+    case "panel":
+    case "button":
+      return theme.backgroundElement
+    case "buttonOn":
+      return theme.accent
+    case "chip":
+    case "ok":
+    case "warn":
+    case "err": {
+      const behind = surface(theme)
+      if (!behind) return undefined
+      const tint = TINTS[fill]
+      return mix(toneColour(theme, tint.tone), behind, tint.amount)
+    }
+    default:
+      return undefined
+  }
+}
diff --git a/packages/trail/src/tui/source.ts b/packages/trail/src/tui/source.ts
new file mode 100644
index 00000000..b5e3aaa0
--- /dev/null
+++ b/packages/trail/src/tui/source.ts
@@ -0,0 +1,94 @@
+/**
+ * What the interface asks OpenCode about conversations, on either version: a session's parent and
+ * title, the project's sessions, and the jump to one. The client host has none of these, so they are
+ * reached through the version's own API (`api.v1`, `api.v2`) — the calls docs/opencode/trail-interface.md measured.
+ *
+ * - **Lists are the project's**: v1 `list({ scope: "project" })`; v2 `list({ project })` always —
+ *   v2's bare `list({})` is every session on the machine.
+ * - **Don't use the interface's caches as a list**: both versions' synced state holds only what this
+ *   interface happened to load. Everything here goes through the client.
+ * - The v1 client answers `{ data, error }`; the v2 client answers the data and throws.
+ */
+
+import type { Host } from "@opencode-cockpit/client/host"
+import type { Log } from "@opencode-cockpit/client/log"
+
+export interface SessionInfo {
+  id: string
+  parentID?: string
+  title?: string
+}
+
+export interface Sessions {
+  get(id: string): Promise
+  /** The project's conversations, children included; empty when the host would not say. */
+  list(): Promise
+  /** Switch the interface to a conversation. */
+  navigate(id: string): void
+}
+
+type Call = (input: Record) => Promise
+
+interface V1Client {
+  session: { get: Call; list: Call }
+}
+interface V2Client {
+  session: { get: Call; list: Call }
+}
+
+const isObject = (value: unknown): value is Record =>
+  typeof value === "object" && value !== null
+
+function infoOf(value: unknown): SessionInfo | undefined {
+  if (!isObject(value) || typeof value.id !== "string") return undefined
+  return {
+    id: value.id,
+    ...(typeof value.parentID === "string" ? { parentID: value.parentID } : {}),
+    ...(typeof value.title === "string" ? { title: value.title } : {}),
+  }
+}
+
+const listOf = (value: unknown): unknown[] =>
+  Array.isArray(value) ? value : isObject(value) && Array.isArray(value.data) ? value.data : []
+
+export function createSessions(api: Host, log: Log): Sessions {
+  if (api.v1) {
+    const v1 = api.v1
+    const client = v1.client as unknown as V1Client
+    /** v1 answers `{ data, error }` and does not throw. */
+    const data = async (call: Promise) => {
+      const answer = await call
+      return isObject(answer) && "data" in answer ? answer.data : undefined
+    }
+    return {
+      get: async (id) => infoOf(await data(client.session.get({ sessionID: id })).catch(() => undefined)),
+      list: async () =>
+        listOf(await data(client.session.list({ scope: "project" })).catch(() => [])).flatMap(
+          (s) => infoOf(s) ?? [],
+        ),
+      navigate: (id) => v1.route.navigate("session", { sessionID: id }),
+    }
+  }
+
+  const ctx = api.v2
+  if (!ctx) throw new Error("trail: neither OpenCode 1 nor 2")
+  const client = ctx.client as V2Client
+  const project = (ctx.location as { project?: { id?: string } } | undefined)?.project?.id
+  return {
+    get: async (id) => infoOf(await client.session.get({ sessionID: id }).catch(() => undefined)),
+    list: async () => {
+      if (!project) {
+        log.debug("no project id; not listing sessions")
+        return []
+      }
+      return listOf(await client.session.list({ project, limit: 500 }).catch(() => [])).flatMap(
+        (s) => infoOf(s) ?? [],
+      )
+    },
+    navigate: (id) =>
+      (ctx.ui.router as unknown as { navigate(to: { type: string; sessionID: string }): void }).navigate({
+        type: "session",
+        sessionID: id,
+      }),
+  }
+}
diff --git a/packages/trail/src/tui/view/dialog.tsx b/packages/trail/src/tui/view/dialog.tsx
new file mode 100644
index 00000000..705d6398
--- /dev/null
+++ b/packages/trail/src/tui/view/dialog.tsx
@@ -0,0 +1,55 @@
+/** @jsxImportSource @opentui/solid */
+// biome-ignore-all lint/a11y/noStaticElementInteractions: these are terminal boxes, not DOM elements
+import type { Host, Layer } from "@opencode-cockpit/client/host"
+import type { BoxRenderable, MouseEvent } from "@opentui/core"
+import type { JSX } from "solid-js"
+import type { Row } from "../../core/view/rows.ts"
+import { Rows } from "./rows.tsx"
+
+export interface DialogProps {
+  api: Host
+  rows: () => readonly Row[]
+  /**
+   * The dialog's keys. Registered from inside the dialog, as Shell's console does: while the host's
+   * dialog is open it takes the keys, so a global layer would never hear them, and a layer owned by
+   * the component goes when the dialog does — on either OpenCode.
+   */
+  keys: () => Layer
+  /** The wheel moves the cursor, three rows a notch, and the list follows it as `j`/`k` do. */
+  onScroll: (by: number) => void
+  /** A click, in the rows' own cells: column and row from their top-left corner. */
+  onClick: (x: number, y: number) => void
+}
+
+/**
+ * `/trail` in the host's dialog: rows from `core/view/dialog.ts`, and the keys and clicks that act on
+ * them — Trust's dialog, the same shape. Clicks are read on release: on press, anything a click opens is up in time
+ * to catch the release and take it for its own (Review's overlay learned this).
+ */
+export function Dialog(props: DialogProps): JSX.Element {
+  props.api.keymap.useLayer(props.keys)
+  let rows: BoxRenderable | undefined
+  return (
+     {
+        const scroll = event.scroll
+        if (!scroll) return
+        event.stopPropagation()
+        props.onScroll(scroll.direction === "up" ? -3 : 3)
+      }}
+      onMouseUp={(event: MouseEvent) => {
+        if (!rows) return
+        event.stopPropagation()
+        props.onClick(event.x - rows.x, event.y - rows.y)
+      }}
+    >
+       {
+          rows = box
+        }}
+      />
+    
+  )
+}
diff --git a/packages/trail/src/tui/view/rows.tsx b/packages/trail/src/tui/view/rows.tsx
new file mode 100644
index 00000000..cbc5fec1
--- /dev/null
+++ b/packages/trail/src/tui/view/rows.tsx
@@ -0,0 +1,57 @@
+/** @jsxImportSource @opentui/solid */
+// biome-ignore-all lint/a11y/noStaticElementInteractions: these are terminal boxes, not DOM elements
+import type { Host } from "@opencode-cockpit/client/host"
+import type { BoxRenderable } from "@opentui/core"
+import { For, type JSX } from "solid-js"
+import type { Row } from "../../core/view/rows.ts"
+import { fillColour, toneColour } from "../render.ts"
+
+export interface RowsProps {
+  api: Host
+  /** The rows `core/view/` produced; a signal, so `` redraws them. */
+  rows: () => readonly Row[]
+  onReady?: (box: BoxRenderable) => void
+  /** A click on a row, by its index. Read on release: the host acts on the release that follows a press. */
+  onRow?: (y: number) => void
+}
+
+/**
+ * Rows of runs, one `` per row, each in a box of its own so a click knows its row — the drawing
+ * both the sidebar block and `/trail` use.
+ *
+ * Shell's and Subagents' pattern, proven live on both OpenCodes: a signal read by `` redraws,
+ * where anything decided once in a slot's tree never would (docs/opencode/gotchas.md, "Slots"). The
+ * shape never changes; only the list of rows does.
+ */
+export function Rows(props: RowsProps): JSX.Element {
+  const theme = () => props.api.theme.current
+  return (
+     props.onReady?.(box)}>
+      
+        {(row, index) => (
+           props.onRow?.(index())}>
+            
+              
+                {(run) => {
+                  const fill = fillColour(theme(), run.fill)
+                  const style = { fg: toneColour(theme(), run.tone), ...(fill ? { bg: fill } : {}) }
+                  return run.bold ? (
+                    
+                      {run.text}
+                    
+                  ) : run.faint ? (
+                    
+                      {run.text}
+                    
+                  ) : (
+                    {run.text}
+                  )
+                }}
+              
+            
+          
+        )}
+      
+    
+  )
+}
diff --git a/packages/trail/test/add.test.ts b/packages/trail/test/add.test.ts
new file mode 100644
index 00000000..a55e3d9b
--- /dev/null
+++ b/packages/trail/test/add.test.ts
@@ -0,0 +1,138 @@
+import { describe, expect, test } from "bun:test"
+import { checkAdd, checkList, LIMITS, recordedEvent } from "../src/core/add.ts"
+import { apply, emptyState } from "../src/core/store.ts"
+
+const who = { session: "ses_1", rootSession: "ses_1", by: "agent" as const, at: 1000 }
+
+const refused = (args: unknown) => {
+  const checked = checkAdd(args)
+  if (checked.ok) throw new Error("expected a refusal")
+  return checked.error
+}
+
+describe("trail_add's arguments", () => {
+  test("a title and a url is enough", () => {
+    expect(checkAdd({ title: "Release notes", url: "https://acme.atlassian.net/wiki/x/1" })).toEqual({
+      ok: true,
+      fields: { title: "Release notes", url: "https://acme.atlassian.net/wiki/x/1" },
+      stripped: [],
+    })
+  })
+
+  test("a title and a ref is enough", () => {
+    expect(checkAdd({ title: "Bump", ref: "a1b2c3d" }).ok).toBe(true)
+  })
+
+  test("no title: refused, saying what a title is and that nothing was recorded", () => {
+    const error = refused({ url: "https://github.com/a/b/pull/1" })
+    expect(error).toContain("needs a `title`")
+    expect(error).toContain("Nothing was recorded")
+    expect(error).toContain("Call it again")
+    expect(refused({ title: "   ", url: "https://x.dev" })).toContain("needs a `title`")
+  })
+
+  test("neither url nor ref: refused, with what each is", () => {
+    const error = refused({ title: "Something" })
+    expect(error).toContain("`url`")
+    expect(error).toContain("`ref`")
+    expect(error).toContain("Nothing was recorded")
+  })
+
+  test("a url that is not a web link: refused, pointing at ref instead", () => {
+    const error = refused({ title: "Notes", url: "file:///tmp/notes.md" })
+    expect(error).toContain("http(s)")
+    expect(error).toContain("pass `ref`")
+    expect(refused({ title: "x", url: "javascript:alert(1)" })).toContain("http(s)")
+  })
+
+  test("not text, or not an object: refused, saying so", () => {
+    expect(refused({ title: ["a", "b"], ref: "x" })).toContain("`title` must be text, got a list")
+    expect(refused({ title: "a", ref: { id: 1 } })).toContain("`ref` must be text, got object")
+    expect(refused("PR #33")).toContain("takes an object")
+    expect(refused(null)).toContain("takes an object")
+  })
+
+  test("a link without its scheme is taken as https", () => {
+    const checked = checkAdd({ title: "PR", url: "github.com/a/b/pull/4" })
+    expect(checked.ok && checked.fields.url).toBe("https://github.com/a/b/pull/4")
+  })
+
+  test("a PR link names itself when no ref is given; a given ref wins", () => {
+    const derived = checkAdd({ title: "PR", url: "https://github.com/a/b/pull/4" })
+    expect(derived.ok && derived.fields.ref).toBe("a/b#4")
+    const given = checkAdd({ title: "PR", url: "https://github.com/a/b/pull/4", ref: "WEB-4" })
+    expect(given.ok && given.fields.ref).toBe("WEB-4")
+  })
+
+  test("secrets are stripped from the link, and named", () => {
+    const checked = checkAdd({ title: "Report", url: "https://r.acme.dev/x?token=abc&tab=2" })
+    expect(checked).toEqual({
+      ok: true,
+      fields: { title: "Report", url: "https://r.acme.dev/x?tab=2" },
+      stripped: ["token"],
+    })
+  })
+
+  test("free text is kept on one line, and cut when too long", () => {
+    const checked = checkAdd({
+      title: "Two\nlines",
+      ref: 33,
+      note: "n".repeat(LIMITS.note + 50),
+      kind: "  ",
+      for: null,
+    })
+    if (!checked.ok) throw new Error(checked.error)
+    expect(checked.fields.title).toBe("Two lines")
+    expect(checked.fields.ref).toBe("33")
+    expect(checked.fields.note?.length).toBe(LIMITS.note)
+    expect(checked.fields.note?.endsWith("…")).toBe(true)
+    expect(checked.fields).not.toHaveProperty("kind")
+    expect(checked.fields).not.toHaveProperty("for")
+  })
+})
+
+describe("the event a call becomes", () => {
+  test("unsaid, the action is created for something new and updated for something known", () => {
+    const state = emptyState()
+    const fields = { title: "PR", url: "https://github.com/a/b/pull/4" }
+    const first = recordedEvent(fields, { ...who, id: "e1" }, state)
+    expect(first.action).toBe("created")
+    apply(state, first)
+    expect(recordedEvent(fields, { ...who, id: "e2" }, state).action).toBe("updated")
+    expect(recordedEvent({ ...fields, action: "merged" }, { ...who, id: "e3" }, state).action).toBe("merged")
+  })
+
+  test("carries who made it and in which conversation", () => {
+    const event = recordedEvent(
+      { title: "Doc", ref: "D-1" },
+      {
+        session: "ses_child",
+        rootSession: "ses_root",
+        sessionTitle: "Plan",
+        by: "agent",
+        subagent: "docs",
+        at: 5,
+      },
+      emptyState(),
+    )
+    expect(event).toMatchObject({
+      type: "recorded",
+      session: "ses_child",
+      rootSession: "ses_root",
+      sessionTitle: "Plan",
+      subagent: "docs",
+      by: "agent",
+      at: 5,
+    })
+    expect(event.id).toMatch(/^tr_/)
+  })
+})
+
+describe("trail_list's arguments", () => {
+  test("both optional, and forgiving", () => {
+    expect(checkList(undefined)).toEqual({ all: false, query: "" })
+    expect(checkList({ all: true, query: " COM-1736 " })).toEqual({ all: true, query: "COM-1736" })
+    expect(checkList({ all: "true" }).all).toBe(true)
+    expect(checkList({ all: "no", query: 4 })).toEqual({ all: false, query: "" })
+  })
+})
diff --git a/packages/trail/test/config.test.ts b/packages/trail/test/config.test.ts
new file mode 100644
index 00000000..704f7061
--- /dev/null
+++ b/packages/trail/test/config.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, test } from "bun:test"
+import { loadTrail } from "../src/core/config.ts"
+
+/** Settings files as text on a pretend disk: the loader every bay shares, Trail's section of it. */
+const at = (files: Record) => ({
+  env: { XDG_CONFIG_HOME: "/home/me/.config" },
+  read: (path: string) => files[path],
+})
+const GLOBAL = "/home/me/.config/opencode-cockpit/config.json"
+const PROJECT = "/work/app/.cockpit.json"
+
+describe("Trail's settings", () => {
+  test("nothing written: on, in the sidebar, five rows, after Shells", () => {
+    const { settings, order, notices } = loadTrail("/work/app", undefined, at({}))
+    expect(settings).toEqual({
+      enabled: true,
+      sidebar: true,
+      sidebarRows: 5,
+      hideWhenEmpty: false,
+      keybinds: {},
+    })
+    /** status, subagents, shell, trail: the fourth place. */
+    expect(order).toBe(140)
+    expect(notices).toEqual([])
+  })
+
+  test("the shared keys, from a file with comments, the project over the global one", () => {
+    const { settings } = loadTrail(
+      "/work/app",
+      undefined,
+      at({
+        [GLOBAL]: '{\n  // mine\n  "trail": { "sidebarRows": 8, "hideWhenEmpty": true, },\n}',
+        [PROJECT]: '{ "trail": { "sidebar": false, "keybinds": { "cockpit.trail.open": "j" } } }',
+      }),
+    )
+    expect(settings).toEqual({
+      enabled: true,
+      sidebar: false,
+      sidebarRows: 8,
+      hideWhenEmpty: true,
+      keybinds: { "cockpit.trail.open": "j" },
+    })
+  })
+
+  test("the top-level list moves the block; a wrong kind is a notice and the default", () => {
+    const loaded = loadTrail(
+      "/work/app",
+      undefined,
+      at({ [GLOBAL]: '{ "sidebar": ["trail", "status"], "trail": { "sidebarRows": "lots" } }' }),
+    )
+    expect(loaded.order).toBe(110)
+    expect(loaded.settings.sidebarRows).toBe(5)
+    expect(loaded.notices).toEqual(['settings: "trail.sidebarRows" should be a number; the default is used'])
+  })
+
+  test("off: `enabled: false` in plugin options, or `features.trail: false` in a file", () => {
+    expect(loadTrail("/work/app", { enabled: false }, at({})).settings.enabled).toBe(false)
+    expect(
+      loadTrail("/work/app", undefined, at({ [PROJECT]: '{ "features": { "trail": false } }' })).settings
+        .enabled,
+    ).toBe(false)
+  })
+})
diff --git a/packages/trail/test/journal.test.ts b/packages/trail/test/journal.test.ts
new file mode 100644
index 00000000..8508d7b6
--- /dev/null
+++ b/packages/trail/test/journal.test.ts
@@ -0,0 +1,96 @@
+import { describe, expect, test } from "bun:test"
+import { appendFileSync, mkdtempSync, writeFileSync } from "node:fs"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+import { createJournal } from "../src/core/journal.ts"
+import { trailPaths } from "../src/core/paths.ts"
+import { type Event, emptyState, serialize } from "../src/core/store.ts"
+
+const paths = () => trailPaths("/work/app", { COCKPIT_HOME: mkdtempSync(join(tmpdir(), "trail-journal-")) })
+const event = (id: string, url = `https://github.com/a/b/pull/${id}`): Event => ({
+  v: 1,
+  at: Number(id) || 1,
+  id,
+  type: "recorded",
+  rootSession: "ses_1",
+  session: "ses_1",
+  by: "agent",
+  title: `PR ${id}`,
+  url,
+  action: "created",
+})
+
+describe("where the trail lives", () => {
+  test("outside the project, one folder per checkout", () => {
+    const env = { HOME: "/home/me" }
+    const a = trailPaths("/home/me/a/web", env)
+    const b = trailPaths("/home/me/b/web", env)
+    expect(a.events).toMatch(
+      /^\/home\/me\/\.local\/share\/opencode-cockpit\/trail\/a-web-[0-9a-f]{8}\/events\.ndjson$/,
+    )
+    expect(a.dir).not.toBe(b.dir)
+    expect(
+      trailPaths("/x/y", { XDG_DATA_HOME: "/data" }).dir.startsWith("/data/opencode-cockpit/trail/"),
+    ).toBe(true)
+  })
+})
+
+describe("the trail file", () => {
+  test("nothing there yet is nothing to read", async () => {
+    expect(await createJournal(paths()).read()).toEqual({ events: [], reset: false })
+  })
+
+  test("appends are read back once, in order", async () => {
+    const journal = createJournal(paths())
+    await journal.append([event("1"), event("2")])
+    expect((await journal.read()).events).toEqual([event("1"), event("2")])
+    expect((await journal.read()).events).toEqual([])
+  })
+
+  test("several writers on one file — windows, or one agent instance per directory — each read all", async () => {
+    const where = paths()
+    const one = createJournal(where)
+    const two = createJournal(where)
+    const three = createJournal(where)
+    await Promise.all([one.append([event("1")]), two.append([event("2")]), three.append([event("3")])])
+    const state = await one.sync(emptyState())
+    expect([...state.records.values()].map((record) => record.title).sort()).toEqual(["PR 1", "PR 2", "PR 3"])
+    expect((await two.sync(emptyState())).records.size).toBe(3)
+  })
+
+  test("sync folds only what is new, and a line read twice counts once", async () => {
+    const where = paths()
+    const journal = createJournal(where)
+    await journal.append([event("1")])
+    let state = await journal.sync(emptyState())
+    await createJournal(where).append([{ ...event("2", "https://github.com/a/b/pull/1"), action: "updated" }])
+    state = await journal.sync(state)
+    expect([...state.records.values()][0]?.history.map((step) => step.action)).toEqual(["created", "updated"])
+    /** The same line appended again (a retried write) is the same event. */
+    appendFileSync(where.events, serialize(event("1")))
+    state = await journal.sync(state)
+    expect([...state.records.values()][0]?.history).toHaveLength(2)
+  })
+
+  test("a torn last line waits; the next whole line after it is recovered", async () => {
+    const where = paths()
+    const journal = createJournal(where)
+    await journal.append([event("1")])
+    await journal.read()
+    appendFileSync(where.events, serialize(event("2")).slice(0, 15))
+    expect((await journal.read()).events).toEqual([])
+    appendFileSync(where.events, serialize(event("3")))
+    expect((await journal.read()).events).toEqual([event("3")])
+  })
+
+  test("a replaced file is folded again from its start", async () => {
+    const where = paths()
+    const journal = createJournal(where)
+    await journal.append([event("1"), event("2"), event("3")])
+    let state = await journal.sync(emptyState())
+    expect(state.records.size).toBe(3)
+    writeFileSync(where.events, serialize(event("9")))
+    state = await journal.sync(state)
+    expect([...state.records.values()].map((record) => record.title)).toEqual(["PR 9"])
+  })
+})
diff --git a/packages/trail/test/links.test.ts b/packages/trail/test/links.test.ts
new file mode 100644
index 00000000..7e9eaa00
--- /dev/null
+++ b/packages/trail/test/links.test.ts
@@ -0,0 +1,128 @@
+import { describe, expect, test } from "bun:test"
+import {
+  cleanUrl,
+  linkKey,
+  linksIn,
+  openable,
+  secretParam,
+  systemOf,
+  workIn,
+  workOf,
+} from "../src/core/links.ts"
+
+describe("the system a link belongs to", () => {
+  test.each([
+    ["https://github.com/Codestz/opencode-cockpit/pull/33", "GitHub"],
+    ["https://acme.atlassian.net/wiki/spaces/ENG/pages/1", "Confluence"],
+    ["https://acme.atlassian.net/browse/COM-1736", "Jira"],
+    ["https://claude.ai/public/artifacts/9f2c", "Claude"],
+    ["https://linear.app/acme/issue/ENG-42/retry", "Linear"],
+    ["https://gitlab.com/group/project/-/merge_requests/12", "GitLab"],
+    ["https://www.notion.so/acme/Plan-123", "notion.so"],
+    ["https://status.acme.dev/incidents/88", "status.acme.dev"],
+  ])("%s → %s", (url, system) => {
+    expect(systemOf(url)).toBe(system)
+  })
+
+  test("no link, or not a web link, has no system", () => {
+    expect(systemOf(undefined)).toBeUndefined()
+    expect(systemOf("file:///tmp/notes.md")).toBeUndefined()
+    expect(systemOf("COM-1736")).toBeUndefined()
+  })
+
+  test("the table is extensible: a row added is read", () => {
+    expect(systemOf("https://ci.acme.dev/job/1", [{ host: "ci.acme.dev", name: "Jenkins" }])).toBe("Jenkins")
+  })
+})
+
+describe("safe links", () => {
+  test("only http(s) opens", () => {
+    expect(openable("https://github.com/x")).toBe(true)
+    expect(openable("http://localhost:3000")).toBe(true)
+    for (const url of ["javascript:alert(1)", "file:///etc/passwd", "ssh://host", "", undefined])
+      expect(openable(url)).toBe(false)
+  })
+
+  test("secret-looking query parameters and credentials are stripped; the rest stays", () => {
+    const { url, stripped } = cleanUrl(
+      new URL(
+        "https://user:pw@s3.amazonaws.com/b/k?X-Amz-Signature=abc&X-Amz-Credential=c&versionId=3&token=t&sig=s&view=full",
+      ),
+    )
+    expect(url).toBe("https://s3.amazonaws.com/b/k?versionId=3&view=full")
+    expect(stripped).toEqual(["credentials", "X-Amz-Signature", "X-Amz-Credential", "token", "sig"])
+  })
+
+  test.each([
+    "token",
+    "access_token",
+    "api_key",
+    "key",
+    "signature",
+    "client_secret",
+    "X-Goog-Signature",
+    "githubToken",
+    "private_token",
+  ])("%s is a secret", (name) => {
+    expect(secretParam(name)).toBe(true)
+  })
+
+  test.each(["view", "page", "tab", "q", "versionId", "monkey"])("%s is not", (name) => {
+    expect(secretParam(name)).toBe(false)
+  })
+})
+
+describe("PRs and issues, from their links", () => {
+  test("GitHub pull and issue", () => {
+    expect(workOf("https://github.com/Codestz/opencode-cockpit/pull/33/files")).toEqual({
+      url: "https://github.com/Codestz/opencode-cockpit/pull/33",
+      ref: "Codestz/opencode-cockpit#33",
+      label: "PR #33",
+    })
+    expect(workOf("https://github.com/acme/web/issues/12#issuecomment-1")?.label).toBe("issue #12")
+  })
+
+  test("GitLab merge requests and issues, on any host", () => {
+    expect(workOf("https://git.acme.dev/group/sub/project/-/merge_requests/7")).toEqual({
+      url: "https://git.acme.dev/group/sub/project/-/merge_requests/7",
+      ref: "group/sub/project!7",
+      label: "MR !7",
+    })
+    expect(workOf("https://gitlab.com/g/p/-/issues/3")?.ref).toBe("g/p#3")
+  })
+
+  test("Jira browse and Linear issues", () => {
+    expect(workOf("https://acme.atlassian.net/browse/COM-1736?focusedCommentId=1")).toEqual({
+      url: "https://acme.atlassian.net/browse/COM-1736",
+      ref: "COM-1736",
+      label: "COM-1736",
+    })
+    expect(workOf("https://linear.app/acme/issue/ENG-42/retry-the-socket")?.ref).toBe("ENG-42")
+  })
+
+  test("anything else is not a work item", () => {
+    for (const url of [
+      "https://github.com/acme/web",
+      "https://github.com/acme/web/pull/new/branch",
+      "https://acme.atlassian.net/wiki/x/1",
+      "https://example.com/pull/3",
+    ])
+      expect(workOf(url)).toBeUndefined()
+  })
+
+  test("found in output, prose punctuation and repeats left out", () => {
+    const text =
+      "Created https://github.com/a/b/pull/1.\nSee (https://github.com/a/b/pull/1/files) and https://acme.atlassian.net/browse/X-9, https://example.com."
+    expect(workIn(text).map((work) => work.url)).toEqual([
+      "https://github.com/a/b/pull/1",
+      "https://acme.atlassian.net/browse/X-9",
+    ])
+    expect(linksIn(text)).toContain("https://example.com")
+  })
+
+  test("two links to one thing compare equal", () => {
+    expect(linkKey("https://github.com/a/b/pull/1/files")).toBe(linkKey("https://GitHub.com/a/b/pull/1"))
+    expect(linkKey("https://Docs.acme.dev/page/")).toBe(linkKey("https://docs.acme.dev/page#top"))
+    expect(linkKey("https://docs.acme.dev/page?id=1")).not.toBe(linkKey("https://docs.acme.dev/page?id=2"))
+  })
+})
diff --git a/packages/trail/test/model.test.ts b/packages/trail/test/model.test.ts
new file mode 100644
index 00000000..60f8783f
--- /dev/null
+++ b/packages/trail/test/model.test.ts
@@ -0,0 +1,160 @@
+import { describe, expect, test } from "bun:test"
+import {
+  arrange,
+  conversationThings,
+  foundIn,
+  type Line,
+  linesOf,
+  matches,
+  notRecorded,
+  projectThings,
+} from "../src/core/model.ts"
+import { SAMPLE_NOW, SAMPLE_SESSION, SAMPLES } from "../src/core/sample.ts"
+import { emptyState, type State } from "../src/core/store.ts"
+import { runAdd } from "../src/core/tools.ts"
+
+let at = 0
+function trail(adds: { [key: string]: string }[], session = "s1", state: State = emptyState()): State {
+  for (const args of adds) {
+    const out = runAdd(state, args, { session, rootSession: session, by: "agent", at: ++at * 60_000 })
+    if (!out.ok) throw new Error(out.text)
+  }
+  return state
+}
+
+const names = (lines: Line[]) =>
+  lines.map((line) =>
+    line.kind === "head"
+      ? `# ${line.name}`
+      : `${"  ".repeat(line.depth)}${line.thing.label ?? line.thing.title}`,
+  )
+
+describe("order: by the work, not by the tool", () => {
+  test("a ticket heads its PRs, newest first inside; groups come before things that stand alone", () => {
+    const state = trail([
+      { title: "Deploy", ref: "deploy-1", kind: "deploy" },
+      { title: "Bundle desync", url: "https://acme.atlassian.net/browse/COM-1736" },
+      { title: "Older PR", url: "https://github.com/a/b/pull/12", for: "COM-1736" },
+      { title: "Newer PR", url: "https://github.com/a/b/pull/33", for: "COM-1736" },
+      { title: "Page", url: "https://acme.atlassian.net/wiki/x/1" },
+    ])
+    expect(names(linesOf(arrange(conversationThings(state, "s1"))))).toEqual([
+      "COM-1736",
+      "  PR #33",
+      "  PR #12",
+      "Page",
+      "deploy-1",
+    ])
+  })
+
+  test("`for` may be the ticket's link, and a ticket nothing records heads its group by name", () => {
+    const state = trail([
+      { title: "A", url: "https://github.com/a/b/pull/1", for: "https://acme.atlassian.net/browse/COM-9" },
+      { title: "B", ref: "abc123", kind: "commit", for: "com-9" },
+    ])
+    const lines = linesOf(arrange(conversationThings(state, "s1")))
+    expect(names(lines)).toEqual(["# COM-9", "  abc123", "  PR #1"])
+  })
+
+  test("a chain is one group under its top; a loop draws every thing once", () => {
+    const chain = trail([
+      { title: "Epic", ref: "EPIC-1" },
+      { title: "Story", ref: "ST-1", for: "EPIC-1" },
+      { title: "PR", url: "https://github.com/a/b/pull/2", for: "ST-1" },
+    ])
+    expect(names(linesOf(arrange(conversationThings(chain, "s1"))))).toEqual(["EPIC-1", "  PR #2", "  ST-1"])
+    const loop = trail([
+      { title: "One", ref: "L-1", for: "L-2" },
+      { title: "Two", ref: "L-2", for: "L-1" },
+    ])
+    expect(names(linesOf(arrange(conversationThings(loop, "s1"))))).toEqual(["L-1", "  L-2"])
+  })
+
+  test("the busy sample, as the design sketches it", () => {
+    const { state, session } = (SAMPLES.busy as () => { state: State; session: string })()
+    const lines = names(linesOf(arrange(conversationThings(state, session))))
+    expect(lines.slice(0, 6)).toEqual([
+      "# COM-1801",
+      "  a1b2c3d",
+      "  ENG-42",
+      "COM-1736",
+      "  PR #33",
+      "  PR #12",
+    ])
+  })
+})
+
+describe("search", () => {
+  const state = trail([
+    { title: "Release notes for 0.8", url: "https://acme.atlassian.net/wiki/x/1", kind: "page" },
+    { title: "Fix desync", url: "https://github.com/a/b/pull/33", for: "COM-1736" },
+    { title: "Bundle desync", url: "https://acme.atlassian.net/browse/COM-1736" },
+  ])
+  const things = conversationThings(state, "s1")
+  const found = (query: string) => things.filter((thing) => matches(thing, query)).map((thing) => thing.title)
+
+  test("over title, ref, kind and system — every word, case-blind", () => {
+    expect(found("release")).toEqual(["Release notes for 0.8"])
+    expect(found("a/b#33")).toEqual(["Fix desync"])
+    expect(found("PAGE")).toEqual(["Release notes for 0.8"])
+    expect(found("confluence")).toEqual(["Release notes for 0.8"])
+    expect(found("desync jira")).toEqual(["Bundle desync"])
+    expect(found("")).toHaveLength(3)
+  })
+
+  test("a query keeps a group's head as context, and counts only what matched", () => {
+    const arranged = arrange(things, "github")
+    expect(names(linesOf(arranged))).toEqual(["COM-1736", "  PR #33"])
+    expect(arranged.shown).toBe(1)
+    expect(arranged.total).toBe(3)
+  })
+})
+
+describe("All conversations", () => {
+  test("one row per link, every conversation that touched it, the latest first", () => {
+    const state = trail([{ title: "PR", url: "https://github.com/a/b/pull/1" }], "s1")
+    trail(
+      [{ title: "PR, reviewed", url: "https://github.com/a/b/pull/1/files", action: "reviewed" }],
+      "s2",
+      state,
+    )
+    trail([{ title: "Other", ref: "X-1" }], "s2", state)
+    const things = projectThings(state)
+    expect(things).toHaveLength(2)
+    const pr = things.find((thing) => thing.label === "PR #1")
+    expect(pr?.touches.map((touch) => touch.session)).toEqual(["s2", "s1"])
+    expect(pr?.title).toBe("PR, reviewed")
+    expect(pr?.history.map((step) => step.action)).toEqual(["created", "reviewed"])
+  })
+
+  test("a deleted conversation's touch is marked, and keeps its title", () => {
+    const { state } = (SAMPLES.project as () => { state: State })()
+    const gone = projectThings(state).find((thing) => thing.title === "Old dashboard")
+    expect(gone?.touches[0]).toMatchObject({ deleted: true, title: "Set up the latency dashboard" })
+  })
+})
+
+describe("seen in output, not recorded", () => {
+  test("links in output minus what this conversation recorded, by link or derived ref", () => {
+    const state = trail([
+      { title: "Recorded", url: "https://github.com/a/b/pull/1" },
+      { title: "By ref", ref: "a/b#2" },
+    ])
+    const seen = [
+      ...foundIn("https://github.com/a/b/pull/1/files https://github.com/a/b/pull/2", 10),
+      ...foundIn("Created https://github.com/a/b/pull/3.", 20),
+      ...foundIn("again https://github.com/a/b/pull/3", 30),
+    ]
+    expect(notRecorded(state, "s1", seen).map((each) => [each.url, each.at])).toEqual([
+      ["https://github.com/a/b/pull/3", 20],
+    ])
+    expect(notRecorded(state, "s9", seen)).toHaveLength(3)
+  })
+
+  test("a find knows its link and the ref it derives", () => {
+    expect(foundIn("https://acme.atlassian.net/browse/COM-9", SAMPLE_NOW)).toEqual([
+      { url: "https://acme.atlassian.net/browse/COM-9", ref: "COM-9", at: SAMPLE_NOW },
+    ])
+    expect(SAMPLE_SESSION).toBe("ses_main")
+  })
+})
diff --git a/packages/trail/test/scan.test.ts b/packages/trail/test/scan.test.ts
new file mode 100644
index 00000000..e4289153
--- /dev/null
+++ b/packages/trail/test/scan.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, test } from "bun:test"
+import { findsOf, ran } from "../src/core/scan.ts"
+import { linkArgs } from "../src/core/tools.ts"
+import { openUrl } from "../src/tui/open.ts"
+
+/** Shapes as measured on 1.18.32 and 2.0.18 (docs/opencode/trail-server.md). */
+
+describe("which calls count: what the agent ran", () => {
+  test("shells and MCP tools; never reading, fetching, a subagent's answer or Code Mode's outer call", () => {
+    for (const tool of ["bash", "shell", "shell_read", "github_create_pull_request", "spike_open_pr"])
+      expect(ran(tool)).toBe(true)
+    for (const tool of ["read", "webfetch", "websearch", "grep", "task", "subagent", "execute", "trail_add"])
+      expect(ran(tool)).toBe(false)
+  })
+
+  test("only PR and issue links, by their own address", () => {
+    const found = findsOf(
+      {
+        tool: "bash",
+        output:
+          "https://github.com/acme/web/pull/40/files\nhttps://acme.dev/docs\nhttps://acme.atlassian.net/browse/COM-9.",
+      },
+      5,
+    )
+    expect(found.map((f) => f.url)).toEqual([
+      "https://github.com/acme/web/pull/40",
+      "https://acme.atlassian.net/browse/COM-9",
+    ])
+    expect(found[0]).toEqual({ url: "https://github.com/acme/web/pull/40", ref: "acme/web#40", at: 5 })
+  })
+})
+
+describe("opening a link (which program: client's openerFor)", () => {
+  test("only http(s) is handed to an opener; anything else is refused, and says so", () => {
+    for (const url of ["file:///etc/passwd", "javascript:alert(1)"]) {
+      const said: string[] = []
+      openUrl(url, (why) => said.push(why))
+      expect(said).toEqual(["only http(s) links are opened"])
+    }
+  })
+})
+
+describe("/link: the link, then a note", () => {
+  test("the note is the title; with none, a PR's own name, else the link", () => {
+    expect(linkArgs("https://github.com/a/b/pull/4  the checkout fix")).toEqual({
+      title: "the checkout fix",
+      url: "https://github.com/a/b/pull/4",
+    })
+    expect(linkArgs("https://github.com/a/b/pull/4")).toEqual({
+      title: "a/b#4",
+      url: "https://github.com/a/b/pull/4",
+    })
+    expect(linkArgs("https://acme.dev/runbook/")).toEqual({
+      title: "acme.dev/runbook",
+      url: "https://acme.dev/runbook/",
+    })
+    expect(linkArgs("   ")).toBeUndefined()
+  })
+})
diff --git a/packages/trail/test/server.test.ts b/packages/trail/test/server.test.ts
new file mode 100644
index 00000000..b4496e08
--- /dev/null
+++ b/packages/trail/test/server.test.ts
@@ -0,0 +1,197 @@
+import { afterAll, beforeEach, describe, expect, test } from "bun:test"
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+import type { ToolContext, ToolDefinition } from "@opencode-ai/plugin"
+import { silentLog } from "@opencode-cockpit/client/log"
+import type { ServerHost, ServerParts } from "@opencode-cockpit/client/server"
+import { createTrailServer, SEEN_FOR_MS } from "../src/agent/plugin.ts"
+import { GUIDANCE } from "../src/core/text.ts"
+
+/**
+ * The agent half as a model meets it: a fake OpenCode holding a conversation with a subagent, the
+ * tools called as the model calls them, and the lines each request is given. The trail file is real,
+ * in a folder of its own.
+ */
+
+const ROOT = "ses_root"
+const CHILD = "ses_child"
+const sessions: Record = {
+  [ROOT]: { title: "Fix the checkout" },
+  [CHILD]: { parentID: ROOT, title: "Open the PR (@general subagent)" },
+  ses_other: { title: "Another conversation" },
+}
+
+const home = mkdtempSync(join(tmpdir(), "trail-server-"))
+const saved = { COCKPIT_HOME: process.env.COCKPIT_HOME, XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME }
+process.env.COCKPIT_HOME = join(home, "data")
+/** No settings file but the test's own: the person running the tests keeps theirs. */
+process.env.XDG_CONFIG_HOME = join(home, "config")
+afterAll(() => {
+  for (const [key, value] of Object.entries(saved))
+    if (value === undefined) delete process.env[key]
+    else process.env[key] = value
+  rmSync(home, { recursive: true, force: true })
+})
+
+let project = ""
+beforeEach(() => {
+  project = mkdtempSync(join(home, "project-"))
+})
+
+function host(directory = project): ServerHost {
+  return {
+    version: 1,
+    directory,
+    scope: {},
+    log: silentLog,
+    readFile: async () => undefined,
+    session: {
+      get: async (id) => sessions[id],
+      notify: async () => {},
+    },
+  }
+}
+
+const context = (sessionID: string, agent = "build") => ({ sessionID, agent }) as unknown as ToolContext
+const call = async (parts: ServerParts, name: string, args: unknown, sessionID = ROOT, agent?: string) => {
+  const def = parts.tools?.[name]
+  if (!def) throw new Error(`no tool ${name}`)
+  return String(await (def as ToolDefinition).execute(args as never, context(sessionID, agent)))
+}
+const start = async (options?: unknown, at = host()) => createTrailServer({ source: "test" })(at, options)
+
+describe("the tools", () => {
+  test("trail_add records, and trail_list reads it back the same", async () => {
+    const parts = await start()
+    const added = await call(parts, "trail_add", {
+      title: "Fix the checkout",
+      url: "https://github.com/acme/web/pull/40",
+      for: "COM-1",
+    })
+    expect(added).toContain('Recorded PR #40 "Fix the checkout" (GitHub) — created.')
+    const listed = await call(parts, "trail_list", {})
+    expect(listed).toContain('PR #40 "Fix the checkout"')
+    expect(listed).toContain("https://github.com/acme/web/pull/40")
+  })
+
+  test("a refusal says what to send, and nothing is written", async () => {
+    const parts = await start()
+    expect(await call(parts, "trail_add", { title: "No link" })).toContain("Nothing was recorded.")
+    expect(await call(parts, "trail_list", {})).toContain("Nothing is in this conversation's trail yet.")
+  })
+
+  test("a subagent's record belongs to the conversation, and remembers the subagent", async () => {
+    const parts = await start()
+    await call(parts, "trail_add", { title: "Docs", url: "https://acme.dev/docs/1" }, CHILD, "general")
+    const listed = await call(parts, "trail_list", {})
+    expect(listed).toContain('"Docs"')
+    expect(listed).toContain("by subagent general")
+    const all = await call(parts, "trail_list", { all: true }, "ses_other")
+    expect(all).toContain('in "Fix the checkout" (ses_root)')
+  })
+
+  test("another window's records are read from the same file", async () => {
+    const one = await start(undefined, host())
+    const two = createTrailServer({ source: "test-2" })
+    const other = await two(host(project), undefined)
+    await call(one, "trail_add", { title: "From one", url: "https://acme.dev/one" })
+    expect(await call(other, "trail_list", {})).toContain('"From one"')
+  })
+})
+
+describe("the Cockpit-wide line", () => {
+  test("the trail is in the sidebar, or only behind its key when the block is off", async () => {
+    expect((await start()).surfaces).toEqual([{ what: "this conversation's trail", open: "ctrl+x f" }])
+    expect((await start({ sidebar: false }, host())).surfaces).toEqual([
+      { what: "this conversation's trail", where: "the trail view", open: "ctrl+x f" },
+    ])
+  })
+})
+
+describe("every request", () => {
+  test("the guidance, always — subagents and unknown sessions too", async () => {
+    const parts = await start()
+    expect(await parts.system?.(undefined)).toEqual([GUIDANCE])
+    expect(await parts.system?.(CHILD)).toEqual([GUIDANCE])
+  })
+
+  test("what the conversation produced, rebuilt from the file — in the subagent's requests too", async () => {
+    const parts = await start()
+    await call(parts, "trail_add", { title: "Fix the checkout", url: "https://github.com/acme/web/pull/40" })
+    const lines = (await parts.system?.(CHILD)) ?? []
+    expect(lines[1]).toBe('Trail — this conversation produced: PR #40 "Fix the checkout" (created).')
+    /** A fresh start, as after a restart or a compaction: the same line, from the file alone. */
+    const again = await start()
+    expect((await again.system?.(ROOT))?.[1]).toBe(lines[1])
+  })
+
+  test("a PR link printed by a command is put to the agent as a choice, until recorded", async () => {
+    const parts = await start()
+    await parts.toolAfter?.({
+      sessionID: CHILD,
+      tool: "bash",
+      callID: "c1",
+      args: {},
+      output: "Creating pull request for feat into main\n\nhttps://github.com/acme/web/pull/41\n",
+    })
+    const seen = (await parts.system?.(ROOT))?.at(-1)
+    expect(seen).toBe(
+      "Seen in output — record it with tools.trail_add if you created or changed it: https://github.com/acme/web/pull/41",
+    )
+    await call(parts, "trail_add", { title: "Checkout", url: "https://github.com/acme/web/pull/41" })
+    expect((await parts.system?.(ROOT))?.join("\n")).not.toContain("Seen in output")
+  })
+
+  test("MCP output counts; a file read or a page fetched does not", async () => {
+    const parts = await start()
+    const after = (tool: string, output: string) =>
+      parts.toolAfter?.({ sessionID: ROOT, tool, callID: tool, args: {}, output })
+    await after("read", "see https://github.com/acme/web/pull/1")
+    await after("webfetch", "https://github.com/acme/web/issues/2")
+    await after("task", "the subagent said https://github.com/acme/web/pull/3")
+    await after("github_create_issue", "Created https://github.com/acme/web/issues/4")
+    const seen = (await parts.system?.(ROOT))?.at(-1) ?? ""
+    expect(seen).toContain("/issues/4")
+    for (const n of ["/pull/1", "/issues/2", "/pull/3"]) expect(seen).not.toContain(n)
+  })
+
+  test("a link seen long ago is no longer put to the agent", async () => {
+    const parts = await start()
+    const real = Date.now
+    Date.now = () => real() - SEEN_FOR_MS - 1000
+    try {
+      await parts.toolAfter?.({
+        sessionID: ROOT,
+        tool: "bash",
+        callID: "x",
+        args: {},
+        output: "https://github.com/a/b/pull/9",
+      })
+    } finally {
+      Date.now = real
+    }
+    expect((await parts.system?.(ROOT))?.join("\n")).not.toContain("Seen in output")
+  })
+})
+
+describe("settings", () => {
+  test("enabled: false in the project's file: no tools, no guidance", async () => {
+    writeFileSync(join(project, ".cockpit.json"), '{ "trail": { "enabled": false } }')
+    expect(await start()).toEqual({})
+  })
+
+  test("features.trail: false in a file does the same", async () => {
+    writeFileSync(join(project, ".cockpit.json"), '{ "features": { "trail": false } }')
+    expect(await start()).toEqual({})
+  })
+})
+
+describe("a deleted conversation", () => {
+  test("keeps its records, marked", async () => {
+    const parts = await start()
+    await call(parts, "trail_add", { title: "Kept", url: "https://acme.dev/kept" })
+    await parts.sessionDeleted?.(ROOT)
+    expect(await call(parts, "trail_list", { all: true }, "ses_other")).toContain("(ses_root, deleted)")
+  })
+})
diff --git a/packages/trail/test/store.test.ts b/packages/trail/test/store.test.ts
new file mode 100644
index 00000000..3237a1a3
--- /dev/null
+++ b/packages/trail/test/store.test.ts
@@ -0,0 +1,163 @@
+import { describe, expect, test } from "bun:test"
+import {
+  applyAll,
+  type Event,
+  emptyState,
+  historyText,
+  parseLines,
+  type State,
+  serialize,
+  vanished,
+} from "../src/core/store.ts"
+
+let n = 0
+function recorded(fields: Partial>, at = ++n * 1000): Event {
+  return {
+    v: 1,
+    at,
+    id: `e${++n}`,
+    type: "recorded",
+    rootSession: "ses_1",
+    session: "ses_1",
+    sessionTitle: "Fix the desync",
+    by: "agent",
+    title: "Thing",
+    action: "created",
+    ...fields,
+  } as Event
+}
+
+const records = (state: State) => [...state.records.values()]
+const PR = "https://github.com/a/b/pull/1"
+
+describe("one thing, one record", () => {
+  test("the same url again is the same record, its actions a history", () => {
+    const state = applyAll(emptyState(), [
+      recorded({ url: PR, title: "Draft" }),
+      recorded({ url: `${PR}/files`, title: "Fix the desync", action: "updated" }),
+      recorded({ url: PR, title: "Fix the desync", action: "updated" }),
+    ])
+    expect(records(state)).toHaveLength(1)
+    const [record] = records(state)
+    expect(record?.title).toBe("Fix the desync")
+    expect(record?.history.map((step) => step.action)).toEqual(["created", "updated", "updated"])
+    expect(historyText(record as NonNullable)).toBe("created → updated")
+  })
+
+  test("with no url, the same ref is the same record, case-blind", () => {
+    const state = applyAll(emptyState(), [
+      recorded({ ref: "COM-1736", title: "Bundle desync" }),
+      recorded({ ref: "com-1736", url: "https://acme.atlassian.net/browse/COM-1736", action: "updated" }),
+    ])
+    expect(records(state)).toHaveLength(1)
+    expect(records(state)[0]?.url).toBe("https://acme.atlassian.net/browse/COM-1736")
+  })
+
+  test("what a later mention leaves out is kept", () => {
+    const state = applyAll(emptyState(), [
+      recorded({ url: PR, kind: "pull request", for: "COM-1", note: "first" }),
+      recorded({ url: PR, title: "Better", action: "merged" }),
+    ])
+    expect(records(state)[0]).toMatchObject({
+      title: "Better",
+      kind: "pull request",
+      for: "COM-1",
+      note: "first",
+    })
+  })
+
+  test("two conversations keep a record each", () => {
+    const state = applyAll(emptyState(), [
+      recorded({ url: PR }),
+      recorded({ url: PR, rootSession: "ses_2", session: "ses_2", sessionTitle: "Review" }),
+    ])
+    expect(records(state).map((record) => record.session)).toEqual(["ses_1", "ses_2"])
+    expect(state.conversations.get("ses_2")?.title).toBe("Review")
+  })
+
+  test("a subagent's record belongs to the conversation and remembers the subagent", () => {
+    const state = applyAll(emptyState(), [recorded({ url: PR, session: "ses_child", subagent: "explore" })])
+    expect(records(state)[0]).toMatchObject({ session: "ses_1", subagent: "explore" })
+  })
+
+  test("an event read twice is applied once", () => {
+    const event = recorded({ url: PR })
+    const state = applyAll(emptyState(), [event, event, recorded({ url: PR, action: "updated" })])
+    expect(records(state)[0]?.history).toHaveLength(2)
+  })
+
+  test("removed takes the record out; recording it again starts a new one", () => {
+    const first = recorded({ url: PR })
+    const state = applyAll(emptyState(), [
+      first,
+      { v: 1, at: 99_000, id: "rm", type: "removed", rootSession: "ses_1", record: first.id },
+    ])
+    expect(records(state)).toHaveLength(0)
+    applyAll(state, [recorded({ url: PR, action: "updated" })])
+    expect(records(state)[0]?.history.map((step) => step.action)).toEqual(["updated"])
+  })
+})
+
+describe("deleted conversations", () => {
+  test("keep their records, marked, and their title", () => {
+    const state = applyAll(emptyState(), [
+      recorded({ url: PR, rootSession: "ses_gone", session: "ses_gone", sessionTitle: "Old work" }),
+      { v: 1, at: 500_000, id: "del", type: "deleted", rootSession: "ses_gone" },
+    ])
+    expect(records(state)).toHaveLength(1)
+    expect(state.conversations.get("ses_gone")).toMatchObject({ title: "Old work", deletedAt: 500_000 })
+  })
+
+  test("a conversation with records the host no longer lists has vanished", () => {
+    const state = applyAll(emptyState(), [
+      recorded({ url: PR }),
+      recorded({ url: PR, rootSession: "ses_2", session: "ses_2" }),
+    ])
+    expect(vanished(state, new Set(["ses_1"])).map((each) => each.session)).toEqual(["ses_2"])
+    applyAll(state, [{ v: 1, at: 1, id: "d2", type: "deleted", rootSession: "ses_2" }])
+    expect(vanished(state, new Set(["ses_1"]))).toEqual([])
+  })
+})
+
+describe("the file", () => {
+  test("a line is one event, whatever a title holds", () => {
+    const event = recorded({ url: PR, title: 'Line\nbreak "quoted"' })
+    expect(serialize(event).split("\n")).toHaveLength(2)
+    expect(parseLines(serialize(event)).events).toEqual([event])
+  })
+
+  test("a line cut short by a crash is skipped, and the event appended onto it recovered", () => {
+    const a = recorded({ url: PR })
+    const b = recorded({ ref: "X-1" })
+    const torn = serialize(a).slice(0, 25) + serialize(b)
+    expect(parseLines(torn).events).toEqual([b])
+  })
+
+  test("what follows the last newline is left for the next read", () => {
+    const a = recorded({ url: PR })
+    const { events, rest } = parseLines(`${serialize(a)}{"v":1,"at"`)
+    expect(events).toEqual([a])
+    expect(rest).toBe('{"v":1,"at"')
+  })
+
+  test("an event that is not one of ours is ignored", () => {
+    const lines = [
+      JSON.stringify({ v: 2, at: 1, id: "x", rootSession: "s", type: "recorded" }),
+      JSON.stringify({
+        v: 1,
+        at: 1,
+        id: "x",
+        rootSession: "s",
+        type: "recorded",
+        session: "s",
+        title: "t",
+        action: "a",
+        by: "agent",
+      }),
+      JSON.stringify({ v: 1, at: 1, id: "x", rootSession: "s", type: "moved" }),
+      "not json",
+      "",
+    ].join("\n")
+    expect(parseLines(`${lines}\n`).events).toEqual([])
+  })
+})
diff --git a/packages/trail/test/text.test.ts b/packages/trail/test/text.test.ts
new file mode 100644
index 00000000..5ef4b345
--- /dev/null
+++ b/packages/trail/test/text.test.ts
@@ -0,0 +1,186 @@
+import { describe, expect, test } from "bun:test"
+import { arrange, conversationThings, foundIn, notRecorded, projectThings } from "../src/core/model.ts"
+import { SAMPLE_NOW, SAMPLES, type Sample } from "../src/core/sample.ts"
+import { emptyState } from "../src/core/store.ts"
+import {
+  ADD_DESCRIPTION,
+  GUIDANCE,
+  LIST_DESCRIPTION,
+  listText,
+  markdownOf,
+  PRODUCED_LIMIT,
+  producedLine,
+  seenLine,
+} from "../src/core/text.ts"
+import { runAdd, runList } from "../src/core/tools.ts"
+import { dialogRows } from "../src/core/view/dialog.ts"
+
+const sample = (name: string) => (SAMPLES[name] as () => Sample)()
+const who = { session: "s1", rootSession: "s1", by: "agent" as const, at: SAMPLE_NOW }
+
+describe("what the model is told", () => {
+  test("the tool says when to call it within what OpenCode 2's catalog shows (~115 characters)", () => {
+    const shown = ADD_DESCRIPTION.slice(0, 112)
+    expect(shown).toContain("right after you create or change something outside this repo")
+    expect(shown).toContain("PR")
+    expect(shown).toContain("ticket")
+    expect(LIST_DESCRIPTION.slice(0, 112)).toContain("trail_add")
+  })
+
+  test("the guidance names tools.trail_add and carries two examples", () => {
+    expect(GUIDANCE.startsWith("## Trail")).toBe(true)
+    expect(GUIDANCE).toContain("tools.trail_add")
+    expect(GUIDANCE.match(/tools\.trail_add\(\{ title: /g)).toHaveLength(2)
+    expect(GUIDANCE).toContain("Not for files in this repository")
+  })
+
+  test("the rule itself says to group with for, to list before answering, and who records a subagent's work", () => {
+    expect(GUIDANCE).toContain("put its key in `for` so the record groups under it")
+    expect(GUIDANCE).toContain("Before saying what this work produced, call trail_list")
+    expect(GUIDANCE).toContain("A subagent records what it makes itself")
+  })
+})
+
+describe("trail_add answers", () => {
+  test("a new record: what it is, what was done, how many now", () => {
+    const state = emptyState()
+    const out = runAdd(state, { title: "Fix desync", url: "https://github.com/a/b/pull/1" }, who)
+    expect(out.ok).toBe(true)
+    expect(out.text).toContain('Recorded PR #1 "Fix desync" (GitHub) — created.')
+    expect(out.text).toContain("holds 1 thing")
+  })
+
+  test("the same url again: the existing record, its history, and that no row was added", () => {
+    const state = emptyState()
+    runAdd(state, { title: "Draft", url: "https://github.com/a/b/pull/1" }, who)
+    const out = runAdd(
+      state,
+      { title: "Fix desync", url: "https://github.com/a/b/pull/1" },
+      { ...who, at: who.at + 1 },
+    )
+    expect(out.text).toContain(
+      'Updated the existing record of PR #1 "Fix desync" (GitHub): created → updated.',
+    )
+    expect(out.text).toContain("never makes a second row")
+    expect(out.text).toContain("holds 1 thing")
+  })
+
+  test("a stripped secret and a `for` not in the trail are both said", () => {
+    const out = runAdd(
+      emptyState(),
+      { title: "Report", url: "https://r.acme.dev/x?token=abc", for: "COM-1" },
+      who,
+    )
+    expect(out.text).toContain("looked like a secret: token")
+    expect(out.text).toContain('"COM-1" is not in this conversation\'s trail itself')
+    if (out.ok) expect(out.event).toMatchObject({ type: "recorded", url: "https://r.acme.dev/x" })
+  })
+
+  test("a refusal writes nothing and says how to recover", () => {
+    const state = emptyState()
+    const out = runAdd(state, { url: "https://github.com/a/b/pull/1" }, who)
+    expect(out.ok).toBe(false)
+    expect(out.text).toContain("Nothing was recorded")
+    expect(state.records.size).toBe(0)
+  })
+})
+
+describe("trail_list answers", () => {
+  test("empty: says so and what to do", () => {
+    expect(runList(emptyState(), {}, "s1", SAMPLE_NOW)).toContain("record it with trail_add")
+    expect(runList(emptyState(), { all: true }, "s1", SAMPLE_NOW)).toContain(
+      "Nothing is in the trail for this project",
+    )
+  })
+
+  test("the same facts, in the same order, as the dialog draws (one truth)", () => {
+    const { state, session } = sample("busy")
+    const text = listText({ state, session, all: false, query: "", now: SAMPLE_NOW })
+    const view = dialogRows({
+      width: 200,
+      height: 60,
+      tab: "this",
+      state,
+      session,
+      now: SAMPLE_NOW,
+      project: "p",
+    })
+    const dialogOrder = view.items.flatMap((item) => (item.kind === "thing" ? [item.thing.title] : []))
+    const listOrder = [...text.matchAll(/^\s*- (?:[^"\n]* )?"([^"]+)"/gm)].map((match) => match[1])
+    expect(listOrder).toEqual(dialogOrder)
+    expect(text).toContain("9 things in this conversation, grouped by what they are for, newest work first:")
+    expect(text).toContain("- COM-1801 (not recorded itself)")
+    expect(text).toContain("created → updated 1h ago")
+    expect(text).toContain("by subagent docs")
+    expect(text).toContain("added by you")
+    expect(text).toContain("note: behind the flag")
+  })
+
+  test("a query that finds nothing says what was searched and what to try", () => {
+    const { state, session } = sample("busy")
+    const text = runList(state, { query: "kubernetes" }, session, SAMPLE_NOW)
+    expect(text).toContain('Nothing in this conversation matches "kubernetes"')
+    expect(text).toContain("all: true")
+    expect(runList(state, { query: "jira" }, session, SAMPLE_NOW)).toMatch(
+      /^1 of 9 things in this conversation/,
+    )
+  })
+
+  test("all: every conversation that touched a thing, with its session id, a deleted one marked", () => {
+    const { state, session } = sample("project")
+    const text = runList(state, { all: true }, session, SAMPLE_NOW)
+    expect(text).toContain('in "Review the 0.8 branch" (ses_review) — reviewed 20m ago')
+    expect(text).toContain(
+      'in "Fix the bundle desync" (ses_main) — created → updated 1h ago — this conversation',
+    )
+    expect(text).toContain('in "Set up the latency dashboard" (ses_gone, deleted)')
+  })
+})
+
+describe("the lines on every request", () => {
+  test("produced: nothing when nothing is recorded; else the things, capped, pointing at trail_list", () => {
+    expect(producedLine(emptyState(), "s1")).toBeUndefined()
+    const { state, session } = sample("busy")
+    const line = producedLine(state, session) as string
+    expect(line.startsWith("Trail — this conversation produced: ")).toBe(true)
+    expect(line).toContain('PR #33 "0.8: Trust, one design system, and the sidebar or…" (created → updated)')
+    expect(line).toContain(`+ ${9 - PRODUCED_LIMIT} more (trail_list)`)
+  })
+
+  test("seen: worded as a choice, recorded links left out", () => {
+    const { state, session, seen } = sample("busy")
+    const line = seenLine(notRecorded(state, session, seen)) as string
+    expect(line).toBe(
+      "Seen in output — record it with tools.trail_add if you created or changed it: https://github.com/acme/web/pull/40, https://acme.atlassian.net/browse/COM-1800",
+    )
+    expect(line).not.toContain("pull/33")
+    expect(seenLine([])).toBeUndefined()
+    const many = foundIn(Array.from({ length: 8 }, (_, i) => `https://github.com/a/b/pull/${i}`).join(" "), 1)
+    expect(seenLine(many)).toEndWith("(+ 3 more)")
+  })
+})
+
+describe("copy as markdown", () => {
+  test("a nested list, links where there are links, titles escaped", () => {
+    const state = emptyState()
+    runAdd(state, { title: "Bundle desync", url: "https://acme.atlassian.net/browse/COM-1" }, who)
+    runAdd(
+      state,
+      { title: "Fix [the] desync", url: "https://github.com/a/b/pull/1", for: "COM-1" },
+      { ...who, at: who.at + 1 },
+    )
+    runAdd(state, { title: "Commit", ref: "abc123" }, { ...who, at: who.at + 2 })
+    expect(markdownOf(arrange(conversationThings(state, "s1")))).toBe(
+      [
+        "- [COM-1 — Bundle desync](https://acme.atlassian.net/browse/COM-1) · Jira · created",
+        "  - [PR #1 — Fix \\[the\\] desync](https://github.com/a/b/pull/1) · GitHub · created",
+        "- abc123 — Commit · created",
+      ].join("\n"),
+    )
+  })
+
+  test("a group headed by name is bold", () => {
+    const { state } = sample("busy")
+    expect(markdownOf(arrange(projectThings(state)))).toStartWith("- **COM-1801**\n  - a1b2c3d — Bump")
+  })
+})
diff --git a/packages/trail/test/view.test.ts b/packages/trail/test/view.test.ts
new file mode 100644
index 00000000..e3afa8b8
--- /dev/null
+++ b/packages/trail/test/view.test.ts
@@ -0,0 +1,300 @@
+import { describe, expect, test } from "bun:test"
+import { emptyBlock } from "@opencode-cockpit/client/design"
+import { arrange, conversationThings } from "../src/core/model.ts"
+import { SAMPLE_NOW, SAMPLES, type Sample } from "../src/core/sample.ts"
+import { emptyState } from "../src/core/store.ts"
+import { runAdd } from "../src/core/tools.ts"
+import { type DialogInput, dialogRows, MIN_HEIGHT, type Tab } from "../src/core/view/dialog.ts"
+import { type Row, refOf, rowText, since, widthOf } from "../src/core/view/rows.ts"
+import { EMPTY_TEXT, sidebarRows } from "../src/core/view/sidebar.ts"
+
+const sample = (name: string) => (SAMPLES[name] as () => Sample)()
+const texts = (rows: readonly Row[]) => rows.map((row) => rowText(row).trimEnd())
+const sidebarOf = (name: string, width: number, limit = 6) => {
+  const { state, session } = sample(name)
+  return sidebarRows({ width, arranged: arrange(conversationThings(state, session)), now: SAMPLE_NOW, limit })
+}
+const dialogOf = (name: string, extra: Partial = {}) => {
+  const { state, session } = sample(name)
+  return dialogRows({
+    width: 100,
+    height: 24,
+    tab: "this",
+    state,
+    session,
+    now: SAMPLE_NOW,
+    project: "opencode-cockpit",
+    ...extra,
+  })
+}
+
+describe("the grid: every row exactly its width", () => {
+  const names = Object.keys(SAMPLES)
+  for (const name of names)
+    test(name, () => {
+      for (const width of [20, 30, 36, 42, 60])
+        for (const row of sidebarOf(name, width).rows) expect(widthOf(rowText(row))).toBe(width)
+      for (const tab of ["this", "all"] as Tab[])
+        for (const [width, height] of [
+          [40, MIN_HEIGHT],
+          [60, 12],
+          [80, 20],
+          [116, 30],
+          [200, 44],
+        ] as const) {
+          const { rows } = dialogOf(name, { tab, width, height })
+          expect(rows).toHaveLength(height)
+          for (const row of rows) expect(widthOf(rowText(row))).toBe(width)
+        }
+    })
+})
+
+describe("the sidebar block", () => {
+  test("empty: the heading, its row of air, and `none yet` in the first record's slot", () => {
+    const rows = texts(sidebarOf("empty", 36).rows)
+    expect(rows).toEqual(["Trail", "", EMPTY_TEXT])
+    const one = texts(sidebarOf("one", 36).rows)
+    expect(one).toHaveLength(rows.length)
+    expect(one[0]).toMatch(/^Trail +1$/)
+  })
+
+  test("empty is the client's block: the same rows every bay draws", () => {
+    const ours = sidebarOf("empty", 30).rows
+    const theirs = emptyBlock("Trail", 30).map((runs) => runs.map((run) => run.text).join(""))
+    expect(ours.map(rowText)).toEqual(theirs)
+  })
+
+  test("a settings notice: `!`, wrapped to the column under the heading — even hidden when empty", () => {
+    const notice = 'settings: "trail.sidebarRows" should be a number; the default is used'
+    const { state, session } = sample("one")
+    const one = sidebarRows({
+      width: 30,
+      arranged: arrange(conversationThings(state, session)),
+      now: SAMPLE_NOW,
+      limit: 5,
+      notices: [notice],
+    })
+    const rows = texts(one.rows)
+    expect(rows[0]).toMatch(/^Trail +1$/)
+    expect(rows[2]).toMatch(/^! settings:/)
+    expect(rows.slice(2, 5).join(" ")).toContain("the default")
+    for (const row of one.rows) expect(widthOf(rowText(row))).toBe(30)
+    expect(one.rows[2]?.[0]).toMatchObject({ text: "! ", tone: "warning" })
+    /** The click on the record still lands on the record, below the notice. */
+    const hit = one.hits[0]
+    expect(hit && rows[hit.y]).toContain("PR #33")
+
+    const hidden = texts(
+      sidebarRows({
+        width: 30,
+        arranged: arrange([]),
+        now: SAMPLE_NOW,
+        limit: 5,
+        hideWhenEmpty: true,
+        notices: [notice],
+      }).rows,
+    )
+    expect(hidden[0]).toBe("Trail")
+    expect(hidden[2]).toMatch(/^! settings:/)
+    expect(hidden).not.toContain(EMPTY_TEXT)
+  })
+
+  test("hideWhenEmpty draws nothing, and only while empty", () => {
+    const empty = sample("empty")
+    expect(
+      sidebarRows({ width: 36, arranged: arrange([]), now: SAMPLE_NOW, limit: 5, hideWhenEmpty: true }).rows,
+    ).toEqual([])
+    const one = sample("one")
+    expect(
+      sidebarRows({
+        width: 36,
+        arranged: arrange(conversationThings(one.state, one.session)),
+        now: SAMPLE_NOW,
+        limit: 5,
+        hideWhenEmpty: true,
+      }).rows,
+    ).toHaveLength(3)
+    expect(empty.state.records.size).toBe(0)
+  })
+
+  test("a row: ref, title, system, last action and age; ↗ when it opens", () => {
+    const rows = texts(sidebarOf("busy", 60).rows)
+    expect(rows[0]).toMatch(/^Trail +9$/)
+    expect(rows).toContain("COM-1801")
+    expect(rows.find((row) => row.startsWith("  PR #33"))).toMatch(
+      /^ {2}PR #33 {3}0\.8: Trust, one desi… +GitHub {2}updated {3}1h ago ↗$/,
+    )
+    expect(rows.find((row) => row.includes("a1b2c3d"))).toMatch(/created {2}12m ago$/)
+  })
+
+  test("+ N more · /trail counts the records not shown, and is a hit", () => {
+    const view = sidebarOf("busy", 42, 4)
+    expect(texts(view.rows).at(-1)).toBe("+ 6 more · /trail")
+    expect(view.hits.at(-1)).toEqual({ y: view.rows.length - 1, kind: "more" })
+  })
+
+  test("a click opens the page, or selects a record that has none", () => {
+    const view = sidebarOf("busy", 42)
+    const commit = view.hits.find((hit) => hit.kind === "select")
+    expect(commit && texts(view.rows)[commit.y]).toContain("a1b2c3d")
+    const pr = view.hits.find((hit) => hit.kind === "open" && hit.url.endsWith("/pull/33"))
+    expect(pr && texts(view.rows)[pr.y]).toContain("PR #33")
+  })
+
+  test("narrow: columns give way before the title does, and a cut says …", () => {
+    const narrow = texts(sidebarOf("busy", 30).rows)
+    expect(narrow.join("\n")).not.toContain("GitHub")
+    expect(narrow.join("\n")).not.toContain("created")
+    expect(narrow.find((row) => row.includes("PR #33"))).toContain("…")
+    const wide = texts(sidebarOf("busy", 60).rows)
+    expect(wide.join("\n")).toContain("GitHub")
+  })
+
+  /**
+   * The column sits under Shells' and Subagents' durations (`1m04s`), so a bare `2h` read as two
+   * hours of work. It says when: `now`, `12m ago`; only a narrow sidebar short of room drops "ago".
+   */
+  test("the time column says when, not for how long: `now`, `12m ago`, `2h ago`", () => {
+    expect(since(10_000)).toBe("now")
+    expect(since(12 * 60_000)).toBe("12m ago")
+    expect(since(2 * 3_600_000)).toBe("2h ago")
+    expect(since(2 * 3_600_000, true)).toBe("2h")
+    for (const width of [36, 42, 60]) {
+      const rows = texts(sidebarOf("busy", width, 12).rows).filter((row) => /(↗|\d[mhd]|now)\s*$/.test(row))
+      expect(rows.length).toBeGreaterThan(0)
+      for (const row of rows) expect(row).toMatch(/(now|\d+[mhd] ago)( ↗)?$/)
+    }
+    const narrow = texts(sidebarOf("busy", 24, 12).rows)
+    expect(narrow.join("\n")).not.toContain("ago")
+    expect(narrow.find((row) => row.includes("a1b"))).toMatch(/ 12m$/)
+  })
+
+  /** `Confluenc…  Release notes for…`: a kind, cut, in the ref's column, and the title cut for it. */
+  test("a record with no ref gives its title the ref's column; a kind is not a ref", () => {
+    for (const width of [24, 30, 36, 50]) {
+      const rows = texts(sidebarOf("busy", width, 12).rows)
+      const notes = rows.find((row) => row.includes("Release"))
+      expect(notes).toStartWith("Release notes")
+      expect(rows.join("\n")).not.toMatch(/Confl[a-z ]*…|artif/)
+      /** A ref still has its column. */
+      expect(rows.find((row) => row.includes("Bundle"))).toStartWith("COM-1")
+    }
+    expect(refOf({ label: "Confluence page", kind: "Confluence page" })).toBeUndefined()
+    expect(refOf({ label: "PR #33", kind: "pr" })).toBe("PR #33")
+    expect(refOf({ label: "deploy 2026-10-03.4", ref: "deploy 2026-10-03.4", kind: "deploy" })).toBe(
+      "deploy 2026-10-03.4",
+    )
+  })
+})
+
+describe("/trail", () => {
+  test("the header names the tabs, the current one marked", () => {
+    const view = dialogOf("busy")
+    expect(texts(view.rows)[0]).toMatch(
+      /^ Trail · opencode-cockpit +\[tab\] {2}This conversation {3}All conversations$/,
+    )
+    const active = view.rows[0]?.find((run) => run.text === " This conversation ")
+    expect(active).toMatchObject({ fill: "chip", bold: true })
+    expect(texts(dialogOf("busy", { width: 60 }).rows)[0]).toMatch(/\[tab\] {2}This {3}All$/)
+  })
+
+  test("the footer: the keys in the agreed order, esc last", () => {
+    const footer = texts(dialogOf("busy", { width: 116 }).rows).at(-1)
+    expect(footer).toBe(
+      " [enter] Open   [g] Go   [c] Copy   [x] Remove   [/] Search   [m] Markdown   [esc] Close",
+    )
+    expect(texts(dialogOf("busy", { width: 40 }).rows).at(-1)).toMatch(/… {3}\[esc\] Close$/)
+  })
+
+  test("keys follow the cursor: a record without a page cannot open", () => {
+    const commit = dialogOf("busy")
+    expect(commit.item?.kind).toBe("thing")
+    expect(commit.target).toEqual({ copy: "a1b2c3d", remove: "ev_9" })
+  })
+
+  test("with no ref, the title starts in the ref's column, and the kind is not said beside the chip", () => {
+    const rows = texts(dialogOf("busy").rows)
+    expect(rows.find((row) => row.includes("Release notes"))).toMatch(
+      /^ Release notes for 0\.8 +Confluence +created +40m ago ↗$/,
+    )
+    expect(rows.find((row) => row.includes("Rollout"))).toMatch(/^ Rollout checklist · docs /)
+    expect(rows.find((row) => row.includes("Bundle desync"))).toMatch(/^ COM-1736 +Bundle desync /)
+  })
+
+  test("only what was recorded: links seen in output are put to the agent, never listed here", () => {
+    const view = dialogOf("busy", { height: 60 })
+    expect(view.items.every((item) => item.kind === "thing")).toBe(true)
+    expect(texts(view.rows).join("\n")).not.toContain("acme/web/pull/40")
+  })
+
+  test("All conversations: each conversation under what it touched; g goes there; a deleted one is marked", () => {
+    const view = dialogOf("project", { tab: "all", height: 60, width: 116 })
+    const rows = texts(view.rows)
+    expect(rows.some((row) => /↳ “Review the 0\.8 branch� .* reviewed +20m ago$/.test(row))).toBe(true)
+    expect(rows.some((row) => row.includes("“Set up the latency dashboard� · deleted"))).toBe(true)
+    const touch = view.items.find((item) => item.kind === "touch" && item.touch.session === "ses_review")
+    expect(dialogOf("project", { tab: "all", selected: touch?.key }).target).toMatchObject({
+      go: "ses_review",
+      open: "https://github.com/Codestz/opencode-cockpit/pull/33",
+    })
+    /** A thing only this conversation touched has no conversation rows: there is nowhere to go. */
+    expect(rows.some((row) => row.includes("Status page incident"))).toBe(true)
+    expect(
+      view.items.filter((item) => item.kind === "touch" && item.thing.title === "Status page incident"),
+    ).toEqual([])
+  })
+
+  test("a short window keeps the cursor in view and says what is above and below", () => {
+    const all = dialogOf("busy", { height: 60 }).items
+    const last = all.at(-1)
+    const view = dialogOf("busy", { height: 10, selected: last?.key })
+    const rows = texts(view.rows)
+    expect(rows.find((row) => row.startsWith("▌"))).toContain("Release notes for 0.8")
+    expect(rows.some((row) => /^ ↑ \d+ more$/.test(row))).toBe(true)
+    const top = texts(dialogOf("busy", { height: 10 }).rows)
+    expect(top.some((row) => /^ ↓ \d+ more$/.test(row))).toBe(true)
+  })
+
+  test("search: the query, how many it kept, and Cancel as the way out while typing", () => {
+    const view = dialogOf("busy", { query: "github", searching: true })
+    const rows = texts(view.rows)
+    expect(rows[1]).toMatch(/^ \[\/\] githubâ–� +2 of 9$/)
+    expect(rows.at(-1)).toBe(" [enter] Done   [esc] Cancel")
+    expect(texts(dialogOf("busy", { query: "nothing-like-this" }).rows)).toContain(
+      " Nothing matches “nothing-like-this�.",
+    )
+  })
+
+  test("empty: says so, and how things get here", () => {
+    const rows = texts(dialogOf("empty").rows)
+    expect(rows[2]).toBe(" Nothing recorded in this conversation yet.")
+    expect(rows.join(" ")).toContain("with trail_add")
+    expect(dialogOf("empty").item).toBeUndefined()
+  })
+
+  test("a subagent's record and one you added say who", () => {
+    const rows = texts(dialogOf("busy", { height: 40 }).rows)
+    expect(rows.some((row) => row.includes("Rollout checklist · docs"))).toBe(true)
+    expect(rows.some((row) => row.includes("Status page incident · you"))).toBe(true)
+  })
+
+  test("x removes one conversation's record; in All a thing several touched is removed one at a time", () => {
+    const state = emptyState()
+    const add = (session: string) =>
+      runAdd(
+        state,
+        { title: "PR", url: "https://github.com/a/b/pull/1" },
+        {
+          session,
+          rootSession: session,
+          by: "agent",
+          at: SAMPLE_NOW,
+        },
+      )
+    add("s1")
+    add("s2")
+    const input = { width: 100, height: 20, state, session: "s1", now: SAMPLE_NOW, project: "p" }
+    expect(dialogRows({ ...input, tab: "this" }).target.remove).toBeDefined()
+    expect(dialogRows({ ...input, tab: "all" }).target.remove).toBeUndefined()
+  })
+})
diff --git a/packages/trail/tsconfig.json b/packages/trail/tsconfig.json
new file mode 100644
index 00000000..2ab5eb48
--- /dev/null
+++ b/packages/trail/tsconfig.json
@@ -0,0 +1,16 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "types",
+    "jsx": "preserve",
+    "jsxImportSource": "@opentui/solid",
+    "lib": ["ESNext", "DOM"]
+  },
+  "include": ["src"],
+  "references": [
+    {
+      "path": "../client"
+    }
+  ]
+}
diff --git a/packages/trail/tui.js b/packages/trail/tui.js
new file mode 100644
index 00000000..43232b00
--- /dev/null
+++ b/packages/trail/tui.js
@@ -0,0 +1,6 @@
+/**
+ * OpenCode 2 finds a plugin configured by *path* by the files at its root — `/tui`,
+ * `/server` — rather than through `exports` (docs/opencode/v2.md). A package installed by
+ * name resolves through `exports` as before; this file is only the door for the path case.
+ */
+export { default } from "./dist/tui/index.js"
diff --git a/packages/trust/README.md b/packages/trust/README.md
index 2b9f069e..17657b59 100644
--- a/packages/trust/README.md
+++ b/packages/trust/README.md
@@ -207,9 +207,13 @@ In the bundle's entry (`"trust": { … }`), this package's own, or the `trust` s
 | `enabled` | `true` | `false` turns Trust off (the bundle also has `features.trust: false`) |
 | `sidebar` | `false` | Show the block in the sidebar. The palette's "Show or hide Trust in the sidebar" flips it for the session |
 | `sidebarRows` | `3` | Answers listed in the sidebar |
-| `sidebarOrder` | `160` | Where the block sits in the sidebar; lower draws first |
 | `keybinds` | `{ "cockpit.trust.ledger": "p" }` | The key that opens the ledger |
 
+Where the block sits is the top-level `"sidebar"` list's to say — Trust last by default; a
+`trust.sidebarOrder` from before 0.9 is no longer read, and is a `!` row in the block until
+`/cockpit-setup` removes it. A setting Trust cannot use (`"threshold": "3"`) is a `!` row too. See
+[Configuration](https://codestz.github.io/opencode-cockpit/configuration/).
+
 The ledger lives outside the project, in
 `~/.local/share/opencode-cockpit/trust/-/events.ndjson` (`$COCKPIT_HOME` or
 `$XDG_DATA_HOME` move it): one line per event, appended, shared by every OpenCode window on the
diff --git a/packages/trust/package.json b/packages/trust/package.json
index 58514749..0ca045fa 100644
--- a/packages/trust/package.json
+++ b/packages/trust/package.json
@@ -48,7 +48,7 @@
   },
   "dependencies": {
     "@opencode-cockpit/client": "workspace:*",
-    "@opencode-ai/plugin": "1.18.31"
+    "@opencode-ai/plugin": "1.18.33"
   },
   "devDependencies": {
     "@opentui/core": "0.4.5",
diff --git a/packages/trust/src/core/config.ts b/packages/trust/src/core/config.ts
index bc1de8b7..3eed445e 100644
--- a/packages/trust/src/core/config.ts
+++ b/packages/trust/src/core/config.ts
@@ -1,19 +1,17 @@
 /**
- * Trust's settings, through the same merge every bay uses (docs/building/a-new-bay.md):
+ * Trust's settings, through the loader every bay shares (`@opencode-cockpit/client/settings`):
  *
  *   ~/.config/opencode-cockpit/config.json  →  /.cockpit.json  →  plugin-entry options
  *
  * Only the `trust` section of a file is read. A file without one says nothing about Trust — reading
  * the whole file as Trust's settings is the trap that page warns about. An unreadable or invalid file
- * is ignored rather than fatal: a typo in a config should never cost you the interface.
+ * is ignored rather than fatal, with a notice: a typo in a config should never cost you the interface.
  *
  * This is Cockpit's config, not OpenCode's. What OpenCode allows, denies and asks about is read from
  * OpenCode itself (`rules.ts`), and never written by Trust.
  */
 
-import { readFile } from "node:fs/promises"
-import { homedir } from "node:os"
-import { join } from "node:path"
+import { baySettings, type SettingsNotice } from "@opencode-cockpit/client/settings"
 
 export interface TrustConfig {
   /** Off switch for this bay, wherever it is written. The bundle also has `features.trust: false`. */
@@ -28,12 +26,11 @@ export interface TrustConfig {
    * Whether Trust draws a block in the sidebar. Default false: the sidebar already carries the
    * statusline, subagents and shells, and Trust works the same without it — `/trust` opens the
    * ledger, and the palette's "Show or hide Trust in the sidebar" brings the block back for the session.
+   * Where it sits is the top-level `sidebar` list's to say.
    */
   sidebar?: boolean
   /** Answers by Trust listed in the sidebar. Default 3. */
   sidebarRows?: number
-  /** Where the block sits among sidebar blocks; lower draws first. */
-  sidebarOrder?: number
   keybinds?: Record
 }
 
@@ -55,9 +52,6 @@ export const DEFAULTS: TrustSettings = {
   sidebarRows: 3,
 }
 
-export const CONFIG_FILE = "config.json"
-export const PROJECT_FILE = ".cockpit.json"
-
 const KEYS = [
   "enabled",
   "threshold",
@@ -65,15 +59,9 @@ const KEYS = [
   "expireDays",
   "sidebar",
   "sidebarRows",
-  "sidebarOrder",
   "keybinds",
 ] as const
 
-export function globalConfigPath(env: Record = process.env): string {
-  const base = env.XDG_CONFIG_HOME ?? join(env.HOME ?? homedir(), ".config")
-  return join(base, "opencode-cockpit", CONFIG_FILE)
-}
-
 /** The `trust` section of a config file. */
 export function trustSection(raw: unknown): TrustConfig {
   if (!raw || typeof raw !== "object") return {}
@@ -98,31 +86,34 @@ function pick(raw: Record): TrustConfig {
   return own
 }
 
-export function mergeTrust(base: TrustConfig, over: TrustConfig): TrustConfig {
-  const merged: TrustConfig = { ...base, ...over }
-  if (base.keybinds || over.keybinds) merged.keybinds = { ...base.keybinds, ...over.keybinds }
-  return merged
+export interface LoadedTrust {
+  /** What was written, every source merged; `resolveSettings` fills the gaps. */
+  config: TrustConfig
+  /** The block's place, from the top-level `sidebar` list. */
+  order: number
+  /** Settings to fix, for a `!` row. */
+  notices: SettingsNotice[]
 }
 
-async function readSection(path: string): Promise {
-  try {
-    return trustSection(JSON.parse(await readFile(path, "utf8")))
-  } catch {
-    return {}
-  }
+/** Reads and merges every source. Never throws. */
+export async function loadTrust(
+  directory: string,
+  options?: unknown,
+  env: Record = process.env,
+): Promise {
+  const loaded = baySettings("trust", DEFAULTS, { options, where: { directory, env } })
+  /** `features.trust: false` in a file turns Trust off as `enabled: false` does. */
+  const off = loaded.config.enabled ? {} : { enabled: false }
+  return { config: { ...pick(loaded.written), ...off }, order: loaded.order, notices: loaded.notices }
 }
 
-/** Reads and merges every source, without blocking the interface thread. */
+/** Every source merged, as written. */
 export async function loadTrustConfig(
   directory: string,
   options?: unknown,
   env: Record = process.env,
 ): Promise {
-  const [global, project] = await Promise.all([
-    readSection(globalConfigPath(env)),
-    readSection(join(directory, PROJECT_FILE)),
-  ])
-  return mergeTrust(mergeTrust(global, project), asTrustConfig(options))
+  return (await loadTrust(directory, options, env)).config
 }
 
 const whole = (value: unknown, fallback: number, least: number): number =>
diff --git a/packages/trust/src/tui/index.tsx b/packages/trust/src/tui/index.tsx
index 17664fde..af358acc 100644
--- a/packages/trust/src/tui/index.tsx
+++ b/packages/trust/src/tui/index.tsx
@@ -9,13 +9,15 @@
  * ledger file read and appended, and the two surfaces — the sidebar block and the ledger dialog.
  */
 
+import { defaultKeys } from "@opencode-cockpit/client/catalog"
+import { warnRows } from "@opencode-cockpit/client/design"
 import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client/feature"
 import { bindingLookup, dualTui, type Host, type Layer } from "@opencode-cockpit/client/host"
-import { sidebarOrder } from "@opencode-cockpit/client/sidebar"
+import { noticeText } from "@opencode-cockpit/client/settings"
 import type { BoxRenderable } from "@opentui/core"
 import { createSignal } from "solid-js"
 import { commandOf, type Seen } from "../core/adapt/seen.ts"
-import { loadTrustConfig, resolveSettings, type TrustConfig } from "../core/config.ts"
+import { loadTrust, resolveSettings, type TrustConfig } from "../core/config.ts"
 import { createEngine } from "../core/engine.ts"
 import type { Request } from "../core/keys.ts"
 import type { Event } from "../core/ledger.ts"
@@ -42,10 +44,7 @@ import { Rows } from "./view/rows.tsx"
 
 const TRUST_PACKAGE = "@opencode-cockpit/trust"
 
-/** `p`, for permissions: free on both OpenCodes (1.18.32's and 2.0.18's defaults) and in Cockpit. */
-const DEFAULT_KEYS = {
-  "cockpit.trust.ledger": "p",
-}
+const DEFAULT_KEYS = defaultKeys("trust")
 
 export type TrustTuiOptions = TrustConfig
 
@@ -75,7 +74,8 @@ export function createTrustTui({ source = TRUST_PACKAGE }: { source?: string } =
     api.lifecycle.onDispose(() => claim.release())
 
     const directory = api.state.path.directory
-    const config = await loadTrustConfig(directory, rawOptions)
+    const { config, order, notices } = await loadTrust(directory, rawOptions)
+    for (const notice of notices) log.warn("settings", { file: notice.file, notice: notice.text })
     const settings = resolveSettings(config)
     if (!settings.enabled) {
       log.info("off by config", { directory })
@@ -143,8 +143,14 @@ export function createTrustTui({ source = TRUST_PACKAGE }: { source?: string } =
     let inSidebar = settings.sidebar
     const paint = () => {
       drawnAt = sidebarWidth()
+      /**
+       * A setting in Trust's section that is not read — an old name, a value of the wrong kind — is
+       * a `!` row on top, for the session, until the file is fixed. Shown with the block hidden too:
+       * like trouble, a setting that silently does nothing is what nobody would find otherwise.
+       */
+      const warned: Row[] = notices.flatMap((notice) => warnRows(noticeText(notice), drawnAt))
       /** Hidden, the block says nothing — except trouble: a failure always speaks. */
-      const next =
+      const block =
         !inSidebar && !trouble
           ? []
           : sidebarRows({
@@ -158,6 +164,7 @@ export function createTrustTui({ source = TRUST_PACKAGE }: { source?: string } =
               ...(inSidebar ? { project: tally(engine.state, settings, Date.now()) } : {}),
               ...(trouble ? { trouble } : {}),
             })
+      const next = [...warned, ...block]
       /** Only when they changed: new rows rebuild every line of the block. */
       const text = JSON.stringify(next)
       if (text !== said) {
@@ -819,8 +826,8 @@ export function createTrustTui({ source = TRUST_PACKAGE }: { source?: string } =
     })
 
     api.slots.register({
-      /** Between Subagents (150) and the shells (170) by default; lower draws first. */
-      order: sidebarOrder("trust", 160, config.sidebarOrder, { directory }),
+      /** Last of Cockpit's blocks by default; the top-level `sidebar` list moves it. */
+      order,
       slots: {
         sidebar_content: () => (
            {
           return (error as NodeJS.ErrnoException).code === "EPERM"
         }
       },
+      modified(path) {
+        try {
+          return statSync(path).mtimeMs
+        } catch {
+          return undefined
+        }
+      },
       writable(dir) {
         try {
           mkdirSync(dir, { recursive: true })
diff --git a/packages/updater/src/cli/run.ts b/packages/updater/src/cli/run.ts
index fa55547f..2a39ec74 100644
--- a/packages/updater/src/cli/run.ts
+++ b/packages/updater/src/cli/run.ts
@@ -8,6 +8,7 @@
 
 import { type ApplyIo, applyPlan, manualSteps, readiness } from "../core/apply.ts"
 import { type GatherIo, gather } from "../core/gather.ts"
+import { RESTART_COMMAND } from "../core/service.ts"
 import type { Outcome } from "../core/verify.ts"
 import { listRows, noteRows, resultRows, reviewRows } from "../core/view/layout.ts"
 import { fit, type Row } from "../core/view/rows.ts"
@@ -58,6 +59,22 @@ published — and updates the ones that are behind, checking every file afterwar
   --yes, -y       do not ask
 `
 
+/**
+ * OpenCode 2 keeps the agent side its background service started with until the service restarts,
+ * whatever is installed under it — so a finished update there needs one more step. Unknown is no.
+ */
+async function onOpencode2(io: Pick, cwd: string): Promise {
+  try {
+    const result = await io.opencode(["--version"], cwd)
+    return result.status === 0 && Number(/(\d+)\.\d+\.\d+/.exec(result.output)?.[1]) >= 2
+  } catch {
+    return false
+  }
+}
+
+const SERVICE_NOTE =
+  "On OpenCode 2, then restart its background service, which keeps the old plugin code until it does:"
+
 export async function update(argv: readonly string[], io: Io): Promise {
   const args = parseArgs(argv)
   const say = (rows: Row[]) => io.write(paint(rows, io.color))
@@ -133,6 +150,11 @@ export async function update(argv: readonly string[], io: Io): Promise {
       "warning",
     )
     for (const step of manualSteps(chosen)) line(`  ${step}`)
+    if (await onOpencode2(io, io.cwd)) {
+      line(SERVICE_NOTE, "warning")
+      // Uncut, like the fixes: a clipped command cannot be pasted.
+      io.write(`  ${RESTART_COMMAND}\n`)
+    }
     return 1
   }
 
@@ -164,6 +186,11 @@ export async function update(argv: readonly string[], io: Io): Promise {
   const done = chosen.filter((plan) => outcomes.find((o) => o.name === plan.name)?.ok)
   if (done.length > 0) {
     line(`Restart OpenCode to load ${done.map((p) => `${p.name} ${p.published}`).join(", ")}.`)
+    if (await onOpencode2(io, io.cwd)) {
+      line(SERVICE_NOTE, "warning")
+      // Uncut, like the fixes: a clipped command cannot be pasted.
+      io.write(`  ${RESTART_COMMAND}\n`)
+    }
   }
   return outcomes.every((o) => o.ok) ? 0 : 1
 }
diff --git a/packages/updater/src/core/configs.ts b/packages/updater/src/core/configs.ts
index d6dff26a..33b70e64 100644
--- a/packages/updater/src/core/configs.ts
+++ b/packages/updater/src/core/configs.ts
@@ -9,8 +9,8 @@
  */
 
 import { join } from "node:path"
+import { parseJsonc } from "@opencode-cockpit/client/jsonc"
 import type { Disk } from "./disk.ts"
-import { parseJsonc } from "./jsonc.ts"
 import { type PluginEntry, parseSpec, type Spec, specOf } from "./spec.ts"
 
 export type Scope = "global" | "project"
diff --git a/packages/updater/src/core/index.ts b/packages/updater/src/core/index.ts
index 8a9faeb5..b8306f46 100644
--- a/packages/updater/src/core/index.ts
+++ b/packages/updater/src/core/index.ts
@@ -1,9 +1,9 @@
+export { type JsoncResult, parseJsonc } from "@opencode-cockpit/client/jsonc"
 export * from "./apply.ts"
 export * from "./cache.ts"
 export * from "./configs.ts"
 export * from "./disk.ts"
 export * from "./gather.ts"
-export * from "./jsonc.ts"
 export * from "./plan.ts"
 export * from "./registry.ts"
 export * from "./settings.ts"
diff --git a/packages/updater/src/core/service.ts b/packages/updater/src/core/service.ts
new file mode 100644
index 00000000..462d069a
--- /dev/null
+++ b/packages/updater/src/core/service.ts
@@ -0,0 +1,98 @@
+/**
+ * OpenCode 2's background service, and why updating a plugin is not enough on its own.
+ *
+ * OpenCode 2 runs the agent side in a long-lived server (`opencode serve --service`) that every window
+ * attaches to. It loads plugins once, when it starts — so after an update the windows draw the new
+ * interface while the service keeps the old agent side: measured 2026-10-03, a service started on Sep
+ * 27 kept the pre-0.9 tools and skills (no `trail_add`, no `cockpit-setup`) through every reinstall,
+ * until `opencode service restart`.
+ *
+ * What 2.0.18 says, measured in an isolated config: `opencode service status` prints the server's URL
+ * when it runs and `stopped` when it does not, exit 0 either way; `restart` prints the URL. The
+ * running service's pid is in `$XDG_STATE_HOME/opencode/service.json` (beside a password, never read
+ * here), and `ps -o etime=` gives how long it has run.
+ *
+ * Node APIs only, through the caller's runner: doctor runs under `npx`, dev-install under Bun.
+ */
+
+import { join } from "node:path"
+
+export const RESTART_COMMAND = "opencode service restart"
+
+/** Runs a program; undefined when it could not start. */
+export type Run = (command: string, args: readonly string[]) => { status: number; stdout: string } | undefined
+
+export type ServiceState = { state: "running"; url: string } | { state: "stopped" } | { state: "unknown" }
+
+export function parseServiceStatus(stdout: string): ServiceState {
+  const text = stdout.trim()
+  const url = /https?:\/\/\S+/.exec(text)?.[0]
+  if (url) return { state: "running", url }
+  if (/^stopped\b/im.test(text)) return { state: "stopped" }
+  return { state: "unknown" }
+}
+
+export function serviceStatus(run: Run, bin = "opencode"): ServiceState {
+  const result = run(bin, ["service", "status"])
+  return result && result.status === 0 ? parseServiceStatus(result.stdout) : { state: "unknown" }
+}
+
+/** `ps`'s elapsed time, `[[dd-]hh:]mm:ss`, in seconds. */
+export function parseElapsed(text: string): number | undefined {
+  const match = /^\s*(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)\s*$/.exec(text)
+  if (!match) return undefined
+  const [, days = "0", hours = "0", minutes = "0", seconds = "0"] = match
+  return ((Number(days) * 24 + Number(hours)) * 60 + Number(minutes)) * 60 + Number(seconds)
+}
+
+export function stateFile(env: Readonly>, home: string): string {
+  return join(env.XDG_STATE_HOME || join(home, ".local", "state"), "opencode", "service.json")
+}
+
+/** The pid the service wrote down, from its state file's text. */
+export function servicePid(text: string | undefined): number | undefined {
+  if (!text) return undefined
+  try {
+    const pid = (JSON.parse(text) as { pid?: unknown }).pid
+    return typeof pid === "number" && Number.isInteger(pid) && pid > 0 ? pid : undefined
+  } catch {
+    return undefined
+  }
+}
+
+/** When the running service started, in ms since the epoch: its pid's elapsed time, back from `now`. */
+export function serviceStartedAt(run: Run, pid: number | undefined, now: number): number | undefined {
+  if (pid === undefined) return undefined
+  const result = run("ps", ["-o", "etime=", "-p", String(pid)])
+  const seconds = result?.status === 0 ? parseElapsed(result.stdout) : undefined
+  return seconds === undefined ? undefined : now - seconds * 1000
+}
+
+/** The major version a binary reports, if it runs. */
+export function majorOf(run: Run, bin: string): number | undefined {
+  const result = run(bin, ["--version"])
+  const version = result?.status === 0 ? /(\d+)\.\d+\.\d+/.exec(result.stdout)?.[1] : undefined
+  return version ? Number(version) : undefined
+}
+
+/**
+ * After an install: restart OpenCode 2's service if it is running, so it loads what was just
+ * installed. Lines to print, saying what happened — or the exact command when it could not be done.
+ */
+export function restartService(run: Run, bin: string): string[] {
+  const status = serviceStatus(run, bin)
+  if (status.state === "stopped")
+    return ["OpenCode 2's background service is not running: the next window starts it with this install."]
+  if (status.state === "unknown")
+    return [
+      "Could not tell whether OpenCode 2's background service is running. If it is, it still has the old",
+      `plugin code until it restarts: ${RESTART_COMMAND}`,
+    ]
+  const restarted = run(bin, ["service", "restart"])
+  if (restarted?.status === 0)
+    return [`Restarted OpenCode 2's background service (${status.url}): it now runs this install.`]
+  return [
+    "OpenCode 2's background service is running the old plugin code, and restarting it failed. Run:",
+    `  ${RESTART_COMMAND}`,
+  ]
+}
diff --git a/packages/updater/src/core/settings.ts b/packages/updater/src/core/settings.ts
index 91119d95..c967ff67 100644
--- a/packages/updater/src/core/settings.ts
+++ b/packages/updater/src/core/settings.ts
@@ -1,39 +1,43 @@
 /**
- * The Updater's one setting: whether to check once a day and say so.
+ * The Updater's one setting: whether to check once a day and say so — `updater.updateCheck`.
  *
- * Read through the same files as every bay — `~/.config/opencode-cockpit/config.json`, then the
- * project's `.cockpit.json`, then plugin-entry options, later wins. `ui.updateCheck` is honoured
- * too: it is the switch Shell's own notice had, and someone who turned that off meant it.
+ * Read through the loader every bay reads with (`@opencode-cockpit/client/settings`):
+ * `~/.config/opencode-cockpit/config.json`, then the project's `.cockpit.json`, then plugin-entry
+ * options, later wins. `ui.updateCheck`, the switch Shell's own notice had, is an old name: the
+ * loader recognises it and doctor names the fix, but its value is not read.
  */
 
-import { join } from "node:path"
+import { baySettings, type SettingsNotice } from "@opencode-cockpit/client/settings"
 import type { Disk } from "./disk.ts"
-import { parseJsonc } from "./jsonc.ts"
 
-type Section = { updateCheck?: unknown } | undefined
-
-function fromFile(disk: Disk, path: string): boolean | undefined {
-  const text = disk.read(path)
-  if (text === undefined) return undefined
-  const parsed = parseJsonc(text)
-  if (!parsed.ok) return undefined // a broken config must not take the notice down with it
-  const file = parsed.value as { updater?: Section; ui?: Section } | null
-  const value = file?.updater?.updateCheck ?? file?.ui?.updateCheck
-  return typeof value === "boolean" ? value : undefined
+export interface UpdateCheckWhere {
+  env: Readonly>
+  home: string
+  directory?: string
 }
 
-export function updateCheckEnabled(
+/** The setting, and what about it is worth fixing. A broken file is a notice, never the end of the check. */
+export function updateCheck(
   disk: Disk,
-  where: { env: Readonly>; home: string; directory?: string },
+  where: UpdateCheckWhere,
   options: unknown,
-): boolean {
-  const base = where.env.XDG_CONFIG_HOME || join(where.home, ".config")
-  const layers = [
-    fromFile(disk, join(base, "opencode-cockpit", "config.json")),
-    where.directory ? fromFile(disk, join(where.directory, ".cockpit.json")) : undefined,
-    (options as Section)?.updateCheck,
-  ]
-  let enabled = true
-  for (const layer of layers) if (typeof layer === "boolean") enabled = layer
-  return enabled
+): { enabled: boolean; notices: SettingsNotice[] } {
+  const loaded = baySettings(
+    "updater",
+    { updateCheck: true },
+    {
+      options,
+      where: {
+        env: where.env,
+        home: where.home,
+        ...(where.directory ? { directory: where.directory } : {}),
+        read: (path) => disk.read(path),
+      },
+    },
+  )
+  return { enabled: loaded.config.enabled && loaded.config.updateCheck, notices: loaded.notices }
+}
+
+export function updateCheckEnabled(disk: Disk, where: UpdateCheckWhere, options: unknown): boolean {
+  return updateCheck(disk, where, options).enabled
 }
diff --git a/packages/updater/src/doctor/checks.ts b/packages/updater/src/doctor/checks.ts
index 817f866f..f6371dea 100644
--- a/packages/updater/src/doctor/checks.ts
+++ b/packages/updater/src/doctor/checks.ts
@@ -7,6 +7,9 @@
  * point is not a report, it is the next step (docs/roadmap/doctor.md).
  */
 
+import { BUNDLE, bayOf } from "@opencode-cockpit/client/plugin-entries"
+import type { Bay } from "@opencode-cockpit/client/settings"
+import { RESTART_COMMAND } from "../core/service.ts"
 import { isNewer, type Spec } from "../core/spec.ts"
 
 export type State = "ok" | "info" | "warn" | "fail"
@@ -21,12 +24,8 @@ export interface Check {
   fix?: string[]
 }
 
-/** The packages Cockpit ships, and which halves each has. */
-export const BUNDLE = "opencode-cockpit"
-export const BAYS = ["shell", "review", "status", "updater", "subagents", "trust"] as const
-export type Bay = (typeof BAYS)[number]
-/** Bays with an agent half — the rest are interface only. */
-const SERVER_BAYS: readonly string[] = ["shell", "review", "subagents"]
+/** Bays whose agent half doctor checks is loaded beside the interface's (`doctor.test.ts` checks it). */
+export const SERVER_BAYS: readonly Bay[] = ["shell", "review", "subagents", "trail"]
 
 /** Where a config file sits in OpenCode's split: agent plugins or interface plugins. */
 export type Half = "server" | "tui"
@@ -64,6 +63,8 @@ export interface Facts {
     backgroundSubagents?: boolean
   }
   settings: SettingsFacts
+  /** OpenCode 2's background service; unset on OpenCode 1. */
+  service?: ServiceFacts
   /** When doctor ran, so a log line's time reads as `7h ago`. Unset prints the time as logged. */
   now?: number
 }
@@ -92,16 +93,27 @@ export interface DaemonFacts {
   errors: { t: string; msg: string }[]
 }
 
+export interface ServiceFacts {
+  state: "running" | "stopped" | "unknown"
+  url?: string
+  /** When it started, ms since the epoch. */
+  startedAt?: number
+  /** When the newest Cockpit install it loads last changed, and which entry that is. */
+  installedAt?: number
+  installed?: string
+  /**
+   * What its agent side said it loaded when it started (`@opencode-cockpit/client/service`), and what
+   * is in that place now — undefined when the install is gone. Unset when the agent side wrote nothing
+   * (a Cockpit older than 0.9, or no window has opened since it started).
+   */
+  agent?: { loaded: { version: string; installedAt: number }; now?: { version: string; installedAt: number } }
+}
+
 export interface SettingsFacts {
   files: { path: string; error?: string }[]
   modules: { path: string; exists: boolean }[]
-}
-
-/** The package an entry is, when it is one of ours. */
-export function bayOf(name: string | undefined): Bay | "bundle" | undefined {
-  if (name === BUNDLE) return "bundle"
-  const match = /^@opencode-cockpit\/([a-z]+)$/.exec(name ?? "")
-  return match && (BAYS as readonly string[]).includes(match[1] as string) ? (match[1] as Bay) : undefined
+  /** What the settings loader would tell a bay: old names, unknown sidebar entries, wrong types. */
+  notices?: { file: string; text: string }[]
 }
 
 const ours = (facts: Facts) => facts.entries.filter((entry) => bayOf(entry.name))
@@ -382,6 +394,9 @@ export function checkSettings(facts: Facts): Check {
   const fix: string[] = []
   for (const file of settings.files)
     if (file.error) fix.push(`${file.path}: ${file.error} — the whole file is ignored`)
+  const broken = fix.length > 0
+  for (const notice of settings.notices ?? []) fix.push(`${notice.file}: ${notice.text}`)
+  const noted = fix.length > 0
   for (const module of settings.modules) {
     if (!module.exists) fix.push(`statusline module ${module.path} does not exist`)
   }
@@ -389,11 +404,15 @@ export function checkSettings(facts: Facts): Check {
   return {
     title: "Settings",
     state: fix.length ? "warn" : "ok",
-    summary: fix.length
+    summary: broken
       ? "a settings file Cockpit cannot use"
-      : read === 0
-        ? "defaults (no settings file)"
-        : `${read} file${read === 1 ? "" : "s"}${settings.modules.length ? `, ${settings.modules.length} statusline module${settings.modules.length === 1 ? "" : "s"}` : ""}`,
+      : noted
+        ? "settings that are not read as written"
+        : fix.length
+          ? "a statusline module is missing"
+          : read === 0
+            ? "defaults (no settings file)"
+            : `${read} file${read === 1 ? "" : "s"}${settings.modules.length ? `, ${settings.modules.length} statusline module${settings.modules.length === 1 ? "" : "s"}` : ""}`,
     detail: settings.files.map((file) => file.path),
     ...(fix.length ? { fix } : {}),
   }
@@ -428,14 +447,94 @@ export function checkSubagents(facts: Facts): Check | undefined {
   }
 }
 
+/**
+ * OpenCode 2's background service loads plugins once, when it starts. Started before Cockpit was last
+ * installed, it still runs the old agent side — the windows draw the new interface, the agent has the
+ * old tools and skills — until it restarts.
+ */
+export function checkService(facts: Facts): Check | undefined {
+  const { service } = facts
+  if (!service) return undefined
+  const title = "Service"
+  if (service.state === "stopped")
+    return {
+      title,
+      state: "ok",
+      summary: "OpenCode 2's background service is not running; a window starts it",
+    }
+  if (service.state === "unknown")
+    return {
+      title,
+      state: "info",
+      summary: "could not ask OpenCode 2 about its background service",
+      detail: ["After updating Cockpit, restart it so it loads the new agent side."],
+      fix: [RESTART_COMMAND],
+    }
+  const when = (at: number) => ago(new Date(at).toISOString(), facts.now)
+  /** What the agent side said it loaded, against what is installed there now: no clocks to compare. */
+  if (service.agent) {
+    const { loaded, now } = service.agent
+    const same = now && now.version === loaded.version && now.installedAt === loaded.installedAt
+    if (same)
+      return {
+        title,
+        state: "ok",
+        summary: `OpenCode 2's background service runs the installed Cockpit (${loaded.version})`,
+      }
+    return {
+      title,
+      state: "warn",
+      summary: "OpenCode 2's background service has the old Cockpit: it was installed again since it started",
+      detail: [
+        now
+          ? `It loaded ${loaded.version}, installed ${when(loaded.installedAt)}; ${now.version} was installed ${when(now.installedAt)}.`
+          : `It loaded ${loaded.version}, from an install that is no longer there.`,
+        "It loads plugins once, when it starts: the agent keeps the old tools and skills until it restarts.",
+      ],
+      fix: [RESTART_COMMAND],
+    }
+  }
+  const { startedAt, installedAt } = service
+  if (startedAt === undefined || installedAt === undefined)
+    return {
+      title,
+      state: "info",
+      summary: `OpenCode 2's background service is running${service.url ? ` (${service.url})` : ""}`,
+      detail: [
+        startedAt === undefined ? "Could not tell when it started." : `Started ${when(startedAt)}.`,
+        "It loads plugins when it starts: after updating Cockpit, restart it.",
+      ],
+      fix: [RESTART_COMMAND],
+    }
+  // A second of slack: an install and a start in the same moment are the same moment.
+  if (startedAt + 1000 < installedAt)
+    return {
+      title,
+      state: "warn",
+      summary: "OpenCode 2's background service has the old Cockpit: it started before the install",
+      detail: [
+        `Started ${when(startedAt)}; ${service.installed ?? "Cockpit"} was installed ${when(installedAt)}.`,
+        "It loads plugins once, when it starts: the agent keeps the old tools and skills until it restarts.",
+      ],
+      fix: [RESTART_COMMAND],
+    }
+  return {
+    title,
+    state: "ok",
+    summary: `OpenCode 2's background service started ${when(startedAt)}, after the install`,
+  }
+}
+
 export function allChecks(facts: Facts): Check[] {
   const subagents = checkSubagents(facts)
+  const service = checkService(facts)
   return [
     checkOpencode(facts),
     checkConfig(facts),
     checkRunning(facts),
     checkErrors(facts),
     checkDaemon(facts),
+    ...(service ? [service] : []),
     checkEnvironment(facts),
     ...(subagents ? [subagents] : []),
     checkSettings(facts),
diff --git a/packages/updater/src/doctor/gather.ts b/packages/updater/src/doctor/gather.ts
index 7fd645fb..9b96579e 100644
--- a/packages/updater/src/doctor/gather.ts
+++ b/packages/updater/src/doctor/gather.ts
@@ -7,18 +7,23 @@
  */
 
 import { join, resolve } from "node:path"
+import { everyNotice } from "@opencode-cockpit/client/checks"
+import { parseJsonc } from "@opencode-cockpit/client/jsonc"
+import { BUNDLE, bayOf, pluginEntries } from "@opencode-cockpit/client/plugin-entries"
+import { parseRecord } from "@opencode-cockpit/client/service"
+import { loadSettings } from "@opencode-cockpit/client/settings"
 import { globalConfigDir } from "../core/configs.ts"
 import type { Disk } from "../core/disk.ts"
-import { parseJsonc } from "../core/jsonc.ts"
+import { servicePid, serviceStartedAt, serviceStatus, stateFile } from "../core/service.ts"
 import { parseSpec } from "../core/spec.ts"
 import {
-  BUNDLE,
-  bayOf,
   type DaemonFacts,
   type Entry,
   type Facts,
   type Half,
   type LogFacts,
+  readBy,
+  type ServiceFacts,
 } from "./checks.ts"
 
 export interface DoctorIo {
@@ -32,6 +37,8 @@ export interface DoctorIo {
   /** Runs a program; undefined when it could not start. */
   run(command: string, args: readonly string[]): { status: number; stdout: string } | undefined
   alive(pid: number): boolean
+  /** A file's last change, in ms since the epoch; undefined when it is not there or not known. */
+  modified?(path: string): number | undefined
   writable(dir: string): boolean
   fetchLatest(names: readonly string[]): Promise>
   now: number
@@ -52,30 +59,6 @@ export function cockpitHome(io: Pick): string {
   return io.env.COCKPIT_HOME ?? join(io.env.XDG_CACHE_HOME ?? join(io.home, ".cache"), "opencode-cockpit")
 }
 
-/**
- * A plugin entry in either OpenCode's spelling: v1's `"spec"` and `["spec", options]` under
- * `plugin`, v2's `"spec"` and `{ package, options }` under `plugins`.
- */
-function specsIn(value: unknown): string[] {
-  const out: string[] = []
-  const config = (value ?? {}) as { plugin?: unknown; plugins?: unknown }
-  for (const list of [config.plugin, config.plugins]) {
-    if (!Array.isArray(list)) continue
-    for (const item of list) {
-      if (typeof item === "string") out.push(item)
-      else if (Array.isArray(item) && typeof item[0] === "string") out.push(item[0])
-      else if (
-        item &&
-        typeof item === "object" &&
-        typeof (item as { package?: unknown }).package === "string"
-      ) {
-        out.push((item as { package: string }).package)
-      }
-    }
-  }
-  return out
-}
-
 /** A local entry is named by its own package.json — that is what OpenCode loads. */
 function localPath(raw: string, io: Pick, base: string): string {
   const path = raw.replace(/^file:\/\//, "").replace(/^file:/, "")
@@ -104,7 +87,7 @@ function readEntries(io: DoctorIo): {
         errors.push({ path: file, message: parsed.message })
         continue
       }
-      for (const raw of specsIn(parsed.value)) {
+      for (const { name: raw } of pluginEntries(parsed.value)) {
         const spec = parseSpec(raw)
         let pkgName: string | undefined
         if (spec.kind === "npm") pkgName = spec.name
@@ -208,22 +191,28 @@ function readDaemon(io: DoctorIo, home: string): DaemonFacts {
   }
 }
 
+/**
+ * Cockpit's own settings, read by the loader every bay reads them with — so a file doctor calls fine
+ * is a file the bays can use, and every notice a bay would draw is a line here.
+ */
 function readSettings(io: DoctorIo): Facts["settings"] {
-  const configDir = join(io.env.XDG_CONFIG_HOME ?? join(io.home, ".config"), "opencode-cockpit")
   const project = io.worktree ?? io.cwd
-  const files: Facts["settings"]["files"] = []
+  const settings = loadSettings({
+    directory: project,
+    env: io.env,
+    home: io.home,
+    read: (path) => io.disk.read(path),
+  })
+  const files: Facts["settings"]["files"] = settings.files
+    .filter((file) => file.found)
+    .map((file) => (file.error ? { path: file.path, error: file.error } : { path: file.path }))
+  /** Each bay's own too — what its block draws — where the bay offered its check (`checks.ts`). */
+  const notices = everyNotice(settings)
+    .filter((notice) => notice.kind !== "unreadable")
+    .map((notice) => ({ file: notice.file, text: notice.text }))
   const modules: Facts["settings"]["modules"] = []
-  for (const path of [join(configDir, "config.json"), join(project, ".cockpit.json")]) {
-    const text = io.disk.read(path)
-    if (text === undefined) continue
-    const parsed = parseJsonc(text)
-    if (!parsed.ok) {
-      files.push({ path, error: parsed.message })
-      continue
-    }
-    files.push({ path })
-    const config = parsed.value as { statusline?: { modules?: unknown }; modules?: unknown } | null
-    const list = config?.statusline?.modules ?? config?.modules
+  for (const layer of settings.layers) {
+    const list = layer.sections.status?.modules
     if (!Array.isArray(list)) continue
     for (const module of list) {
       if (typeof module !== "string") continue
@@ -232,7 +221,7 @@ function readSettings(io: DoctorIo): Facts["settings"] {
       modules.push({ path: module, exists: io.exists(full) })
     }
   }
-  return { files, modules }
+  return { files, modules, notices }
 }
 
 /**
@@ -249,6 +238,63 @@ export function backgroundSubagents(env: DoctorIo["env"]): boolean {
   return flag(env.OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS) ?? flag(env.OPENCODE_EXPERIMENTAL) ?? false
 }
 
+/** A local entry's directory, as `readEntries` resolved it to find its package.json. */
+function entryDir(entry: Entry, io: DoctorIo): string | undefined {
+  if (entry.spec.kind === "npm") return undefined
+  const base = entry.file.slice(0, entry.file.lastIndexOf("/"))
+  return localPath(entry.spec.raw, io, base)
+}
+
+/**
+ * OpenCode 2's background service against the Cockpit it should be running: when it started, and
+ * when the installed Cockpit's files last changed. Local installs only — an npm entry's files are
+ * wherever OpenCode 2 put them, which was not measured.
+ */
+function readService(io: DoctorIo, entries: Entry[]): ServiceFacts | undefined {
+  const status = serviceStatus(io.run)
+  if (status.state === "unknown") return { state: "unknown" }
+  if (status.state === "stopped") return { state: "stopped" }
+  const pid = servicePid(io.disk.read(stateFile(io.env, io.home)))
+  const startedAt = serviceStartedAt(io.run, pid !== undefined && io.alive(pid) ? pid : undefined, io.now)
+  const installed = entries
+    .filter((entry) => bayOf(entry.name) && readBy(entry, true))
+    .flatMap((entry) => {
+      const dir = entryDir(entry, io)
+      const at = dir ? io.modified?.(join(dir, "package.json")) : undefined
+      return at === undefined ? [] : [{ at, raw: entry.raw }]
+    })
+    .sort((a, b) => b.at - a.at)[0]
+  const agent = pid === undefined ? undefined : readAgent(io, pid)
+  return {
+    state: "running",
+    url: status.url,
+    ...(startedAt !== undefined ? { startedAt } : {}),
+    ...(installed ? { installedAt: installed.at, installed: installed.raw } : {}),
+    ...(agent ? { agent } : {}),
+  }
+}
+
+/**
+ * What the service's agent side wrote when it started — the install it loaded — and what is in that
+ * install's place now: the same record every window compares itself with (client `service.ts`).
+ */
+function readAgent(io: DoctorIo, pid: number): ServiceFacts["agent"] {
+  const record = parseRecord(io.disk.read(join(cockpitHome(io), "agents", `${pid}.json`)))
+  if (!record) return undefined
+  const loaded = { version: record.version, installedAt: record.installedAt }
+  const file = join(record.dir, "package.json")
+  let version: unknown
+  try {
+    version = (JSON.parse(io.disk.read(file) ?? "") as { version?: unknown }).version
+  } catch {
+    version = undefined
+  }
+  const at = io.modified?.(file)
+  return typeof version === "string" && at !== undefined
+    ? { loaded, now: { version, installedAt: Math.round(at) } }
+    : { loaded }
+}
+
 export async function gatherFacts(io: DoctorIo): Promise {
   const home = cockpitHome(io)
   const versionOut = io.run("opencode", ["--version"])
@@ -263,6 +309,8 @@ export async function gatherFacts(io: DoctorIo): Promise {
     ]),
   ]
   const latest = await io.fetchLatest(names).catch(() => new Map())
+  const major = version ? Number(version.split(".")[0]) : undefined
+  const service = major !== undefined && major >= 2 ? readService(io, entries) : undefined
   return {
     opencode: { version, major: version ? Number(version.split(".")[0]) : undefined },
     entries,
@@ -280,5 +328,6 @@ export async function gatherFacts(io: DoctorIo): Promise {
       backgroundSubagents: backgroundSubagents(io.env),
     },
     settings: readSettings(io),
+    ...(service ? { service } : {}),
   }
 }
diff --git a/packages/updater/src/tui/index.tsx b/packages/updater/src/tui/index.tsx
index 45d078d5..7c992195 100644
--- a/packages/updater/src/tui/index.tsx
+++ b/packages/updater/src/tui/index.tsx
@@ -122,7 +122,7 @@ export function createUpdaterTui({ source = UPDATER_PACKAGE }: { source?: string
           host.ui.toast({
             title: "Plugins",
             message:
-              "On OpenCode 2, change the version in your opencode.json plugin entry, then restart OpenCode.",
+              "On OpenCode 2, change the version in your opencode.json plugin entry, then run `opencode service restart` and restart OpenCode.",
             duration: 10_000,
           })
 
diff --git a/packages/updater/test/cli.test.ts b/packages/updater/test/cli.test.ts
index c2f3cf5f..8fda6824 100644
--- a/packages/updater/test/cli.test.ts
+++ b/packages/updater/test/cli.test.ts
@@ -46,9 +46,10 @@ const PUBLISHED = new Map([
  * A fake `opencode plugin  -f -g` that does what the real one was seen to do: replace the
  * entry in every global file that has it, and install the new spec's directory.
  */
-function fakeOpencode(files: Record, calls: string[][], broken = false) {
+function fakeOpencode(files: Record, calls: string[][], broken = false, version = "1.18.32") {
   return async (args: readonly string[]) => {
     calls.push([...args])
+    if (args[0] === "--version") return { status: 0, output: `${version}\n` }
     if (args[1] === "--help") return { status: 0, output: "  -f, --force  replace existing plugin version" }
     if (broken) return { status: 1, output: "Failed updating plugin config" }
     const to = parseSpec(args[1] as string)
@@ -156,6 +157,7 @@ describe("update", () => {
     expect(cli.calls.slice(1)).toEqual([
       ["plugin", "opencode-cockpit@0.5.0", "-f", "-g"],
       ["plugin", "opencode-foo@1.3.0", "-f", "-g"],
+      ["--version"],
     ])
     expect(files[`${CONFIG}/tui.json`]).toBe(`{"plugin": ["opencode-cockpit@0.5.0"]}`)
     expect(files[`${CONFIG}/opencode.jsonc`]).toContain("// mine")
@@ -180,7 +182,7 @@ describe("update", () => {
   test("--only touches one plugin", async () => {
     const cli = io(machine())
     expect(await update(["--only", "opencode-foo", "--yes"], cli)).toBe(0)
-    expect(cli.calls.slice(1)).toEqual([["plugin", "opencode-foo@1.3.0", "-f", "-g"]])
+    expect(cli.calls.slice(1)).toEqual([["plugin", "opencode-foo@1.3.0", "-f", "-g"], ["--version"]])
   })
 
   test("declining writes nothing", async () => {
@@ -198,6 +200,27 @@ describe("update", () => {
     expect(cli.out.join("")).toContain("Rerun with --yes")
   })
 
+  test("on OpenCode 2 the steps by hand end with restarting its background service", async () => {
+    const calls: string[][] = []
+    const cli = io(machine(), {
+      opencode: async (args) => {
+        calls.push([...args])
+        if (args[0] === "--version") return { status: 0, output: "opencode v2.0.18\n" }
+        return { status: 0, output: "SUBCOMMANDS\n  list  add  check  update  remove" }
+      },
+    })
+    expect(await update(["-y"], cli)).toBe(1)
+    expect(cli.out.join("")).toContain("restart its background service, which keeps the old plugin code")
+    expect(cli.out.join("")).toContain("opencode service restart")
+  })
+
+  test("on OpenCode 1 a finished update says nothing of a service", async () => {
+    const files = machine()
+    const cli = io(files)
+    expect(await update(["-y"], cli)).toBe(0)
+    expect(cli.out.join("")).not.toContain("service restart")
+  })
+
   test("without opencode it writes nothing and prints what to do by hand", async () => {
     const cli = io(machine(), { opencode: async () => ({ status: null, output: "ENOENT" }) })
     expect(await update([], cli)).toBe(1)
diff --git a/packages/updater/test/doctor.test.ts b/packages/updater/test/doctor.test.ts
index 769453f2..343d4bd6 100644
--- a/packages/updater/test/doctor.test.ts
+++ b/packages/updater/test/doctor.test.ts
@@ -1,6 +1,8 @@
 import { describe, expect, test } from "bun:test"
+import { readFileSync } from "node:fs"
+import { join } from "node:path"
 import { memoryDisk } from "../src/core/disk.ts"
-import { ago, type Check } from "../src/doctor/checks.ts"
+import { ago, type Check, SERVER_BAYS } from "../src/doctor/checks.ts"
 import type { DoctorIo } from "../src/doctor/gather.ts"
 import { doctor } from "../src/doctor/run.ts"
 
@@ -22,6 +24,10 @@ interface Machine {
   alive?: number[]
   git?: boolean
   env?: Record
+  /** OpenCode 2's service: what `opencode service status` prints, and `ps -o etime=` for its pid. */
+  service?: { status: string; elapsed?: string }
+  /** A file's last change, ms since the epoch. */
+  mtimes?: Record
 }
 
 async function run(machine: Machine, args: string[] = []) {
@@ -34,13 +40,20 @@ async function run(machine: Machine, args: string[] = []) {
     worktree: "/work/project",
     disk: memoryDisk(files),
     exists: (path) => path in files || (machine.exists ?? []).includes(path),
-    run(command) {
-      if (command === "opencode")
+    run(command, args) {
+      if (command === "opencode" && args[0] === "--version")
         return machine.opencode ? { status: 0, stdout: `${machine.opencode}\n` } : undefined
       if (command === "git") return machine.git === false ? undefined : { status: 0, stdout: "git version 2" }
+      if (command === "opencode" && args[0] === "service")
+        return machine.service ? { status: 0, stdout: `${machine.service.status}\n` } : undefined
+      if (command === "ps" && args[0] === "-o" && args[1] === "etime=")
+        return machine.service?.elapsed
+          ? { status: 0, stdout: ` ${machine.service.elapsed}\n` }
+          : { status: 1, stdout: "" }
       return { status: 0, stdout: "" }
     },
     alive: (pid) => (machine.alive ?? []).includes(pid),
+    modified: (path) => machine.mtimes?.[path],
     writable: () => true,
     fetchLatest: async (names) => new Map(names.map((name) => [name, machine.latest?.[name]])),
     now: Date.parse("2026-09-24T12:00:00Z"),
@@ -146,6 +159,27 @@ describe("the config", () => {
     expect(found.Config?.fix?.join()).toContain("also inside opencode-cockpit")
   })
 
+  test("Trail is a bay with both halves: inside the bundle, and missing its tools on OpenCode 1", async () => {
+    const twice = await checks({
+      opencode: "2.0.18",
+      latest: { "opencode-cockpit": "0.9.0", "@opencode-cockpit/trail": "0.9.0" },
+      files: {
+        [`${CONFIG}/opencode.json`]: json({
+          plugins: ["opencode-cockpit@0.9.0", "@opencode-cockpit/trail@0.9.0"],
+        }),
+      },
+    })
+    expect(twice.Config?.fix?.join()).toContain("@opencode-cockpit/trail@0.9.0 (")
+    const half = await checks({
+      opencode: "1.18.32",
+      latest: { "@opencode-cockpit/trail": "0.9.0" },
+      files: { [`${CONFIG}/tui.json`]: json({ plugin: ["@opencode-cockpit/trail@0.9.0"] }) },
+    })
+    expect(half.Config?.fix?.join()).toContain(
+      "@opencode-cockpit/trail is in tui.json but not opencode.json: the agent has none of its tools",
+    )
+  })
+
   /** v1 never reads cli.json: panels configured only there do not show. */
   test("on OpenCode 1, the interface configured only in cli.json is a missing half", async () => {
     const found = await checks({
@@ -328,13 +362,107 @@ describe("the rest", () => {
       opencode: "2.0.15",
       files: {
         [`${HOME}/.config/opencode-cockpit/config.json`]: json({
-          statusline: { modules: ["~/.config/opencode-cockpit/modules/gone.ts"] },
+          status: { modules: ["~/.config/opencode-cockpit/modules/gone.ts"] },
         }),
       },
     })
     expect(found.Settings?.state).toBe("warn")
     expect(found.Settings?.fix?.join()).toContain("gone.ts")
   })
+
+  /** Status reads modules from `status.modules` only; doctor agrees, and names the old places as such. */
+  test("modules under the old names are not checked: each is only the loader's notice", async () => {
+    const file = `${HOME}/.config/opencode-cockpit/config.json`
+    const found = await checks({
+      opencode: "2.0.15",
+      files: {
+        [file]: json({ statusline: { modules: ["~/old.ts"] }, modules: ["~/root.ts"] }),
+      },
+    })
+    const fix = found.Settings?.fix?.join("\n") ?? ""
+    expect(fix).not.toContain("does not exist")
+    expect(fix).toContain(`${file}: "statusline" is no longer read — run /cockpit-setup`)
+    expect(fix).toContain(`${file}: "modules" at the top level is not read: it belongs in "status"`)
+  })
+})
+
+/**
+ * Doctor reads Cockpit's settings through the loader the bays use, so the two cannot disagree: a file
+ * with a comment was "fine" to doctor while every bay dropped it whole.
+ */
+describe("Cockpit's settings", () => {
+  const GLOBAL = `${HOME}/.config/opencode-cockpit/config.json`
+  const PROJECT = "/work/project/.cockpit.json"
+
+  test("comments and trailing commas: fine to doctor, and read by the bays", async () => {
+    const files = { [GLOBAL]: `{\n  // mine\n  "trust": { "threshold": 5, },\n}` }
+    const found = await checks({ opencode: "2.0.18", files })
+    expect(found.Settings?.state).toBe("ok")
+    expect(found.Settings?.summary).toBe("1 file")
+    const { baySettings } = await import("@opencode-cockpit/client/settings")
+    const trust = baySettings(
+      "trust",
+      { threshold: 3 },
+      {
+        where: {
+          env: {},
+          home: HOME,
+          directory: "/work/project",
+          read: (path) => memoryDisk(files).read(path),
+        },
+      },
+    )
+    expect(trust.config.threshold).toBe(5)
+  })
+
+  /** What a bay draws as a `!` row is a fix line too: Trust's threshold, and Status's own words when offered. */
+  test("each bay's own notices: a key of the wrong kind, and a bay's own check", async () => {
+    const { offerSettingsCheck } = await import("@opencode-cockpit/client/checks")
+    offerSettingsCheck("status", () => [
+      { bay: "status", file: GLOBAL, kind: "invalid", text: 'override "gti" matches no segment' },
+    ])
+    try {
+      const files = { [GLOBAL]: json({ status: { override: { gti: false } }, trust: { threshold: "3" } }) }
+      const found = await checks({ opencode: "2.0.18", files })
+      expect(found.Settings?.state).toBe("warn")
+      expect(found.Settings?.fix).toEqual([
+        `${GLOBAL}: override "gti" matches no segment`,
+        `${GLOBAL}: "trust.threshold" should be a number; the default is used`,
+      ])
+    } finally {
+      offerSettingsCheck("status", () => [])
+    }
+  })
+
+  test("a file the bays cannot parse is not fine", async () => {
+    const found = await checks({ opencode: "2.0.18", files: { [PROJECT]: `{ "trust": { "threshold": 5 ` } })
+    expect(found.Settings?.state).toBe("warn")
+    expect(found.Settings?.summary).toBe("a settings file Cockpit cannot use")
+    expect(found.Settings?.fix?.[0]).toStartWith(`${PROJECT}: `)
+    expect(found.Settings?.fix?.[0]).toEndWith("the whole file is ignored")
+  })
+
+  test("every old name and unknown sidebar entry is a fix line", async () => {
+    const found = await checks({
+      opencode: "2.0.18",
+      files: {
+        [GLOBAL]: json({
+          statusline: { preset: "sidebar" },
+          ui: { dockHeight: 20 },
+          sidebar: ["shells", "status"],
+        }),
+        [PROJECT]: json({ trust: { sidebarOrder: 1 } }),
+      },
+    })
+    expect(found.Settings?.state).toBe("warn")
+    expect(found.Settings?.summary).toBe("settings that are not read as written")
+    expect(found.Settings?.fix).toEqual([
+      `${GLOBAL}: "statusline" is no longer read — run /cockpit-setup`,
+      `${GLOBAL}: "ui.dockHeight" is no longer read — run /cockpit-setup`,
+      `${GLOBAL}: "shells" in "sidebar" is not a bay: did you mean "shell"? (status, subagents, shell, trail, trust)`,
+      `${PROJECT}: "trust.sidebarOrder" is no longer read — run /cockpit-setup`,
+    ])
+  })
 })
 
 describe("background subagents (the 0.8 load test)", () => {
@@ -399,3 +527,113 @@ describe("a logged time reads as how long ago", () => {
     expect(ago("2026-09-30T11:00:00Z", undefined)).toBe("2026-09-30T11:00:00Z")
   })
 })
+
+describe("OpenCode 2's background service (it keeps the plugin code it started with)", () => {
+  const NOW = Date.parse("2026-09-24T12:00:00Z")
+  const DEV = "/home/me/.cockpit-dev/node_modules/opencode-cockpit"
+  const machine = (service: Machine["service"], installedAgo?: number): Machine => ({
+    opencode: "2.0.18",
+    files: {
+      [`${CONFIG}/opencode.json`]: json({ plugins: [DEV] }),
+      [`${DEV}/package.json`]: json({ name: "opencode-cockpit", version: "0.9.0" }),
+      [`${HOME}/.local/state/opencode/service.json`]: json({
+        pid: 4242,
+        url: "http://127.0.0.1:49374",
+        password: "x",
+      }),
+    },
+    alive: [4242],
+    service,
+    ...(installedAgo !== undefined ? { mtimes: { [`${DEV}/package.json`]: NOW - installedAgo } } : {}),
+  })
+  const HOUR = 3600_000
+
+  test("started before the install: warns, and the fix is the restart", async () => {
+    // Started 6 days ago (the measured Sep 27 service); Cockpit installed 2 hours ago.
+    const found = await checks(machine({ status: "http://127.0.0.1:49374", elapsed: "6-00:00:00" }, 2 * HOUR))
+    expect(found.Service?.state).toBe("warn")
+    expect(found.Service?.summary).toContain("started before the install")
+    expect(found.Service?.detail?.[0]).toBe(`Started 6d ago; ${DEV} was installed 2h ago.`)
+    expect(found.Service?.fix).toEqual(["opencode service restart"])
+  })
+
+  test("started after the install: fine", async () => {
+    const found = await checks(machine({ status: "http://127.0.0.1:49374", elapsed: "10:00" }, 2 * HOUR))
+    expect(found.Service?.state).toBe("ok")
+    expect(found.Service?.summary).toContain("10m ago, after the install")
+  })
+
+  test("not running: nothing to restart", async () => {
+    expect((await checks(machine({ status: "stopped" }, HOUR))).Service?.state).toBe("ok")
+  })
+
+  test("running, but when it started or what it loads is not known: the restart, as advice", async () => {
+    const found = await checks(machine({ status: "http://127.0.0.1:49374" }))
+    expect(found.Service?.state).toBe("info")
+    expect(found.Service?.fix).toEqual(["opencode service restart"])
+  })
+
+  describe("with the record its agent side wrote (0.9+): what it loaded against what is there now", () => {
+    const CLIENT = "/home/me/.cockpit-dev/node_modules/@opencode-cockpit/client"
+    const withRecord = (now: { version: string; at: number } | undefined, loadedAt = NOW - 2 * HOUR) => {
+      const base = machine({ status: "http://127.0.0.1:49374", elapsed: "10:00" }, 6 * 24 * HOUR)
+      return {
+        ...base,
+        files: {
+          ...base.files,
+          [`${HOME}/.cache/opencode-cockpit/agents/4242.json`]: json({
+            version: "0.9.0",
+            installedAt: loadedAt,
+            dir: CLIENT,
+            pid: 4242,
+            startedAt: loadedAt + 1000,
+          }),
+          ...(now ? { [`${CLIENT}/package.json`]: json({ version: now.version }) } : {}),
+        },
+        mtimes: { ...base.mtimes, ...(now ? { [`${CLIENT}/package.json`]: now.at } : {}) },
+      }
+    }
+
+    test("the same install: fine, whatever the clocks say", async () => {
+      const found = await checks(withRecord({ version: "0.9.0", at: NOW - 2 * HOUR }))
+      expect(found.Service?.state).toBe("ok")
+      expect(found.Service?.summary).toContain("runs the installed Cockpit (0.9.0)")
+    })
+
+    test("installed again since (a dev install keeps its version): warns, with both", async () => {
+      const found = await checks(withRecord({ version: "0.9.0", at: NOW - HOUR }))
+      expect(found.Service?.state).toBe("warn")
+      expect(found.Service?.detail?.[0]).toBe(
+        "It loaded 0.9.0, installed 2h ago; 0.9.0 was installed 1h ago.",
+      )
+      expect(found.Service?.fix).toEqual(["opencode service restart"])
+    })
+
+    test("the install it loaded is gone: warns", async () => {
+      const found = await checks(withRecord(undefined))
+      expect(found.Service?.state).toBe("warn")
+      expect(found.Service?.detail?.[0]).toContain("no longer there")
+    })
+  })
+
+  test("OpenCode 1 has no service to talk about", async () => {
+    expect((await checks({ ...machine({ status: "stopped" }), opencode: "1.18.32" })).Service).toBeUndefined()
+  })
+
+  test("as a person reads it", async () => {
+    const { out } = await run(machine({ status: "http://127.0.0.1:49374", elapsed: "6-00:00:00" }, 2 * HOUR))
+    expect(out).toContain("! Service")
+    expect(out).toContain("→ opencode service restart")
+  })
+})
+
+describe("the bays doctor knows", () => {
+  test("every bay it checks for an agent half publishes one", () => {
+    for (const bay of SERVER_BAYS) {
+      const manifest = JSON.parse(
+        readFileSync(join(import.meta.dir, "..", "..", bay, "package.json"), "utf8"),
+      )
+      expect(Object.keys(manifest.exports)).toContain("./server")
+    }
+  })
+})
diff --git a/packages/updater/test/registry.test.ts b/packages/updater/test/registry.test.ts
index 6697ac1f..13e66de1 100644
--- a/packages/updater/test/registry.test.ts
+++ b/packages/updater/test/registry.test.ts
@@ -8,7 +8,7 @@
 import { describe, expect, test } from "bun:test"
 import { memoryDisk } from "../src/core/disk.ts"
 import { fetchAllLatest, fetchLatest, registryFrom } from "../src/core/registry.ts"
-import { updateCheckEnabled } from "../src/core/settings.ts"
+import { updateCheck, updateCheckEnabled } from "../src/core/settings.ts"
 
 const stub = (fn: (url: string, init: RequestInit) => Promise | Response) =>
   ((url: string, init: RequestInit) => fn(String(url), init)) as unknown as typeof fetch
@@ -59,10 +59,26 @@ describe("the daily notice setting", () => {
     expect(updateCheckEnabled(memoryDisk({}), where, undefined)).toBe(true)
   })
 
-  test("Shell's old `ui.updateCheck: false` still silences it", () => {
-    expect(
-      updateCheckEnabled(memoryDisk({ [GLOBAL]: '{"ui":{"updateCheck":false}}' }), where, undefined),
-    ).toBe(false)
+  /** An old name is not read (0.9): it is a notice naming `updater.updateCheck`, and the check stays on. */
+  test("Shell's old `ui.updateCheck` is not read, and says what to write instead", () => {
+    const disk = memoryDisk({ [GLOBAL]: '{"ui":{"updateCheck":false}}' })
+    expect(updateCheckEnabled(disk, where, undefined)).toBe(true)
+    expect(updateCheck(disk, where, undefined).notices).toMatchObject([
+      { bay: "updater", kind: "old", old: "ui.updateCheck", new: "updater.updateCheck" },
+    ])
+  })
+
+  test("comments and trailing commas are fine, as in every bay", () => {
+    const disk = memoryDisk({ [GLOBAL]: '{\n  // quiet\n  "updater": { "updateCheck": false, },\n}' })
+    expect(updateCheckEnabled(disk, where, undefined)).toBe(false)
+  })
+
+  test("a value of the wrong kind is a notice, and the default stands", () => {
+    const disk = memoryDisk({ [GLOBAL]: '{"updater":{"updateCheck":"no"}}' })
+    expect(updateCheck(disk, where, undefined)).toMatchObject({
+      enabled: true,
+      notices: [{ kind: "invalid" }],
+    })
   })
 
   test("the project file beats the global one, and plugin options beat both", () => {
diff --git a/packages/updater/test/service.test.ts b/packages/updater/test/service.test.ts
new file mode 100644
index 00000000..6490f139
--- /dev/null
+++ b/packages/updater/test/service.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, test } from "bun:test"
+import {
+  parseElapsed,
+  parseServiceStatus,
+  type Run,
+  restartService,
+  servicePid,
+  serviceStartedAt,
+} from "../src/core/service.ts"
+
+/**
+ * OpenCode 2's background service, through a runner made of a script — never the real one: a test
+ * that restarted it would cut off whoever is using OpenCode on this machine.
+ */
+
+/** Answers each ` ` from a table, and remembers what was run. */
+function runner(answers: Record) {
+  const ran: string[] = []
+  const run: Run = (command, args) => {
+    const line = [command, ...args].join(" ")
+    ran.push(line)
+    return answers[line]
+  }
+  return { run, ran }
+}
+
+describe("what `opencode service status` says (2.0.18)", () => {
+  test("a URL is running, `stopped` is stopped, anything else is unknown", () => {
+    expect(parseServiceStatus("http://127.0.0.1:49374\n")).toEqual({
+      state: "running",
+      url: "http://127.0.0.1:49374",
+    })
+    expect(parseServiceStatus("stopped\n")).toEqual({ state: "stopped" })
+    expect(parseServiceStatus("")).toEqual({ state: "unknown" })
+  })
+
+  test("ps's elapsed time, in every shape it prints", () => {
+    expect(parseElapsed("  05:07\n")).toBe(307)
+    expect(parseElapsed("01:02:03")).toBe(3723)
+    expect(parseElapsed("22-00:13:28")).toBe(22 * 86400 + 13 * 60 + 28)
+    expect(parseElapsed("")).toBeUndefined()
+  })
+
+  test("the pid from the state file, and when that process started", () => {
+    expect(servicePid('{"id":"x","pid":3310,"password":"p"}')).toBe(3310)
+    expect(servicePid("nope")).toBeUndefined()
+    const { run } = runner({ "ps -o etime= -p 3310": { status: 0, stdout: "01:00\n" } })
+    expect(serviceStartedAt(run, 3310, 1_000_000)).toBe(940_000)
+    expect(serviceStartedAt(run, undefined, 1_000_000)).toBeUndefined()
+  })
+})
+
+describe("after an install", () => {
+  const BIN = "/home/me/.opencode/bin/opencode"
+
+  test("running: restarted, and says so", () => {
+    const { run, ran } = runner({
+      [`${BIN} service status`]: { status: 0, stdout: "http://127.0.0.1:49374\n" },
+      [`${BIN} service restart`]: { status: 0, stdout: "http://127.0.0.1:49374\n" },
+    })
+    expect(restartService(run, BIN)).toEqual([
+      "Restarted OpenCode 2's background service (http://127.0.0.1:49374): it now runs this install.",
+    ])
+    expect(ran).toEqual([`${BIN} service status`, `${BIN} service restart`])
+  })
+
+  test("stopped: nothing restarted", () => {
+    const { run, ran } = runner({ [`${BIN} service status`]: { status: 0, stdout: "stopped\n" } })
+    expect(restartService(run, BIN)[0]).toContain("not running")
+    expect(ran).toEqual([`${BIN} service status`])
+  })
+
+  test("a restart that fails, or a status it cannot read, prints the exact command", () => {
+    const failed = runner({
+      [`${BIN} service status`]: { status: 0, stdout: "http://127.0.0.1:49374\n" },
+      [`${BIN} service restart`]: { status: 1, stdout: "" },
+    })
+    expect(restartService(failed.run, BIN).at(-1)).toBe("  opencode service restart")
+    const unknown = runner({})
+    expect(restartService(unknown.run, BIN).join(" ")).toContain("opencode service restart")
+    expect(unknown.ran).toEqual([`${BIN} service status`])
+  })
+})
diff --git a/packages/updater/test/spec.test.ts b/packages/updater/test/spec.test.ts
index ebfd78d3..3609646e 100644
--- a/packages/updater/test/spec.test.ts
+++ b/packages/updater/test/spec.test.ts
@@ -1,5 +1,4 @@
 import { describe, expect, test } from "bun:test"
-import { parseJsonc } from "../src/core/jsonc.ts"
 import { isNewer, parseSpec } from "../src/core/spec.ts"
 
 describe("parseSpec", () => {
@@ -56,29 +55,3 @@ describe("isNewer", () => {
     expect(isNewer("latest", "0.4.3")).toBe(false)
   })
 })
-
-describe("parseJsonc", () => {
-  test("drops comments and trailing commas, and leaves strings alone", () => {
-    const text = `{
-      // the plugins
-      "$schema": "https://opencode.ai/config.json", /* a URL is not a comment */
-      "plugin": ["a@1.0.0", "b,}",],
-    }`
-    expect(parseJsonc(text)).toEqual({
-      ok: true,
-      value: { $schema: "https://opencode.ai/config.json", plugin: ["a@1.0.0", "b,}"] },
-    })
-  })
-
-  test("an escaped quote does not end a string", () => {
-    expect(parseJsonc(`{"a": "say \\"hi\\" // not a comment"}`)).toEqual({
-      ok: true,
-      value: { a: 'say "hi" // not a comment' },
-    })
-  })
-
-  test("broken JSON is a failure with a message, not an empty config", () => {
-    const result = parseJsonc(`{"plugin": [}`)
-    expect(result.ok).toBe(false)
-  })
-})
diff --git a/scripts/dev-install.ts b/scripts/dev-install.ts
index da656916..3a9eb1cc 100644
--- a/scripts/dev-install.ts
+++ b/scripts/dev-install.ts
@@ -16,6 +16,7 @@ import { existsSync, mkdirSync, renameSync, rmSync } from "node:fs"
 import { homedir } from "node:os"
 import { join } from "node:path"
 import { FEATURES } from "../packages/opencode/src/features.ts"
+import { majorOf, type Run, restartService } from "../packages/updater/src/core/service.ts"
 
 const root = join(import.meta.dir, "..")
 const target = process.env.COCKPIT_DEV_DIR ?? join(homedir(), ".cockpit-dev")
@@ -69,3 +70,21 @@ const bundle = join(target, "node_modules", "opencode-cockpit")
 console.log(`installed into ${target}\n\nPoint both OpenCode versions at:\n  ${bundle}\n`)
 console.log(`v1  opencode.json + tui.json:  "plugin": ["${bundle}"]`)
 console.log(`v2  opencode.json + cli.json:  "plugins": ["${bundle}"]`)
+
+/**
+ * OpenCode 2's background service loads plugins once, when it starts: without a restart it keeps the
+ * agent side it had (measured: no `trail_add`, no skills, days after a reinstall). The v2 binary is
+ * the one on PATH, or OpenCode's own install directory when PATH has OpenCode 1.
+ */
+const runner: Run = (command, args) => {
+  try {
+    const result = Bun.spawnSync([command, ...args], { stdout: "pipe", stderr: "pipe", timeout: 30_000 })
+    return { status: result.exitCode ?? 1, stdout: result.stdout.toString() }
+  } catch {
+    return undefined
+  }
+}
+const v2 = [Bun.which("opencode"), join(homedir(), ".opencode", "bin", "opencode")]
+  .filter((bin): bin is string => Boolean(bin) && existsSync(bin as string))
+  .find((bin) => majorOf(runner, bin) === 2)
+if (v2) console.log(`\n${restartService(runner, v2).join("\n")}`)
diff --git a/scripts/measure-agent.ts b/scripts/measure-agent.ts
new file mode 100644
index 00000000..94d497a7
--- /dev/null
+++ b/scripts/measure-agent.ts
@@ -0,0 +1,191 @@
+/**
+ * What every bay's behaviour measurement shares (`packages//measure/agent.ts`): a real OpenCode,
+ * in a folder of its own, running one turn of a free model against a bay's server half, and what that
+ * turn did — every tool call, Code Mode's inner calls included, and what it said.
+ *
+ * Isolation: OpenCode's config, state, data and cache and Cockpit's home are the run's own, and OpenCode
+ * 2 runs `--standalone`, never against the user's background service. Needs the network, so the
+ * measurements stay out of CI, like `AGENT=1 smoke:tui`.
+ */
+
+import { readFileSync } from "node:fs"
+import { join } from "node:path"
+
+export interface OpenCode {
+  bin: string
+  /** As `--version` prints it. */
+  version: string
+  v2: boolean
+}
+
+/** `OPENCODE`, or the `opencode` on PATH. Exits 2 when there is none. */
+export function openCode(): OpenCode {
+  const bin = process.env.OPENCODE ?? Bun.which("opencode")
+  if (!bin) {
+    console.error("opencode binary not found: set OPENCODE")
+    process.exit(2)
+  }
+  const version = Bun.spawnSync([bin, "--version"]).stdout.toString().trim()
+  return { bin, version, v2: version.replace(/^opencode\s+v?/, "").startsWith("2") }
+}
+
+/** `--flag value` from argv. */
+export function flag(name: string): string | undefined {
+  const args = process.argv.slice(2)
+  const at = args.indexOf(name)
+  return at >= 0 ? args[at + 1] : undefined
+}
+
+export interface Call {
+  tool: string
+  status?: string
+  input?: unknown
+}
+
+/** Every tool call of a `--format json` run, Code Mode's inner calls included (v2 lists them on `execute`). */
+export function toolCalls(stdout: string): Call[] {
+  const out: Call[] = []
+  for (const event of events(stdout)) {
+    if (event.type !== "tool_use" || !event.part) continue
+    const part = event.part as {
+      tool: string
+      state: {
+        status?: string
+        input?: unknown
+        metadata?: { toolCalls?: unknown[]; metadata?: { toolCalls?: unknown[] } }
+      }
+    }
+    out.push({ tool: part.tool, status: part.state.status, input: part.state.input })
+    const inner = part.state.metadata?.toolCalls ?? part.state.metadata?.metadata?.toolCalls ?? []
+    for (const call of inner as Call[]) out.push(call)
+  }
+  return out
+}
+
+function events(stdout: string): { type?: string; sessionID?: string; part?: Record }[] {
+  return stdout
+    .split("\n")
+    .filter((line) => line.startsWith("{"))
+    .flatMap((line) => {
+      try {
+        return [JSON.parse(line)]
+      } catch {
+        return []
+      }
+    })
+}
+
+export interface Turn {
+  calls: Call[]
+  /** What the model wrote, joined. */
+  said: string
+  session?: string
+  stdout: string
+  stderr: string
+}
+
+export interface World {
+  oc: OpenCode
+  work: string
+  project: string
+  model: string
+  env: Record
+}
+
+/**
+ * A world for one run: OpenCode configured with the bay's server half and the model, everything under
+ * `work`. `extra` goes into the environment (a PATH with a fake `gh`, say).
+ */
+export async function world(
+  oc: OpenCode,
+  work: string,
+  plugin: string,
+  model: string,
+  extra: Record = {},
+): Promise {
+  const project = join(work, "project")
+  await Bun.write(
+    join(work, "config", "opencode", "opencode.json"),
+    JSON.stringify({ [oc.v2 ? "plugins" : "plugin"]: [plugin], model }),
+  )
+  const env = {
+    ...process.env,
+    XDG_CONFIG_HOME: join(work, "config"),
+    XDG_STATE_HOME: join(work, "state"),
+    XDG_DATA_HOME: join(work, "data"),
+    XDG_CACHE_HOME: join(work, "cache"),
+    COCKPIT_HOME: join(work, "cockpit"),
+    /** v2 places the session in $PWD's directory, not the spawn's cwd (trail-interface.md). */
+    PWD: project,
+    ...extra,
+  }
+  return { oc, work, project, model, env }
+}
+
+/** One `opencode run` turn, bounded; `session` continues that conversation. */
+export function turn(at: World, prompt: string, session?: string): Turn {
+  const cmd = [at.oc.bin, "run", ...(at.oc.v2 ? ["--standalone", "--auto"] : []), "-m", at.model]
+  /** stdin must not be an open pipe, or `opencode run` waits forever (trail-server.md). */
+  const result = Bun.spawnSync(
+    [...cmd, ...(session ? ["--session", session] : []), "--format", "json", prompt],
+    {
+      cwd: at.project,
+      env: at.env,
+      stdin: "ignore",
+      stdout: "pipe",
+      stderr: "pipe",
+      timeout: 300_000,
+    },
+  )
+  const stdout = result.stdout.toString()
+  const all = events(stdout)
+  const said = all
+    .filter((event) => event.type === "text")
+    .map((event) => String((event.part as { text?: string } | undefined)?.text ?? ""))
+    .join("\n")
+  if (process.env.MEASURE_DEBUG) console.log(stdout.slice(-4000), result.stderr.toString().slice(-2000))
+  return {
+    calls: toolCalls(stdout),
+    said,
+    session: all.find((event) => typeof event.sessionID === "string")?.sessionID,
+    stdout,
+    stderr: result.stderr.toString(),
+  }
+}
+
+/** Stops the Cockpit daemon a run spawned under its own home — and with it the shells it ran. */
+export function stopDaemon(cockpitHome: string): void {
+  try {
+    const pid = Number(readFileSync(join(cockpitHome, "cockpitd.pid"), "utf8").trim())
+    if (pid > 0) process.kill(pid, "SIGTERM")
+  } catch {
+    /** No daemon was started, or it has already gone. */
+  }
+}
+
+/** A run's text, short, on one line. */
+export const brief = (text: string, room = 200) => text.replace(/\s+/g, " ").slice(0, room)
+
+/** Runs one measurement `runs` times, each tried again while it measured nothing; exits 0 when all passed. */
+export async function measure(
+  title: string,
+  runs: number,
+  once: (index: number) => Promise,
+  attempts = 3,
+): Promise {
+  console.log(title)
+  let passed = 0
+  for (let i = 1; i <= runs; i++) {
+    let outcome = await once(i)
+    for (let attempt = 2; !outcome.measured && attempt <= attempts; attempt++) {
+      console.log(`run ${i}: the model did nothing this measures — trying again (${attempt}/${attempts})`)
+      for (const line of outcome.report) console.log(`  ${line}`)
+      outcome = await once(i)
+    }
+    if (outcome.ok) passed++
+    console.log(`run ${i}: ${outcome.ok ? "PASS" : outcome.measured ? "FAIL" : "NOTHING MEASURED"}`)
+    for (const line of outcome.report) console.log(`  ${line}`)
+  }
+  console.log(`${passed} of ${runs} passed`)
+  process.exit(passed === runs ? 0 : 1)
+}
diff --git a/scripts/pack-check.ts b/scripts/pack-check.ts
index d614b6f4..47a2ba83 100644
--- a/scripts/pack-check.ts
+++ b/scripts/pack-check.ts
@@ -276,6 +276,36 @@ try {
       }
     }
 
+    /**
+     * Trail alone: both halves, from what was installed. Its agent half is the one that matters most
+     * — `trail_add` is how anything gets into the trail — and no other bay's check imports it; its
+     * preview bin is named twice, with a shebang, like Status's.
+     */
+    if (bays.includes("@opencode-cockpit/trail")) {
+      const { default: server } = await import(Bun.resolveSync("@opencode-cockpit/trail/server", dir))
+      if (
+        server?.id !== "opencode-cockpit.trail" ||
+        typeof server.server !== "function" ||
+        typeof server.setup !== "function"
+      )
+        throw new Error(
+          `${install.name}: @opencode-cockpit/trail/server must export the plugin for both OpenCodes`,
+        )
+      if (install.packages.includes("@opencode-cockpit/trail")) {
+        const root = join(dir, "node_modules", "@opencode-cockpit", "trail")
+        const bins = (await Bun.file(join(root, "package.json")).json()).bin as Record
+        if (!bins.trail) throw new Error(`${install.name}: trail must declare a "trail" bin, for bunx`)
+        for (const [name, file] of Object.entries(bins)) {
+          const first = (await Bun.file(join(root, file)).text()).split("\n", 1)[0] ?? ""
+          if (!first.startsWith("#!")) throw new Error(`${install.name}: the ${name} bin has no shebang`)
+        }
+        for (const door of ["server.js", "tui.js"])
+          if (!existsSync(join(root, door)))
+            throw new Error(`${install.name}: trail ships no ${door} for OpenCode 2`)
+      }
+      console.log(`  ${install.name}: trail's agent half loads, its bin and OpenCode 2's doors are there`)
+    }
+
     // The statusline bay has no server half and no daemon: there is nothing further to run.
     if (!bays.includes("@opencode-cockpit/shell")) {
       console.log(`  ${install.name}: loads, compiled interface entry, authoring subpaths resolve`)
diff --git a/scripts/record.ts b/scripts/record.ts
index 1676ed8e..d9182574 100644
--- a/scripts/record.ts
+++ b/scripts/record.ts
@@ -13,6 +13,7 @@ import { copyFileSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node
 import { dirname, join, resolve } from "node:path"
 import { SerializeAddon } from "@xterm/addon-serialize"
 import { Terminal } from "@xterm/headless"
+import { FEATURES } from "../packages/opencode/src/features.ts"
 
 export interface Step {
   /** Keys to send, exactly as the terminal would receive them (see KEYS). */
@@ -78,9 +79,9 @@ export const KEYS = {
   tab: "\t",
   ctrlP: "\x10",
   ctrlC: "\x03",
-  /** OpenCode's leader is ctrl+x; cockpit binds o and i. */
+  /** OpenCode's leader is ctrl+x; cockpit binds o and j. */
   dock: "\x18o",
-  console: "\x18i",
+  console: "\x18j",
   up: "\x1b[A",
   down: "\x1b[B",
   slash: "/",
@@ -88,10 +89,56 @@ export const KEYS = {
 
 const root = resolve(import.meta.dir, "..")
 
+/**
+ * This checkout, packed and installed under the take's own directory, as a user would get it.
+ *
+ * OpenCode 1 loads `packages/opencode` straight from the checkout. OpenCode 2 refuses it: each
+ * package has the workspace's OpenTUI and Solid beside it, a second copy next to the host's (see
+ * scripts/dev-install.ts). A packed install has none of them — and lives in the throwaway directory,
+ * never in `~/.cockpit-dev`. Needs `bun run build` first, as the smoke does.
+ */
+function packed(work: string): string {
+  const install = join(work, "install")
+  const tarballs = join(install, "tarballs")
+  mkdirSync(tarballs, { recursive: true })
+  const run = (cmd: string[], cwd: string) => {
+    const done = Bun.spawnSync(cmd, { cwd, stdout: "pipe", stderr: "pipe" })
+    if (done.exitCode !== 0) throw new Error(`$ ${cmd.join(" ")}\n${done.stdout}\n${done.stderr}`)
+  }
+  for (const dir of ["protocol", "daemon", "client", "opencode", ...FEATURES]) {
+    run(["bun", "pm", "pack", "--destination", tarballs], join(root, "packages", dir))
+  }
+  const names = [...new Bun.Glob("*.tgz").scanSync(tarballs)]
+  const tarball = (dir: string) => {
+    const pattern = dir === "opencode" ? /^opencode-cockpit-\d/ : new RegExp(`^opencode-cockpit-${dir}-\\d`)
+    return `file:./tarballs/${names.find((name) => pattern.test(name)) as string}`
+  }
+  const scoped = (dir: string) => (dir === "opencode" ? "opencode-cockpit" : `@opencode-cockpit/${dir}`)
+  writeFileSync(
+    join(install, "package.json"),
+    JSON.stringify({
+      name: "tape",
+      private: true,
+      dependencies: Object.fromEntries(["opencode", ...FEATURES].map((dir) => [scoped(dir), tarball(dir)])),
+      overrides: Object.fromEntries(
+        ["protocol", "daemon", "client"].map((dir) => [scoped(dir), tarball(dir)]),
+      ),
+    }),
+  )
+  run(["npm", "install", "--no-audit", "--no-fund", "--cache", join(work, "npm-cache")], install)
+  return join(install, "node_modules", "opencode-cockpit")
+}
+
 async function record(tape: Tape): Promise {
   /** `OPENCODE=opencodeold bun scripts/record.ts …` records against another install, as the smoke does. */
   const opencode = Bun.which(process.env.OPENCODE ?? "opencode")
   if (!opencode) throw new Error("opencode binary not found; install OpenCode to record")
+  /** Which OpenCode this is decides where plugins are configured and how it is started, as in the smoke. */
+  const v2 = Bun.spawnSync([opencode, "--version"])
+    .stdout.toString()
+    .trim()
+    .replace(/^opencode\s+v?/, "")
+    .startsWith("2")
 
   const cols = tape.cols ?? 120
   const rows = tape.rows ?? 34
@@ -110,12 +157,14 @@ async function record(tape: Tape): Promise {
   const realAuth = join(process.env.HOME ?? "", ".local/share/opencode/auth.json")
   if (existsSync(realAuth)) copyFileSync(realAuth, join(data, "auth.json"))
 
-  const plugin = tape.plugin ?? join(root, "packages", "opencode")
+  const plugin = tape.plugin ?? (v2 ? packed(work) : join(root, "packages", "opencode"))
+  /** v1 reads `plugin` from opencode.json and tui.json; v2 reads `plugins` from opencode.json and cli.json. */
+  const key = v2 ? "plugins" : "plugin"
   writeFileSync(
     join(config, "opencode.json"),
-    JSON.stringify({ plugin: [plugin], ...(tape.model ? { model: tape.model } : {}) }),
+    JSON.stringify({ [key]: [plugin], ...(tape.model ? { model: tape.model } : {}) }),
   )
-  writeFileSync(join(config, "tui.json"), JSON.stringify({ plugin: [plugin] }))
+  writeFileSync(join(config, v2 ? "cli.json" : "tui.json"), JSON.stringify({ [key]: [plugin] }))
   if (tape.config) writeFileSync(join(project, ".cockpit.json"), JSON.stringify(tape.config, null, 2))
   for (const [name, content] of Object.entries(tape.files ?? {})) {
     const file = join(project, name)
@@ -156,8 +205,14 @@ async function record(tape: Tape): Promise {
   const mirror = new Terminal({ cols, rows, allowProposedApi: true })
   const serializer = new SerializeAddon()
   mirror.loadAddon(serializer)
+  /**
+   * One decoder for the whole stream: a chunk can end inside a character, and decoding each on its
+   * own turned a spinner's `⠋` or a `›` split across two of them into a pair of `�`.
+   */
+  const decoder = new TextDecoder("utf-8")
 
-  const proc = Bun.spawn([opencode], {
+  /** v2 would attach to the user's background service; a private server keeps the take to itself. */
+  const proc = Bun.spawn([opencode, ...(v2 ? ["--standalone"] : [])], {
     cwd: project,
     env: {
       ...process.env,
@@ -170,7 +225,8 @@ async function record(tape: Tape): Promise {
       cols,
       rows,
       data(_t: unknown, chunk: Uint8Array) {
-        const text = Buffer.from(chunk).toString("utf8")
+        const text = decoder.decode(chunk, { stream: true })
+        if (!text) return
         mirror.write(text)
         if (!recording) return
         const at = ((Date.now() - started) / 1000).toFixed(3)
@@ -200,6 +256,8 @@ async function record(tape: Tape): Promise {
 
   recording = false
   proc.kill("SIGKILL")
+  /** The shells' daemon outlives OpenCode by design; this one was the take's, and its path says so. */
+  Bun.spawnSync(["pkill", "-f", work])
   mirror.dispose()
 
   const header = JSON.stringify({
diff --git a/scripts/set-version.ts b/scripts/set-version.ts
index 3907951c..d80b5647 100644
--- a/scripts/set-version.ts
+++ b/scripts/set-version.ts
@@ -62,9 +62,13 @@ const docs = [
     .map((file) => join(root, file)),
   join(root, "site/src/data/landing.ts"),
 ]
-/** v1's `opencode plugin x@v`, v2's `opencode plugin add x@v`, and v2's `"package": "x@v"` entries. */
+/**
+ * v1's `opencode plugin x@v`, v2's `opencode plugin add x@v`, v2's `"package": "x@v"` entries, and a
+ * plain entry in a `"plugins": ["x@v", …]` list. Only list entries (after `[` or `,`), so a doctor
+ * sample quoting an old version in a sentence keeps it.
+ */
 const pinned =
-  /((?:opencode plugin (?:add )?|"package": ")(?:opencode-cockpit|@opencode-cockpit\/[a-z]+))@\d+\.\d+\.\d+(?:-[\w.]+)?/g
+  /((?:opencode plugin (?:add )?|"package": "|\[\s*"|,\s*")(?:opencode-cockpit|@opencode-cockpit\/[a-z]+))@\d+\.\d+\.\d+(?:-[\w.]+)?/g
 for (const file of docs) {
   if (!existsSync(file)) continue
   const text = await Bun.file(file).text()
diff --git a/scripts/tui-smoke.ts b/scripts/tui-smoke.ts
index 4179cc62..7825d148 100644
--- a/scripts/tui-smoke.ts
+++ b/scripts/tui-smoke.ts
@@ -10,7 +10,7 @@
  * pack check cannot. Needs the `opencode` binary, so it stays out of CI.
  */
 
-import { mkdtempSync, rmSync } from "node:fs"
+import { mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs"
 import { join } from "node:path"
 import { Terminal } from "@xterm/headless"
 import { FEATURES } from "../packages/opencode/src/features.ts"
@@ -50,7 +50,7 @@ const run = (cmd: string[], cwd: string) => {
  */
 function agentTurn(env: Record) {
   const prompt = [
-    "Call the tool shell_start with command 'echo AGENT-SHELL-OK' and description 'agent probe', then call review_list, then call subagents_list.",
+    "Call the tool shell_start with command 'echo AGENT-SHELL-OK' and description 'agent probe', then call review_list, then call subagents_list, then call cockpit_settings.",
     "Your system prompt has heading lines starting with '## Background shells', '## Review comments' and '## Subagents'.",
     "Quote all three heading lines exactly in your reply.",
   ].join(" ")
@@ -61,12 +61,17 @@ function agentTurn(env: Record) {
     "-m",
     "opencode/space-bunny-free",
   ]
+  /** Bounded, and stdin closed: an open stdin or a permission prompt makes `opencode run` wait forever. */
   const result = Bun.spawnSync([...args, "--format", "json", prompt], {
     cwd: project,
     env,
+    stdin: "ignore",
     stdout: "pipe",
     stderr: "pipe",
+    timeout: 300_000,
   })
+  if (result.exitCode === null || result.signalCode)
+    throw new Error(`the agent turn never finished (5 min):\n${result.stdout.toString().slice(-3000)}`)
   const events = result.stdout
     .toString()
     .split("\n")
@@ -95,7 +100,7 @@ function agentTurn(env: Record) {
     .map((event) => (event.part as unknown as { text: string }).text)
     .join("\n")
   const report = `${result.stdout}\n${result.stderr}`.slice(-3000)
-  for (const name of ["shell_start", "review_list", "subagents_list"]) {
+  for (const name of ["shell_start", "review_list", "subagents_list", "cockpit_settings"]) {
     if (!called.includes(name)) throw new Error(`the agent never completed ${name}:\n${report}`)
   }
   for (const heading of ["## Background shells", "## Review comments", "## Subagents"]) {
@@ -105,7 +110,8 @@ function agentTurn(env: Record) {
 
 const cols = Number(process.env.SMOKE_COLS) || 150
 const rows = 40
-const term = new Terminal({ cols, rows, allowProposedApi: true })
+/** One per OpenCode started: the second run (AGENT=1) draws on a clean screen of its own. */
+let term = new Terminal({ cols, rows, allowProposedApi: true })
 const screen = async () => {
   await new Promise((done) => term.write("", done))
   const buffer = term.buffer.active
@@ -114,14 +120,127 @@ const screen = async () => {
     (_, y) => buffer.getLine(buffer.baseY + y)?.translateToString(true) ?? "",
   ).join("\n")
 }
+/** The sidebar's half of a screen. */
+const rightHalf = (text: string) =>
+  text
+    .split("\n")
+    .map((line) => line.slice(Math.floor(cols / 2)))
+    .join("\n")
+/**
+ * Which of `patterns` showed on some screen within `ms` — not all on one: a turn scrolls the first
+ * out of view before the last arrives. Done as soon as every one has.
+ */
+const seen = async (ms: number, patterns: readonly RegExp[]) => {
+  const found = new Set()
+  for (const end = Date.now() + ms; found.size < patterns.length && Date.now() < end; await Bun.sleep(250)) {
+    const text = await screen()
+    for (const [at, pattern] of patterns.entries()) if (pattern.test(text)) found.add(at)
+  }
+  return {
+    all: found.size === patterns.length,
+    missing: patterns.filter((_, at) => !found.has(at)),
+    last: await screen(),
+  }
+}
+/** Reads the screen until `done` says so, or `ms` runs out; the last screen either way. */
+const until = async (ms: number, done: (text: string) => boolean) => {
+  let text = await screen()
+  for (const end = Date.now() + ms; !done(text) && Date.now() < end; text = await screen()) {
+    await Bun.sleep(250)
+  }
+  return text
+}
+/**
+ * A block's heading in the sidebar, and the first row under it (past the heading's air) — read in
+ * the heading's own column, so the conversation beside it cannot answer for the block.
+ */
+const under = (text: string, heading: string): string | undefined => {
+  const lines = text.split("\n")
+  const right = Math.floor(cols / 2)
+  for (const [y, line] of lines.entries()) {
+    const at = line.slice(right).search(new RegExp(`(^|\\s)${heading}(\\s|$)`))
+    if (at < 0) continue
+    const x = right + at + (line[right + at] === " " ? 1 : 0)
+    const next = lines.slice(y + 1, y + 4).find((row) => row.slice(x).trim())
+    return next?.slice(x).trim()
+  }
+  return undefined
+}
+/**
+ * A subagent's row in the sidebar: its status glyph, then the agent's name in the block's agent column,
+ * eight cells at most, so `explore` whole (the old `expl…`/`exp…` still read, for an older build).
+ */
+const EXPLORE_ROW = /[�○⠋⠙⠹⠸⠼⠴⠦⠧⠇�] exp(lore|l?o?…) /
+/** The Status table's token row, which only a conversation with a reply in it fills. */
+const TOKENS_ROW = / tokens [\d.]+k? · \d+%/
+/** The line `/cockpit-setup` sends: once it is in the conversation, the command ran. */
+const SETUP_LINE = "Use the cockpit-setup skill to help me set up Cockpit."
+/** The skill loaded, and its first step taken — the tool it reads the live state with. Either version. */
+const SETUP_SKILL_USED = [/Skill "cockpit-setup"/, /[⚙›] cockpit_settings/]
+/** `/statusline`'s line, which says the new name first, and the skill it names, loaded. */
+const STATUS_SKILL_USED = [
+  /\/statusline is now \/status-setup\. Use the status-setup skill/,
+  /Skill "status-setup"/,
+]
+/** A prompt sent while the agent answers, waiting its turn: OpenCode 1's tag, OpenCode 2's line. */
+const QUEUED = /QUEUED|1 queued · Use the cockpit-setup skill/
+/** A minimal stdio MCP server: answers initialize, lists one tool, runs it. Run with Bun. */
+const OK_MCP = `import { createInterface } from "node:readline"
+const send = (msg) => process.stdout.write(JSON.stringify(msg) + "\\n")
+createInterface({ input: process.stdin }).on("line", (line) => {
+  let req
+  try { req = JSON.parse(line) } catch { return }
+  if (req.id === undefined) return
+  if (req.method === "initialize")
+    return send({ jsonrpc: "2.0", id: req.id, result: {
+      protocolVersion: req.params?.protocolVersion ?? "2024-11-05",
+      capabilities: { tools: {} },
+      serverInfo: { name: "ok-test", version: "1.0.0" },
+    } })
+  if (req.method === "tools/list")
+    return send({ jsonrpc: "2.0", id: req.id, result: { tools: [{
+      name: "ping", description: "Answers pong.", inputSchema: { type: "object", properties: {} },
+    }] } })
+  if (req.method === "tools/call")
+    return send({ jsonrpc: "2.0", id: req.id, result: { content: [{ type: "text", text: "pong" }] } })
+  send({ jsonrpc: "2.0", id: req.id, result: {} })
+})
+`
+
+/**
+ * Build and pack under a lock. `build` empties every `dist/` before it compiles, so a second run
+ * (v1 and v2 side by side, or a `dev:install`) packing at that moment shipped a bay without its
+ * files: OpenCode said "1 plugin failed" and Trust's commands were missing. A directory is the lock
+ * (`mkdir` is atomic); one older than ten minutes is left over from a killed run.
+ */
+const buildLock = join(root, "node_modules", ".cockpit-build.lock")
+async function withBuildLock(work: () => void): Promise {
+  for (;;) {
+    try {
+      mkdirSync(buildLock)
+      break
+    } catch {
+      const age = Date.now() - (statSync(buildLock, { throwIfNoEntry: false })?.mtimeMs ?? Date.now())
+      if (age > 600_000) rmSync(buildLock, { recursive: true, force: true })
+      else await Bun.sleep(500)
+    }
+  }
+  try {
+    work()
+  } finally {
+    rmSync(buildLock, { recursive: true, force: true })
+  }
+}
 
 try {
-  run(["bun", "run", "build"], root)
   const tarballs = join(work, "tarballs")
-  /** The plumbing, then every bay — from the bundle's own list, so a new one cannot be left out. */
-  for (const dir of ["protocol", "daemon", "client", "opencode", ...FEATURES]) {
-    run(["bun", "pm", "pack", "--destination", tarballs], join(root, "packages", dir))
-  }
+  await withBuildLock(() => {
+    run(["bun", "run", "build"], root)
+    /** The plumbing, then every bay — from the bundle's own list, so a new one cannot be left out. */
+    for (const dir of ["protocol", "daemon", "client", "opencode", ...FEATURES]) {
+      run(["bun", "pm", "pack", "--destination", tarballs], join(root, "packages", dir))
+    }
+  })
   const names = [...new Bun.Glob("*.tgz").scanSync(tarballs)]
   const file = (prefix: string) => `file:${join(tarballs, names.find((n) => n.startsWith(prefix)) as string)}`
   await Bun.write(
@@ -136,6 +255,7 @@ try {
         "@opencode-cockpit/updater": file("opencode-cockpit-updater-"),
         "@opencode-cockpit/subagents": file("opencode-cockpit-subagents-"),
         "@opencode-cockpit/trust": file("opencode-cockpit-trust-"),
+        "@opencode-cockpit/trail": file("opencode-cockpit-trail-"),
       },
       overrides: {
         "@opencode-cockpit/protocol": file("opencode-cockpit-protocol-"),
@@ -161,8 +281,17 @@ try {
 
   // The plugins must live under node_modules: that is what disables OpenCode's Solid transform.
   const bay = (name: string) => join(install, "node_modules", "@opencode-cockpit", name)
-  const tuiBays = [bay("shell"), bay("status"), bay("review"), bay("updater"), bay("subagents"), bay("trust")]
-  const serverBays = [bay("shell"), bay("review"), bay("subagents")]
+  const tuiBays = [
+    bay("shell"),
+    bay("status"),
+    bay("review"),
+    bay("updater"),
+    bay("subagents"),
+    bay("trail"),
+    bay("trust"),
+  ]
+  /** Status's agent side carries only the `status-setup` skill and its commands. */
+  const serverBays = [bay("shell"), bay("status"), bay("review"), bay("subagents"), bay("trail")]
   /**
    * v1 reads `plugin` from opencode.json and tui.json; v2 reads `plugins` from opencode.json and
    * cli.json (docs/opencode/v2.md). The same packages go in either way.
@@ -185,6 +314,13 @@ try {
         [key]: plugins,
         /** A model that needs no key, for the turns AGENT=1 runs inside the interface. */
         ...(process.env.AGENT && name === "opencode.json" ? { model: "opencode/space-bunny-free" } : {}),
+        /**
+         * The setup skills read and write Cockpit's config, outside the project: a prompt it would
+         * wait on forever here. OpenCode 2 is started with `--auto` for the same reason.
+         */
+        ...(process.env.AGENT && !v2 && name === "opencode.json"
+          ? { permission: { external_directory: "allow" } }
+          : {}),
       }),
     )
   }
@@ -192,11 +328,18 @@ try {
    * A statusline whose value has to come from somewhere the plugin cannot fake: a literal marker
    * proves the line drew at all, and a command segment proves the whole pipeline -- spawn, parse,
    * repaint -- works from a published build.
+   *
+   * The global file carries the section's name from before 0.9, `statusline`: it is no longer read,
+   * and Status has to say so in a `!` row instead of drawing as if nothing had been written.
    */
+  await Bun.write(
+    join(config, "opencode-cockpit", "config.json"),
+    JSON.stringify({ statusline: { preset: "minimal" } }),
+  )
   await Bun.write(
     join(project, ".cockpit.json"),
     JSON.stringify({
-      statusline: {
+      status: {
         surface: "bottom",
         segments: [
           { type: "text", value: "STATUSLINE-DREW" },
@@ -204,6 +347,8 @@ try {
         ],
         commands: { smoke: { run: "printf 'COMMAND-RAN'", intervalMs: 250 } },
       },
+      /** An old name in Review's section: the pane has to say so in a `!` row. */
+      review: { sidebarOrder: 3 },
     }),
   )
 
@@ -216,17 +361,22 @@ try {
     TERM: "xterm-256color",
   }
   /** v2 would attach to the user's background service; a private server keeps the run to itself. */
-  const proc = Bun.spawn(v2 ? [opencode, "--standalone"] : [opencode], {
-    cwd: project,
-    env,
-    terminal: {
-      cols,
-      rows,
-      data: (_t: unknown, chunk: Uint8Array) => term.write(chunk.slice()),
-    },
-  } as Parameters[1]) as ReturnType & {
-    terminal: { write(data: string): void }
+  const launch = (cwd: string, args: string[] = []) => {
+    const screenOf = new Terminal({ cols, rows, allowProposedApi: true })
+    term = screenOf
+    return Bun.spawn([opencode, ...(v2 ? ["--standalone"] : []), ...args], {
+      cwd,
+      env,
+      terminal: {
+        cols,
+        rows,
+        data: (_t: unknown, chunk: Uint8Array) => screenOf.write(chunk.slice()),
+      },
+    } as Parameters[1]) as ReturnType & {
+      terminal: { write(data: string): void }
+    }
   }
+  let proc = launch(project)
   const type = async (keys: string, waitMs: number) => {
     proc.terminal.write(keys)
     await Bun.sleep(waitMs)
@@ -247,7 +397,7 @@ try {
    * The console, which was never opened here — so a crash on open was never caught here either.
    * Pressing `?` walks both halves of the key row: the keys that act, and the rest in the panel.
    */
-  await type("\x18i", 3000) // ctrl+x i
+  await type("\x18j", 3000) // ctrl+x j
   const consoleScreen = await screen()
   await type("?", 1500)
   const consoleDetails = await screen()
@@ -257,7 +407,7 @@ try {
    * Full screen, which neither version's run opened before — so on OpenCode 2 it could draw nothing
    * and still pass. `w` swaps the dialog for it and is remembered, so it is swapped back before leaving.
    */
-  await type("\x18i", 3000)
+  await type("\x18j", 3000)
   await type("w", 2500)
   const fullScreen = await screen()
   await type("w", 1500)
@@ -308,7 +458,20 @@ try {
         .some((line) => line.includes(title))
     )
   }
+  /**
+   * The screen at rest, nothing open over it — taken before the first command runs. Each step waits
+   * for the screen to come back to it: a command's toast ("No finished subagents to clear") still up
+   * when `ctrl+p` was typed covered the palette, and the next step failed on v2. A line or two may
+   * differ (a tip, a toggled sidebar); a toast or a dialog is more. Timed out, it goes on anyway, and
+   * the palette check below says what was in the way.
+   */
+  let quiet = ""
+  const atRest = (text: string) => {
+    const was = quiet.split("\n")
+    return text.split("\n").filter((line, y) => line !== was[y]).length <= 2
+  }
   const palette = async (title: string, waitMs = 2500) => {
+    await until(12_000, atRest)
     await type("\x10", 1000) // ctrl+p
     await type(title, 1500)
     const listed = await screen()
@@ -320,11 +483,13 @@ try {
   const found: [string, string][] = []
   for (const [name, title] of [
     ["shell", "Start a background shell"],
-    ["status", "Ask the agent to customise the statusline"],
+    ["status", "Ask the agent to set up the status bay"],
     ["review", "Open or close the changes"],
     ["updater", "Update plugins"],
     ["subagents", "Open the subagents"],
+    ["trail", "Show what this conversation made"],
     ["trust", "Show what Trust answers for you"],
+    ["setup", "Ask the agent to set up Cockpit"],
   ] as const) {
     await type("\x10", 1000)
     await type(`cockpit ${name}`, 1500)
@@ -332,6 +497,13 @@ try {
     if (!listed.includes(title)) found.push([`"cockpit ${name}" never listed "${title}"`, listed])
     await type("\x1b", 800)
   }
+  /** At rest: a beat after the last search's palette closed, the same screen twice running (or 5s). */
+  await Bun.sleep(1000)
+  quiet = await until(5000, (text) => {
+    const same = text === quiet
+    quiet = text
+    return same
+  })
   /** Each surface is closed before the next: an open review takes the keys, `ctrl+p` included. */
   const ranReview = await palette("Toggle the changes full screen", 3000)
   await type("\x1b", 1200)
@@ -345,6 +517,9 @@ try {
   await type("\x1b", 1200)
   const ranUpdater = await palette("Update plugins", 4000)
   await type("\x1b", 1200)
+  /** From home there is no conversation, so Trail opens on the project's records: none, and says so. */
+  const ranTrail = await palette("Show what this conversation made", 2500)
+  await type("\x1b", 1200)
 
   /**
    * The updater's dialog, opened by its slash name and by the old one it replaced — two slash names
@@ -363,7 +538,7 @@ try {
     )
     await type("\r", 1000)
     let sidebar = ""
-    for (let i = 0; i < 45 && !/[�○⠋⠙⠹⠸⠼⠴⠦⠧⠇�] explore /.test(sidebar); i++) {
+    for (let i = 0; i < 45 && !EXPLORE_ROW.test(sidebar); i++) {
       await Bun.sleep(2000)
       sidebar = await screen()
     }
@@ -371,17 +546,28 @@ try {
     /** Found again before every click: the blocks above it (the statusline's) grow as the turn runs. */
     const clickSubagent = async () => {
       const lines = (await screen()).split("\n")
-      const y = lines.findIndex((line) => /[�○⠋⠙⠹⠸⠼⠴⠦⠧⠇�] explore /.test(line.slice(Math.floor(cols / 2))))
+      const right = Math.floor(cols / 2)
+      const y = lines.findIndex((line) => EXPLORE_ROW.test(line.slice(right)))
       if (y < 0) throw new Error(`the sidebar never showed the subagent:\n${lines.join("\n")}`)
-      const x = (lines[y] as string).lastIndexOf(" explore ") + 3
+      const x = right + ((lines[y] as string).slice(right).search(EXPLORE_ROW) as number) + 3
       proc.terminal.write(`\x1b[<0;${x + 1};${y + 1}M`)
       await Bun.sleep(80)
       await type(`\x1b[<0;${x + 1};${y + 1}m`, 2500)
     }
     await clickSubagent()
     const full = await screen()
-    /** The cursor onto the last item and open it, then a message typed into the pane, not a dialog. */
-    await type("k", 600)
+    /** `?` swaps the run for every key the pane takes, and `?` again brings the run back. */
+    await type("?", 1200)
+    const keys = await screen()
+    await type("?", 1000)
+    /**
+     * The cursor onto the last item and open it, then a message typed into the pane, not a dialog.
+     * Each key waits on what it needs on screen rather than a fixed time: the run's first item can
+     * take a while to arrive on a slow turn, and `enter` with no cursor yet opens nothing.
+     */
+    await until(30_000, (text) => /[›⌄◇◆] /.test(rightHalf(text)))
+    await type("k", 300)
+    await until(5000, (text) => text.includes("▌ "))
     await type("\r", 1200)
     const toggled = await screen()
     await type("m", 600)
@@ -418,19 +604,139 @@ try {
     await type("x", 1500)
     const removed = await screen()
     await type("q", 1000)
-    return { sidebar, full, toggled, typing, slashed, finished, pasted, relayed, conversation, removed }
+    return { sidebar, full, keys, toggled, typing, slashed, finished, pasted, relayed, conversation, removed }
   }
 
-  const slash = async (name: string) => {
-    await type(`/${name}`, 1200)
-    await type("\r", 4000)
-    const drawn = await screen()
+  /** `ready`: read as soon as it shows, for what does not stay — a toast the next one replaces. */
+  const slash = async (name: string, ready?: (text: string) => boolean) => {
+    await type(`/${name}`, 300)
+    /** `enter` once the popup offers the name: with the agent busy it can take longer to list. */
+    await until(4000, (text) => new RegExp(`/${name}\\s{2,}\\S`).test(text))
+    await type("\r", ready ? 0 : 4000)
+    const drawn = ready ? await until(4000, ready) : await screen()
     await type("\x1b", 1000)
     return drawn
   }
+  /**
+   * A command the agent side ships, which OpenCode runs as its own: on OpenCode 1 `enter` on the popup
+   * first completes the name into the prompt, and a second `enter` sends it (measured, both versions).
+   */
+  const shipped = async (name: string) => {
+    await type(`/${name}`, 300)
+    await until(4000, (text) => new RegExp(`/${name}\\s{2,}\\S`).test(text))
+    await type("\r", 1200)
+    if (new RegExp(`┃\\s+/${name}\\s*$`, "m").test(await screen())) await type("\r", 0)
+  }
   const updater = await slash("plugins-update")
   const subagents = process.env.AGENT ? await subagentsInTheInterface() : undefined
+  /**
+   * The setup commands' slash names, shipped by the agent side, offered in the popup as they are typed
+   * — once each: the interface's palette entries for them carry no slash name. Only listed here, not
+   * run: running one asks a model, and a run without AGENT=1 stays offline.
+   */
+  const popup = async (typed: string) => {
+    await type("\x15", 300)
+    await type(typed, 1500)
+    const listed = await screen()
+    await type("\x15", 300)
+    await type("\x1b", 800)
+    return listed
+  }
+  const popups = {
+    "/cockpit-setup": await popup("/cockpit-se"),
+    "/status-setup": await popup("/status-se"),
+  }
+  /**
+   * AGENT=1: Status's command under its old name, kept for a release as a command of its own whose
+   * line says the new name first, and the skill it names loaded. In the conversation the subagent run
+   * left open, last, because it starts a turn. `/status-setup` sends the same line without the note.
+   */
+  const status = process.env.AGENT
+    ? await (async () => {
+        /** Whatever an earlier step left in the prompt goes first, or the name is typed after it. */
+        await type("\x15", 300)
+        await shipped("statusline")
+        const old = await seen(120_000, STATUS_SKILL_USED)
+        /** The skill asks a question next; `esc` dismisses it so nothing is left waiting. */
+        await type("\x1b", 1000)
+        return { old }
+      })()
+    : undefined
   proc.kill("SIGKILL")
+
+  /**
+   * AGENT=1: a second OpenCode, in a project nothing has happened in. `/cockpit-setup` from home has
+   * to open a conversation, and the agent there has to load the `cockpit-setup` skill and call
+   * `cockpit_settings` — the skill's first step. Run again while the agent answers, it has to queue
+   * behind the reply rather than cut it off. With the conversation open the sidebar draws: every
+   * block that lists something has to say it is there while it is empty — the heading and `none yet`
+   * — and the Status table, which nothing configures here, has to be the default surface, with the
+   * global file's old `statusline` named in a `!` row in the sidebar.
+   */
+  const presence = async () => {
+    const fresh = join(work, "fresh")
+    await Bun.write(join(fresh, "README.md"), "fresh\n")
+    for (const cmd of [
+      ["git", "init", "-q", "-b", "main"],
+      ["git", "config", "user.email", "smoke@example.com"],
+      ["git", "config", "user.name", "Smoke"],
+      ["git", "add", "-A"],
+      ["git", "commit", "-qm", "fresh"],
+    ]) {
+      run(cmd, fresh)
+    }
+    /** The skill has the agent read Cockpit's config, outside the project: nothing may wait on a prompt. */
+    proc = launch(fresh, v2 ? ["--auto"] : [])
+    await Bun.sleep(14_000)
+    await shipped("cockpit-setup")
+    const opened = await until(20_000, (text) => text.includes(SETUP_LINE))
+    /** Again, while the agent is still answering the first. */
+    await type("\x15", 300)
+    await shipped("cockpit-setup")
+    const queued = await until(8000, (text) => QUEUED.test(text))
+    const used = await seen(180_000, SETUP_SKILL_USED)
+    const drawn = await until(60_000, (text) => TOKENS_ROW.test(rightHalf(text)))
+    await Bun.sleep(3000)
+    const settled = await screen()
+    proc.kill("SIGKILL")
+    return { opened, used, queued, drawn: TOKENS_ROW.test(rightHalf(settled)) ? settled : drawn }
+  }
+  const fresh = process.env.AGENT ? await presence() : undefined
+
+  /**
+   * Status's `diagnostics` row in the sidebar table, on both versions (#34: OpenCode 2 hands a
+   * server's status as `{ status: "connected" }`, and every healthy server was flagged). A project
+   * with two MCP servers: a small stdio server that answers, and one whose command does not exist.
+   * The table — the default surface, nothing configures it here — draws in a conversation, so one
+   * short turn is sent (a free model; the only turn outside AGENT=1). Then, with the servers given
+   * time to connect or fail: no `! ok-test`, and `! broken-test` drawn, which proves the row was live.
+   * One `opencode.json` for both: 2.0 reads `mcp.servers`, 1.18 the flat keys (2.0 ignored a .jsonc).
+   */
+  const mcpStatus = async () => {
+    const at = join(work, "mcp")
+    const okMcp = join(work, "ok-mcp.ts")
+    await Bun.write(okMcp, OK_MCP)
+    const servers = {
+      "ok-test": { type: "local", command: [process.execPath, okMcp] },
+      "broken-test": { type: "local", command: ["cockpit-does-not-exist"] },
+    }
+    await Bun.write(
+      join(at, "opencode.json"),
+      JSON.stringify({ model: "opencode/space-bunny-free", mcp: { servers, ...servers } }),
+    )
+    await Bun.write(join(at, "README.md"), "mcp\n")
+    proc = launch(at)
+    await Bun.sleep(14_000)
+    await type("Reply with just the word hi.", 400)
+    await type("\r", 1000)
+    const drawn = await until(90_000, (text) => /! broken-test/.test(rightHalf(text)))
+    /** A healthy server that is flagged at all may be flagged only once it has connected. */
+    await Bun.sleep(8000)
+    const settled = await screen()
+    proc.kill("SIGKILL")
+    return { drawn, settled }
+  }
+  const mcp = await mcpStatus()
   // `SMOKE_SHOW=1 bun run smoke:tui` prints the updater's frame: a marker proves it drew, not how.
   if (process.env.SMOKE_SHOW) console.log(updater)
 
@@ -446,10 +752,29 @@ try {
   ] as const) {
     if (!second.includes(marker)) throw new Error(`${what}:\n${second}`)
   }
+  if (!second.includes(`! settings: "statusline" is no longer read`))
+    throw new Error(`Status never named the old "statusline" section:\n${second}`)
+  for (const [name, listed] of Object.entries(popups)) {
+    /** Once: the shipped command's row, and no second one from the interface. */
+    /** A row: at the popup's edge, the name, a gap on the same line, its description. */
+    const rows = listed.match(new RegExp(`┃ ${name} {2,}\\S`, "g")) ?? []
+    if (rows.length !== 1)
+      throw new Error(`the slash popup offered ${name} ${rows.length} times, not once:\n${listed}`)
+  }
+  if (status && !status.old.all)
+    throw new Error(
+      `/statusline never ran the status-setup skill with its new name said (missing ${status.old.missing.join(", ")}):\n${status.old.last}`,
+    )
+
+  for (const marker of ["Nothing recorded in this project yet.", "[esc] Close"]) {
+    if (!ranTrail.includes(marker))
+      throw new Error(`the palette's Trail never drew "${marker}":\n${ranTrail}`)
+  }
 
   for (const [what, marker] of [
     ["the review panel never drew", "review"],
     ["the review panel drew no diff", "SMOKE-REVIEW"],
+    ["the review panel never named its old setting", '! settings: "review.sidebarOrder"'],
   ] as const) {
     if (!review.includes(marker)) throw new Error(`${what}:\n${review}`)
   }
@@ -484,6 +809,9 @@ try {
       ["the sidebar never showed the Subagents block", subagents.sidebar, "Subagents"],
       ["a click on the subagent never opened its full screen", subagents.full, "EXPLORE"],
       ["the full screen never drew its keys", subagents.full, "[m] Message"],
+      ["the full screen never offered every key", subagents.full, "[?] Keys"],
+      ["? never showed every key", subagents.keys, "KEYS"],
+      ["the keys screen never said how back", subagents.keys, "[esc] Hide Keys"],
       ["enter never opened the selected item", subagents.toggled, "▌ "],
       ["m never opened the message input in the pane", subagents.typing, "┃ hello there"],
       ["/subagents never opened the full screen", subagents.slashed, "[m] Message"],
@@ -502,13 +830,25 @@ try {
         throw new Error(`the main conversation never showed the relayed exchange:\n${subagents.conversation}`)
     } else console.log("relay not checked: the subagent had not finished when the message was sent")
     const asked = subagents.removed.includes("Press x again")
-    const listed = /[�○⠋⠙⠹⠸⠼⠴⠦⠧⠇�] explore /.test(subagents.removed)
+    const listed = EXPLORE_ROW.test(subagents.removed)
     if (!asked && listed)
       throw new Error(`x neither removed the subagent nor asked to stop it:\n${subagents.removed}`)
   }
 
   /** A plugin OpenCode could not load says so in the footer, whichever half it was. */
-  for (const text of [first, second, consoleScreen, fullScreen, review, updater, ...Object.values(ran)]) {
+  for (const text of [
+    first,
+    second,
+    consoleScreen,
+    fullScreen,
+    review,
+    updater,
+    ranTrail,
+    ...Object.values(ran),
+    ...Object.values(popups),
+    ...(fresh ? [fresh.drawn] : []),
+    mcp.settled,
+  ]) {
     if (/plugins? failed/.test(text)) throw new Error(`OpenCode could not load a plugin:\n${text}`)
   }
 
@@ -521,9 +861,69 @@ try {
       `the panel froze: still at tick ${firstMax} after 4s (published JSX not Solid-compiled?)\n${second}`,
     )
   }
+  if (process.env.SMOKE_SHOW) console.log(mcp.settled)
+  if (!/! broken-test/.test(rightHalf(mcp.drawn)) || !/! broken-test/.test(rightHalf(mcp.settled)))
+    throw new Error(`the Status table never flagged the broken MCP server:\n${mcp.settled}`)
+  for (const text of [mcp.drawn, mcp.settled]) {
+    if (/!\s+ok-test/.test(text)) throw new Error(`the Status table flagged a healthy MCP server:\n${text}`)
+  }
+  if (fresh) {
+    const { opened, used, queued, drawn } = fresh
+    if (process.env.SMOKE_SHOW) console.log(drawn)
+    if (!opened.includes(SETUP_LINE))
+      throw new Error(`/cockpit-setup from home never opened a conversation with its line:\n${opened}`)
+    if (!used.all)
+      throw new Error(
+        `/cockpit-setup's agent never used the skill (missing ${used.missing.join(", ")}):\n${used.last}`,
+      )
+    if (!QUEUED.test(queued))
+      throw new Error(`/cockpit-setup while the agent answered never queued:\n${queued}`)
+    for (const heading of ["Subagents", "Shells", "Trail"]) {
+      if (!under(drawn, heading)?.startsWith("none yet"))
+        throw new Error(`the sidebar never drew "${heading}" with "none yet" under it:\n${drawn}`)
+    }
+    if (!TOKENS_ROW.test(rightHalf(drawn)))
+      throw new Error(`the sidebar never drew the Status table's tokens row:\n${drawn}`)
+    if (!rightHalf(drawn).includes(`! settings: "statusline" is no longer`))
+      throw new Error(`the sidebar never named the old "statusline" section:\n${drawn}`)
+  }
   if (process.env.AGENT) agentTurn(env)
+  /**
+   * AGENT=1: each bay's measurement against its installed server half — the guidance has to change
+   * what a real turn does, without the prompt naming the bay. Trail: a turn that opens a PR (with a
+   * fake `gh`) records it. Shell: "start the dev server" goes in shell_start, and a second
+   * conversation reuses it rather than starting another. Review: a waiting comment is read with
+   * review_list and answered with review_reply.
+   */
+  if (process.env.AGENT) {
+    for (const [name, what] of [
+      ["trail", "Trail's"],
+      ["shell", "Shell's"],
+      ["review", "Review's"],
+    ] as const) {
+      /**
+       * Trail's on a free model misses about one turn in six, so it is two of three; the others
+       * still one of one.
+       */
+      const runs = name === "trail" ? ["--runs", "3", "--pass", "2"] : ["--runs", "1"]
+      const measured = Bun.spawnSync(
+        ["bun", join(root, `packages/${name}/measure/agent.ts`), "--plugin", bay(name), ...runs],
+        {
+          cwd: root,
+          env: { ...process.env, OPENCODE: opencode },
+          stdin: "ignore",
+          stdout: "pipe",
+          stderr: "pipe",
+          /** Three attempts, of up to two five-minute turns each, inside each run of the measurement. */
+          timeout: name === "trail" ? 6_000_000 : 2_000_000,
+        },
+      )
+      if (measured.exitCode !== 0)
+        throw new Error(`${what} measurement failed:\n${measured.stdout}\n${measured.stderr}`.slice(-3000))
+    }
+  }
   console.log(
-    `tui smoke passed: panel live, tick ${firstMax} → ${secondMax}; console and its keys drew; full screen drew; statusline drew; review drew its diff; updater answered its slash name; every bay found under "cockpit" in the palette, and its commands ran from there${subagents ? "; a subagent showed in the sidebar and opened full screen, by click and by /subagents" : ""}${process.env.AGENT ? "; an agent called both bays' tools and was told about them" : ""}`,
+    `tui smoke passed: panel live, tick ${firstMax} → ${secondMax}; console and its keys drew; full screen drew; statusline drew and named its old section; review drew its diff and named its old setting; updater answered its slash name; trail opened empty; every bay and /cockpit-setup found under "cockpit" in the palette, and the commands ran from there; /cockpit-setup and /status-setup offered once each as they were typed${subagents ? "; a subagent showed in the sidebar and opened full screen, by click and by /subagents, with its keys; /statusline ran the status-setup skill, saying its new name" : ""}${fresh ? "; /cockpit-setup from home opened a conversation whose agent loaded the cockpit-setup skill and called cockpit_settings, and queued behind the reply; an empty sidebar said none yet in every block, under the Status table" : ""}; the Status table flagged the broken MCP server and not the healthy one${process.env.AGENT ? "; an agent called the bays' tools and was told about them; Trail's, Shell's and Review's measurements passed" : ""}`,
   )
 } finally {
   /** KEEP=1 leaves the install and project behind, to inspect what a run actually loaded. */
diff --git a/site/astro.config.mjs b/site/astro.config.mjs
index e82d4836..11ccfa83 100644
--- a/site/astro.config.mjs
+++ b/site/astro.config.mjs
@@ -75,6 +75,10 @@ export default defineConfig({
           label: "Subagents",
           items: [{ label: "Overview", slug: "subagents/overview" }],
         },
+        {
+          label: "Trail",
+          items: [{ label: "Overview", slug: "trail/overview" }],
+        },
         {
           label: "Trust",
           items: [{ label: "Overview", slug: "trust/overview" }],
diff --git a/site/src/content/docs/configuration.md b/site/src/content/docs/configuration.md
index c0c496e7..cf4e988d 100644
--- a/site/src/content/docs/configuration.md
+++ b/site/src/content/docs/configuration.md
@@ -1,110 +1,300 @@
 ---
 title: Configuration
-description: One file for both halves of the plugin — every section, with examples.
+description: Every bay's settings in one file, read by both halves of the plugin — the whole shape, each bay's keys and defaults, and the names from before 0.9.
 ---
 
-This page is Shell's configuration, and the sidebar order every bay shares. The other bays carry
-their own, because they are their own packages:
-[Statusline](/opencode-cockpit/status/configuration/),
-[Review](/opencode-cockpit/review/interface/#settings),
-[Subagents](/opencode-cockpit/subagents/overview/#settings) and
-[Trust](/opencode-cockpit/trust/overview/#settings).
-
-Everything is optional. Settings are merged from three places, later winning key by key:
+Every bay reads the same two files, and nothing else needs touching:
 
 ```
-~/.config/opencode-cockpit/config.json   →   /.cockpit.json   →   plugin-entry options
+~/.config/opencode-cockpit/config.json   →   /.cockpit.json
 ```
 
-A project can override one setting without restating the rest, and an unreadable or invalid file is
-**ignored rather than fatal** — a typo should never stop your shells from working. `XDG_CONFIG_HOME`
-is honoured for the global path.
+The global file applies everywhere (`XDG_CONFIG_HOME` is honoured); a project's file wins over it,
+section by section and key by key, so it can change one setting without restating the rest. A list
+replaces the one before it. Both halves of a bay — the agent's tools and the interface — read the
+same section, so a bay is configured in one place, not once in `opencode.json` and again in
+`tui.json`. Comments and trailing commas are fine. Everything is optional: with no file at all you
+get the defaults below.
+
+## The easy way: /cockpit-setup
+
+Type `/cockpit-setup`, or just ask — "make my sidebar quieter", "hide the shells block when it's
+empty", "move trail above subagents". The agent loads the `cockpit-setup` skill that ships with
+Cockpit and reads what is installed and written now with its `cockpit_settings` tool: every value
+and where it came from, anything from before 0.9 that is no longer read, OpenCode's own sidebar
+blocks. It fixes the old names first, offers a starting point (everything visible, quiet, minimal,
+or Status as a line under the prompt), asks only what is left, one question at a time, writes the
+smallest file that does it, and checks it reads back with no notices. It asks before touching
+OpenCode's own files, and never suggests turning OpenCode's Todo block off. From the home screen the
+command opens a conversation; while the agent is answering, it waits its turn (`1 queued`). It is in
+the palette too (`ctrl+p`, "cockpit").
+
+**Then, if you want it: "tune it to how you work."** With the blocks set, the agent offers a second
+phase (`cockpit_settings` with `tune: true`):
+
+- **A tour** of each bay you have on, with its real key and command as you have them set.
+- **Your project's conventions:** which commands keep running — found in `package.json` scripts, a
+  Makefile, a compose file or a Procfile — and belong in a background shell; your ticket prefix,
+  offered from your branch names and commits, so Trail groups by ticket; where PRs go; whether to
+  explore in background subagents.
+
+After you agree, the `cockpit_conventions` tool writes them as one `## Cockpit conventions` section
+in this project's `AGENTS.md` or OpenCode's global one. A rerun replaces that section in place and
+keeps every other byte of the file. It holds conventions only: how to use each bay is already in
+every request.
+
+Settings are read when OpenCode starts, so a change applies after a restart.
+
 
-```json title="~/.config/opencode-cockpit/config.json"
+## The whole shape
+
+```jsonc title="~/.config/opencode-cockpit/config.json"
+// a project's .cockpit.json takes the same shape
 {
-  "kinds": { "e2e": "playwright|cypress", "infra": "^(terraform|pulumi)\\b" },
-  "watch": {
-    "auto": false,
-    "presets": { "e2e": { "done": "\\d+ passed", "fail": "\\d+ failed" } }
-  },
-  "defaults": { "logFile": true, "timeoutSeconds": 900 },
-  "notify": { "exit": true, "watch": true, "tailLines": 15 },
-  "guidance": true,
-  "listRunningShells": 15,
-  "ui": { "dockHeight": 16, "dockOpen": true, "defaultView": "screen" }
+  "sidebar": ["status", "subagents", "shell", "trail", "trust"],   // the order, top to bottom
+  "features": { "trust": false },                                  // switch a whole bay off
+
+  "status":    { "preset": "sidebar", "sidebarRows": 14 },
+  "subagents": { "sidebarRows": 6, "hideWhenEmpty": false, "hideFinishedAfterMinutes": 60 },
+  "shell":     { "sidebarRows": 5, "dockHeight": 16, "lifecycle": { "onExit": "keep" } },
+  "trail":     { "sidebar": true, "sidebarRows": 5 },
+  "trust":     { "sidebar": true, "threshold": 3 },
+  "review":    { "variant": "right", "source": "worktree" },
+  "updater":   { "updateCheck": true }
 }
 ```
 
-## kinds
+One section per bay, and nothing at the top level but `sidebar` and `features`.
+
+## Keys every bay shares
+
+Spelled the same in every section:
 
-Extra shell categories, or overrides, as `name` → regular expression matched against the command.
-They drive the badge in the panel and sidebar and the `kind` filter in `shell_list`, so you can
-group your own stack instead of the built-ins (`server`, `tests`, `build`, `watcher`, `task`).
+| Key | | Default |
+| --- | --- | --- |
+| `enabled` | The bay's off switch, both halves. `features.: false` does the same | `true` |
+| `sidebar` | Draw the bay's sidebar block — a boolean here; the top-level `sidebar` is the order. For Status, `false` puts its line at the bottom, under the prompt | `true`; Trust `false` |
+| `sidebarRows` | Rows the block lists before the rest fold into `+ N more` | Status 8 (its table 14), Subagents 6, Shell 5, Trail 5, Trust 3 |
+| `hideWhenEmpty` | Subagents, Shell and Trail. `false`: with nothing to list the block still says it is there — its heading and `none yet`. `true`: no block at all until there is something | `false` |
+| `keybinds` | Keys for the bay's commands, `{ "": "" }` | each bay's own |
+
+Time keys carry their unit: `hideFinishedAfterMinutes`, `hideNestedAfterSeconds`.
 
-## watch
+## Keys
 
-`presets` are your own rules, keyed by name, and they reach the daemon as explicit rules — so a
-preset you invent works immediately. `auto` attaches a matching preset to every new shell; **off by
-default**.
+The same on OpenCode 1 and 2, and none of them one of OpenCode's own. `` is OpenCode's
+leader, `ctrl+x` unless you changed it.
+
+| Key | Command | Does |
+| --- | --- | --- |
+| `ctrl+x d` | `cockpit.subagents.open` | Subagents: the one working now |
+| `ctrl+x o` | `cockpit.shells.dock` | Shell: the panel under the chat |
+| `ctrl+x j` | `cockpit.shells.console` | Shell: the console |
+| `ctrl+x f` | `cockpit.trail.open` | Trail: `/trail` |
+| `ctrl+x p` | `cockpit.trust.ledger` | Trust: `/trust` |
+| `ctrl+x v` | `cockpit.review.open` | Review: open or close the changes |
+| `ctrl+x k` | `cockpit.review.place` | Review: the right pane or full screen |
 
-## lifecycle
+Set one in its bay's `keybinds` — `{ "subagents": { "keybinds": { "cockpit.subagents.open":
+"w" } } }` brings back the key from before 0.9 — or `"none"` to unbind it. Every command
+also has a slash name or a palette entry, so nothing depends on a key being free.
 
-When shells end without being asked to.
+## The sidebar order
+
+One list, at the top of either file, and nowhere else:
 
 ```json
-{ "lifecycle": { "onExit": "stopMine", "orphanAfterMinutes": 60 } }
+{ "sidebar": ["status", "subagents", "shell", "trail", "trust"] }
+```
+
+That is the default. A project's list replaces the global one (it is an order, not a set), and a bay
+the list leaves out keeps its default place after the ones it names. On OpenCode 1 Cockpit's blocks
+sit together under OpenCode's own Context block and above the rest. An entry that is not a bay —
+`"shells"` — is not silently ignored: a `!` row asks whether you meant `"shell"`.
+
+:::note[OpenCode 2, separate packages]
+On OpenCode 2 the bundle applies the list. **Installed as separate packages, the blocks draw in the
+order the packages are listed in `cli.json`**, so list them in the order you want them.
+:::
+
+## status
+
+The Status bay. The rest of it — segments, lines, modules — is under
+[Segments and layout](/opencode-cockpit/status/configuration/).
+
+| Key | | Default |
+| --- | --- | --- |
+| `preset` | A whole line by name: `sidebar` (the table), `minimal`, `default`, `detailed` (bottom lines). Anything written beside it wins | `sidebar` |
+| `surface` | `sidebar` or `bottom`; `"sidebar": false` says the same | `sidebar` |
+| `segments` | The line's parts, built-ins or your own — the whole list, replacing the preset's | the preset's |
+| `override` | Changes to the preset's segments by name, the rest kept: `false` drops one, a name swaps it, an object merges into its settings — `{ "git": { "against": "branch" } }` | none |
+| `lines` | More than one line, each with its own `surface`, `segments`, `maxRows`… | one |
+| `separator`, `stack`, `icons`, `debug`, `padding*` | How a line is laid out | per surface |
+| `commands` | Shell commands usable as segments — your Claude Code statusline script, unchanged | none |
+| `modules` | Your own segments in TypeScript; a project's add to the global ones | none |
+
+## subagents
+
+See [Subagents](/opencode-cockpit/subagents/overview/#settings).
+
+| Key | | Default |
+| --- | --- | --- |
+| `hideFinishedAfterMinutes` | Minutes a finished subagent stays in the sidebar | unset: the whole conversation |
+| `hideNestedAfterSeconds` | Seconds a finished *nested* subagent stays; negative keeps them | `30` |
+| `guidance` | Tell the agent about background subagents and follow-ups | `true` |
+
+## shell
+
+| Key | | Default |
+| --- | --- | --- |
+| `kinds` | Your own shell categories, name → regex on the command | none |
+| `watch` | `presets` (your own rules) and `auto` (attach one to every shell) | `auto: false` |
+| `defaults` | Applied to every shell the agent starts: `watch`, `logFile`, `idleTimeoutSeconds`, `timeoutSeconds`, `notifyOnExit` | none |
+| `lifecycle` | `onExit` (`stopMine` or `keep`), `orphanAfterMinutes`, `removeFinishedAfterMinutes` | `stopMine`, `60`, `30` |
+| `notify` | What may interrupt the agent: `exit`, `watch`, `tailLines` | on |
+| `guidance`, `listRunningShells` | The system-prompt paragraph, and how many running shells it names | `true`, `15` |
+| `dockHeight`, `dockOpen`, `defaultView`, `colors` | The panel under the chat and the console | `14`, as last left, `screen`, `true` |
+| `hideFinishedAfterMinutes` | How long a finished shell stays in the folded views | `30` |
+
+```jsonc
+{
+  "shell": {
+    "kinds": { "e2e": "playwright|cypress", "infra": "^(terraform|pulumi)\\b" },
+    "watch": {
+      "auto": false,
+      "presets": { "e2e": { "done": "\\d+ passed", "fail": "\\d+ failed" } }
+    },
+    "defaults": { "logFile": true, "timeoutSeconds": 900 },
+    "notify": { "exit": true, "watch": true, "tailLines": 15 },
+    "dockHeight": 16,
+    "dockOpen": true
+  }
+}
 ```
 
-`onExit` decides what happens to the shells an OpenCode window started when that window closes:
-`stopMine` (the default) stops them, `keep` leaves them running for the next window — which is how
-a shell survives an OpenCode restart.
+**kinds** are extra shell categories, or overrides, as `name` → regular expression matched against
+the command. They drive the badge in the panel and sidebar and the `kind` filter in `shell_list`, so
+you can group your own stack instead of the built-ins (`server`, `tests`, `build`, `watcher`,
+`task`).
 
-`orphanAfterMinutes` stops a shell that no window of its own has been connected to for that long,
-so nothing can quietly run for a week. `0` turns it off.
+**watch.presets** are your own rules, keyed by name, and they reach the daemon as explicit rules — so
+a preset you invent works immediately. `auto` attaches a matching preset to every new shell; **off
+by default**.
+
+**lifecycle** says when shells end without being asked to. `onExit` decides what happens to the
+shells an OpenCode window started when that window closes: `stopMine` (the default) stops them,
+`keep` leaves them running for the next window — which is how a shell survives an OpenCode restart.
+`orphanAfterMinutes` stops a shell that no window of its own has been connected to for that long, so
+nothing can quietly run for a week; `0` turns it off. `removeFinishedAfterMinutes` removes a shell
+the agent started that long after it exits cleanly; failed ones stay until `/shells-clear`.
 
 :::note[Two windows]
 A window closing only counts once **both halves** of the plugin have disconnected, so quitting one
 of two open OpenCodes never stops the other's shells.
 :::
 
-## defaults
+**guidance and listRunningShells** are the context budget. `guidance` is the paragraph that teaches
+the model when to reach for a shell (~120 tokens per request); `listRunningShells` is how many
+running shells are named in the system prompt each turn (~20 tokens each, `0` disables). Turning both
+off saves tokens at the cost of a model that uses shells less well.
+
+**dockOpen** decides how the panel starts: set it and it always starts that way; leave it out and it
+starts however you last left it.
 
-Applied to every shell the agent starts unless the call says otherwise: `watch`, `logFile`,
-`idleTimeoutSeconds`, `timeoutSeconds`, `notifyOnExit`.
+## trail
 
-## notify
+Nothing beyond the shared keys. Its block is on by default, and `ctrl+x f` (`cockpit.trail.open`)
+opens `/trail`. See [Trail](/opencode-cockpit/trail/overview/#settings).
 
-What may interrupt the agent: `exit`, `watch`, and `tailLines` — how much output rides along with an
-exit message.
+## trust
 
-## guidance and listRunningShells
+See [Trust](/opencode-cockpit/trust/overview/#settings).
 
-The context budget. `guidance` is the paragraph that teaches the model when to reach for a shell
-(~120 tokens per request); `listRunningShells` is how many running shells are named in the system
-prompt each turn (~20 tokens each, `0` disables). Turning both off saves tokens at the cost of a
-model that uses shells less well.
+| Key | | Default |
+| --- | --- | --- |
+| `threshold` | Approvals in a row, by you, before Trust answers | `3` |
+| `dangerExtra` | What a dangerous command costs on top | `5` |
+| `expireDays` | Days unused before trust has to be earned again; `0` never | `30` |
 
-## ui
+Its block is off by default: `"sidebar": true` shows it, and the palette flips it for the session.
 
-Interface only: `dockHeight`, `dockOpen`, `sidebarRows`, `sidebarOrder`, `historyMinutes`, `colors`,
-`defaultView` (`screen` or `log`), `keybinds`, `updateCheck`.
+## review
 
-:::tip[Four bays share the sidebar]
-The statusline (140), Subagents (150), Trust (160, when `trust.sidebar` shows it) and Shell (170) all
-draw there, in that order; lower draws first, on both OpenCodes. One list orders them all — in `~/.config/opencode-cockpit/config.json`, or a project's `.cockpit.json`,
-which wins:
+No sidebar block. See [Panel and keys](/opencode-cockpit/review/interface/#settings).
+
+| Key | | Default |
+| --- | --- | --- |
+| `variant` | Where the panel opens: `right` or `full` | `right` |
+| `source` | What it reviews on open: `worktree` (uncommitted) or `branch` | `worktree` |
+
+## updater
+
+See [Updater](/opencode-cockpit/updater/overview/).
+
+| Key | | Default |
+| --- | --- | --- |
+| `updateCheck` | Check for plugin updates once a day and say so | `true` |
+
+## Names from before 0.9
+
+0.9 gave every bay the same shape, so some names changed. **The old ones are not read.** Each one a
+file still carries is drawn as a `!` row in its bay's block, printed by
+[doctor](/opencode-cockpit/help/doctor/), and fixed first by `/cockpit-setup`:
 
-```json
-{ "sidebar": ["status", "subagents", "trust", "shell"] }
+```
+! settings: "statusline" is no longer read — run /cockpit-setup
 ```
 
-A bay the list leaves out keeps its place. A bay's own number (`ui.sidebarOrder` here,
-`statusline.sidebarOrder`, Subagents' and Trust's `sidebarOrder`) still beats the list.
-:::
+| Before 0.9 | Now |
+| --- | --- |
+| `statusline` | `status` |
+| `status.maxRows` | `status.sidebarRows` |
+| Shell's keys at the file's root (`kinds`, `watch`, `defaults`, `lifecycle`, `notify`, `guidance`, `listRunningShells`) | the same keys under `shell` |
+| `ui.dockHeight`, `ui.dockOpen`, `ui.sidebarRows`, `ui.colors`, `ui.keybinds`… | the same keys under `shell` |
+| `ui.historyMinutes` | `shell.hideFinishedAfterMinutes` |
+| `ui.updateCheck` | `updater.updateCheck` |
+| `ui.sidebarOrder`, `.sidebarOrder` | the top-level `sidebar` list |
+| `subagents.hideFinishedAfter`, `subagents.hideNestedAfter` | `…Minutes`, `…Seconds` |
+| Status's keys at the file's root (`preset`, `segments`, `enabled`…) | the same keys under `status` |
 
-:::tip[dockOpen decides how it starts]
-Set it and the panel always starts that way. Leave it out and it starts however you last left it.
-:::
+A file that is not valid JSON, a top-level name nothing reads, or a value of the wrong kind gets a
+`!` row too, and the defaults — never a silently blank sidebar.
+
+## OpenCode's own sidebar blocks
+
+Status's table carries what OpenCode's own Context block says. To keep only one, switch the host's
+off — it is OpenCode's setting, in OpenCode's file, and the name differs by version:
+
+```jsonc title="OpenCode 1 — ~/.config/opencode/tui.json"
+{ "plugin_enabled": { "internal:sidebar-context": false } }
+```
+
+```jsonc title="OpenCode 2 — ~/.config/opencode/cli.json"
+{ "plugins": ["opencode-cockpit@0.8.0", "-opencode.sidebar.context"] }
+```
+
+The other blocks switch the same way, by these ids. Hiding them is a matter of taste: Status's table
+already warns when an MCP or language server fails, and `opencode mcp list` still lists them all.
+
+| Block | OpenCode 1 (`tui.json`, `plugin_enabled`) | OpenCode 2 (`cli.json`, `plugins`) |
+| --- | --- | --- |
+| Context | `internal:sidebar-context` | `opencode.sidebar.context` |
+| MCP | `internal:sidebar-mcp` | `opencode.sidebar.mcp` |
+| Footer (path and branch) | `internal:sidebar-footer` | `opencode.sidebar.footer` |
+| LSP | `internal:sidebar-lsp` | — |
+| Files | `internal:sidebar-files` | — |
+| Todo | `internal:sidebar-todo` | — |
+
+An `internal:` id in OpenCode 2's `cli.json` does nothing, silently. Leave OpenCode's Todo block on:
+nothing in Cockpit replaces it. `/cockpit-setup` reads these files, says what each block is set to,
+and asks before changing them.
+
+## Advanced: options on the plugin entry
+
+The same keys can also go on the plugin entry — the bundle's `["opencode-cockpit", { "shell": { … } }]`
+or a single bay's own `["@opencode-cockpit/shell", { … }]` — where they win over both files. It is
+rarely worth it: on OpenCode 1 the interface's options belong in `tui.json` and the agent's in
+`opencode.json`, so the same bay ends up configured in two places. The files are read by both.
 
 ## Environment variables
 
diff --git a/site/src/content/docs/help/doctor.md b/site/src/content/docs/help/doctor.md
index 66a43734..e7e53dee 100644
--- a/site/src/content/docs/help/doctor.md
+++ b/site/src/content/docs/help/doctor.md
@@ -25,6 +25,7 @@ Cockpit doctor
                 2026-09-24T11:00:00Z  error tui:review  review: trouble: …
                 → the full lines, with stacks: tail -100 ~/.cache/opencode-cockpit/cockpit.log
  · Daemon       not running — it starts with the first shell, and stops when idle
+ ✓ Service      OpenCode 2's background service is not running; a window starts it
  ✓ Environment  git, ps, and a writable Cockpit home
  ✓ Settings     1 file, 1 statusline module
 
@@ -61,11 +62,24 @@ messages — including the ones that were only a toast.
 
 **Daemon.** Whether the shell daemon is running, and the build it was started from.
 
+**Service.** On OpenCode 2 only: whether its background service runs the Cockpit installed now. The
+service loads plugins once, when it starts, so after an install or an update it keeps the old agent
+side — no new tools, no new skills — until `opencode service restart`. Doctor reads which install the
+agent side recorded when it loaded, and warns when a newer one was installed since; with no record,
+it compares when the service started with when Cockpit was installed.
+
+**Subagents.** Where Subagents is installed: whether subagents can run in the background. OpenCode 2
+has it built in; OpenCode 1 only when started with `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true`,
+and doctor gives the line for your shell's profile when it is missing.
+
 **Environment.** `git` on your PATH (Review reads branches through it), `ps` (the daemon stops a
 shell's processes with it), and a Cockpit home it can write to.
 
-**Settings.** `~/.config/opencode-cockpit/config.json` and the project's `.cockpit.json` parse — an
-invalid one is ignored whole — and every statusline module they list exists.
+**Settings.** `~/.config/opencode-cockpit/config.json` and the project's `.cockpit.json` parse
+(comments and trailing commas are fine; a file that does not is ignored whole, and the defaults
+apply), every setting is read as written — a name from before 0.9, a value of the wrong kind, a
+Status `override` that matches no segment are each listed, the same notices the bays draw as `!`
+rows — and every statusline module they list exists.
 
 ## For an issue
 
@@ -84,5 +98,6 @@ for a dotfiles script or CI.
 ## Not yet
 
 Doctor checks that statusline modules exist, not that they load; it does not yet know which cached
-copy of a plugin OpenCode loaded; and it runs from a terminal, not from inside OpenCode.
+copy of a plugin OpenCode loaded; and it runs from a terminal, not from inside OpenCode — though
+inside OpenCode, `cockpit_settings` gives the agent the same settings notices.
 [Troubleshooting](/opencode-cockpit/help/troubleshooting/) covers the rest.
diff --git a/site/src/content/docs/help/troubleshooting.md b/site/src/content/docs/help/troubleshooting.md
index 97f074e0..5d495ff3 100644
--- a/site/src/content/docs/help/troubleshooting.md
+++ b/site/src/content/docs/help/troubleshooting.md
@@ -126,8 +126,8 @@ npx opencode-cockpit@latest update
 
 It pins the newest version, clears the stale cache, and checks both files; then restart.
 
-On **OpenCode 2**, change the version in your `opencode.json` entry and restart — the updater above
-edits OpenCode 1's files only.
+On **OpenCode 2**, change the version in your `opencode.json` entry, run `opencode service restart`,
+and restart OpenCode — the updater above edits OpenCode 1's files only.
 
 To check which build is actually running, the newest start line says it:
 
@@ -135,16 +135,33 @@ To check which build is actually running, the newest start line says it:
 grep '"msg":"start"' ~/.cache/opencode-cockpit/cockpit.log | tail -1
 ```
 
-## A setting seems to do nothing
+## The agent has none of the new tools, on OpenCode 2
 
-Settings merge global → project → plugin entry. A `.cockpit.json` in the project wins over your
-global file, and an invalid file is ignored silently by design — check it parses:
+The windows show the new Cockpit, but the agent has no `trail_add`, no `cockpit_settings`, or answers
+`/cockpit-setup` without its skill. OpenCode 2 runs the agent side in a background service that loads
+plugins once, when it starts, and keeps running when you close OpenCode — so after an install or an
+update it still runs the old Cockpit. Restart it:
 
 ```sh
-cat .cockpit.json | python3 -m json.tool
+opencode service restart
 ```
 
+A window says so when it can tell, in one toast: `Cockpit was updated — OpenCode's background service
+still runs the old one. Run: opencode service restart`. Doctor's **Service** check says the same from a
+terminal. Cockpit never restarts the service itself: that would cut every open window.
+
+## A setting seems to do nothing
+
+Settings merge global → project → plugin entry, and a `.cockpit.json` in the project wins over your
+global file, key by key. They are read when OpenCode starts, so a change applies after a restart.
+
+A setting Cockpit cannot use is never dropped in silence: a file that does not parse, a name from
+before 0.9 (`statusline`, `ui.*`, `sidebarOrder`…), a value of the wrong kind or a top-level name
+nothing reads is a `!` row in the bay's sidebar block and a line in doctor's **Settings** check.
+Comments and trailing commas are fine. To see every value and where it came from, ask the agent —
+`/cockpit-setup`, or "why is my shells block hidden?" — which reads them with `cockpit_settings`.
+
 ## Shells are gone after a restart
 
 They survive OpenCode restarts, not machine restarts: the daemon exits when idle and reaps what it
-owned. Shells finished more than `ui.historyMinutes` ago are also hidden from the panel by default.
+owned. Shells finished more than `shell.hideFinishedAfterMinutes` (30) ago are also hidden from the panel by default.
diff --git a/site/src/content/docs/platform/bays.md b/site/src/content/docs/platform/bays.md
index 69b92e7e..cd86bc6b 100644
--- a/site/src/content/docs/platform/bays.md
+++ b/site/src/content/docs/platform/bays.md
@@ -8,7 +8,7 @@ a switch in config. They share everything underneath.
 
 ## Shell — available
 
-Background terminals with a real PTY. Nine agent tools, 35 watch presets, three views of every
+Background terminals with a real PTY. Eight agent tools, 34 watch presets, three views of every
 shell, log search, limits and log files. See [Shell](/opencode-cockpit/shell/overview/).
 
 ```sh
@@ -17,7 +17,7 @@ opencode plugin @opencode-cockpit/shell@0.8.0 --global --force
 
 ## Statusline — available
 
-A line of live session state under the conversation, or a column of it in the sidebar. Fourteen
+A table of live session state in the sidebar, or a line of it under the conversation. Twenty-three
 segments, two surfaces, and three ways to configure it — including running the statusline script you
 already wrote for Claude Code, colours and all. See
 [Statusline](/opencode-cockpit/status/overview/).
@@ -59,6 +59,17 @@ instead of starting a new one, and the main agent can list, read and wait on its
 opencode plugin @opencode-cockpit/subagents@0.8.0 --global --force
 ```
 
+## Trail — available
+
+What a conversation made: the pull requests, tickets, pages and deploys the agent created or changed,
+recorded by the agent with `trail_add`, kept per conversation, grouped by the ticket they were for,
+and one click from the page. `/trail` says which conversation made what, across the project. No
+setup. See [Trail](/opencode-cockpit/trail/overview/).
+
+```sh
+opencode plugin @opencode-cockpit/trail@0.8.0 --global --force
+```
+
 ## Trust — available
 
 Permissions that learn. Approve the exact same command three times in a row and Trust answers
diff --git a/site/src/content/docs/review/interface.md b/site/src/content/docs/review/interface.md
index 37db780a..4911a6e5 100644
--- a/site/src/content/docs/review/interface.md
+++ b/site/src/content/docs/review/interface.md
@@ -5,7 +5,7 @@ description: Where the panel sits, every key that drives it, and what the footer
 
 ## Where it sits
 
-<leader>v opens and closes the review; <leader>r cycles where it
+<leader>v opens and closes the review; <leader>k cycles where it
 sits. `` is OpenCode's own prefix, `ctrl+x` unless you changed it. From the palette it is
 `/changes` — `/review` and `/diff` belong to OpenCode itself.
 
@@ -19,29 +19,22 @@ narrows rather than disappearing, because a review you cannot change file in is
 
 ## Settings
 
-Three, all optional, given where the plugin is listed:
+All optional, in the `review` section of `~/.config/opencode-cockpit/config.json` or a project's
+`.cockpit.json` (before 0.9 Review read no file, only its plugin entry):
 
-```json title="opencode.json"
-{
-  "plugin": [
-    ["@opencode-cockpit/review", { "variant": "full", "source": "branch" }]
-  ]
-}
+```json title="~/.config/opencode-cockpit/config.json"
+{ "review": { "variant": "full", "source": "branch" } }
 ```
 
 | | |
 | --- | --- |
 | `variant` | `right` (default) or `full` — where it opens |
-| `source` | `worktree` (default), `branch`, or `session` — what it opens on |
-| `keybinds` | overrides for the two global keys, e.g. `{ "cockpit.review.open": "d" }` |
+| `source` | `worktree` (default) or `branch` — what it opens on |
+| `keybinds` | overrides for the two global keys, e.g. `{ "cockpit.review.open": "z" }` |
+| `enabled` | `false` switches Review off; so does `features.review: false` |
 
-Through the bundle, the same options go under a `review` key:
-
-```json title="opencode.json"
-{
-  "plugin": [["opencode-cockpit", { "review": { "variant": "full" } }]]
-}
-```
+A value Review does not know (`"variant": "left"`) is the default and a `!` row in the pane naming
+the ones it does. See [Configuration](/opencode-cockpit/configuration/).
 
 The keys *inside* the panel are not configurable. They are a closed set that only exists while the
 panel is open, and the panel gives them straight back when it closes.
@@ -53,7 +46,7 @@ The panel takes the keyboard while it is open, and gives it straight back when i
 | Key | Does |
 | --- | --- |
 | <leader>v | Open or close the review |
-| <leader>r | Move it: right pane, full screen |
+| <leader>k | Move it: right pane, full screen |
 | j k ↑ ↓ | Move |
 | tab | Switch pane |
 | return l → | Open a file, or fold a folder |
@@ -65,13 +58,19 @@ The panel takes the keyboard while it is open, and gives it straight back when i
 | f | Comment on the whole file |
 | x | Remove the thread you are on |
 | space m | Mark read, and go to the next unread |
+| z | Fold |
 | s | Submit the review |
 | b | Next source: uncommitted, or what this branch changes |
+| B | Pick the base the branch is compared with, remembered per branch |
+| o | On a changed image: open both versions in your system viewer |
 | g | Reload the diff |
 | w | Right pane or full screen |
 | p | Show what the panel is costing |
+| ? | Every key the panel takes; esc back |
 | q esc | Close |
 
+ctrl+p works while the review is open: it steps aside for OpenCode's palette.
+
 Submit is s and source is b, which looks arbitrary until you try it the other
 way: they were s and S for an afternoon, two meanings on one letter separated
 only by a shift, and one of them sends your review to the agent.
diff --git a/site/src/content/docs/review/reading.md b/site/src/content/docs/review/reading.md
index 5877cbf8..394df93d 100644
--- a/site/src/content/docs/review/reading.md
+++ b/site/src/content/docs/review/reading.md
@@ -56,3 +56,21 @@ Code is coloured the way the rest of OpenCode colours it, because the palette co
 rather than from us. Twelve filetypes are covered, and adding one is a row in a table —
 [the language table](https://github.com/Codestz/opencode-cockpit/blob/main/packages/review/src/core/view/syntax/languages.ts)
 is the file to edit.
+
+## Images and other binaries
+
+A binary is never drawn as text — git's rule decides it (a NUL in the first 8000 bytes). Its card
+says what is true about it:
+
+```text
+PNG 2880×1800 · 807 KB → 789 KB
+2.56% of pixels changed · 601×221 at 1900,300
+[o] Open Both
+```
+
+PNG, APNG, JPEG, GIF, WebP and BMP are named with their dimensions; anything else is
+`binary · 12.3 KB → 14.0 KB`. PNG and GIF of the same size are also decoded — in slices, off the draw
+path, so a pair of screenshots never freezes the window — for the pixel diff and a small preview,
+before and after side by side in half-block characters with what changed lit. The preview shows
+*where*; o opens both versions in your system's viewer for *what*. Binaries are read up to
+32 MB and decoded up to 4096×4096 pixels; past either, the card still names and sizes the file.
diff --git a/site/src/content/docs/shell/interface.mdx b/site/src/content/docs/shell/interface.mdx
index bb81893c..146fddd7 100644
--- a/site/src/content/docs/shell/interface.mdx
+++ b/site/src/content/docs/shell/interface.mdx
@@ -12,13 +12,14 @@ import Cast from "../../../components/Cast.astro"
 | Key | Does |
 | --- | --- |
 | <leader>o | Toggle the panel under the conversation |
-| <leader>i | Open the full console |
+| <leader>j | Open the full console |
 
 OpenCode's leader is ctrl+x unless you have changed it, so these read as
-ctrl+x o and ctrl+x i. Both are rebindable:
+ctrl+x o and ctrl+x j. Both are rebindable, in the
+`shell` section — pick a key OpenCode does not already use:
 
 ```json title="~/.config/opencode-cockpit/config.json"
-{ "ui": { "keybinds": { "cockpit.shells.console": "t" } } }
+{ "shell": { "keybinds": { "cockpit.shells.console": "z" } } }
 ```
 
 Every command below also has a slash name, so nothing here depends on a key being free.
@@ -32,7 +33,7 @@ anything. Press s in the console to widen it to the whole project and
 ## The panel
 
 A dock under the chat with a tab per shell, the selected shell's live screen, and the health of each
-one at a glance. `ui.dockHeight` sets how tall, `ui.dockOpen` whether it starts open.
+one at a glance. `shell.dockHeight` sets how tall, `shell.dockOpen` whether it starts open.
 
 ## The console
 
@@ -97,6 +98,7 @@ the console's; `--help` lists the states. Plain text under `NO_COLOR` or into a
 ## Commands
 
 `/shells-stop` stops the shells you are looking at; `/shells-stop-all` stops every shell in the
-project and asks first when that includes conversations you cannot see. `/shells` toggles the panel, `/shell` opens the console, `/shell-new` starts one,
+project and asks first when that includes conversations you cannot see. `/shells-dock` toggles the
+panel, `/shell` opens the console, `/shells` picks a shell (or "New shell"), `/shell-new` starts one,
 `/shells-clear` forgets finished shells, `/shells-restart-daemon` replaces a daemon running older
-code. Rebind them under `ui.keybinds`.
+code. Rebind them under `shell.keybinds`.
diff --git a/site/src/content/docs/shell/tools.md b/site/src/content/docs/shell/tools.md
index 0b91bf3c..4ae8843b 100644
--- a/site/src/content/docs/shell/tools.md
+++ b/site/src/content/docs/shell/tools.md
@@ -1,6 +1,6 @@
 ---
 title: Agent tools
-description: The nine tools the agent is given, and what each one is for.
+description: The eight tools the agent is given, and what each one is for.
 ---
 
 Every per-shell tool takes an `id` or a `name` — the description the shell was started with. Partial
@@ -48,7 +48,7 @@ Attaches or changes a watcher. See [Watching health](/opencode-cockpit/shell/wat
 Finds the right shell: filter by `query`, `status`, `session` or `kind`, including
 [kinds you defined yourself](/opencode-cockpit/configuration/).
 
-## shell_stop · shell_restart · shell_remove
+## shell_stop · shell_restart
 
-End it, run it again with the same id, or forget it. A restart keeps the id and marks the new run in
-the log, so earlier output stays readable.
+End it, or run it again with the same id. `shell_stop remove=true` also forgets it. A restart keeps
+the id and marks the new run in the log, so earlier output stays readable.
diff --git a/site/src/content/docs/shell/watching.mdx b/site/src/content/docs/shell/watching.mdx
index 231a5b8b..9f817db6 100644
--- a/site/src/content/docs/shell/watching.mdx
+++ b/site/src/content/docs/shell/watching.mdx
@@ -35,7 +35,7 @@ Rules can be passed to `shell_start` as `watch`, or to `shell_watch` as `rule`.
 
 ## Presets
 
-About 35 ship and are picked automatically from the command: tsc, eslint, biome, prettier, mypy,
+34 ship and are picked automatically from the command: tsc, eslint, biome, prettier, mypy,
 ruff, vitest, jest, mocha, bun test, deno test, pytest, rspec, phpunit, playwright, cypress, vite,
 next, nuxt, astro, angular, webpack, esbuild, tsup, turbo, metro, storybook, cargo, go, dotnet,
 gradle, maven, docker compose and terraform.
@@ -45,15 +45,17 @@ without waiting for a release:
 
 ```json title="~/.config/opencode-cockpit/config.json"
 {
-  "watch": {
-    "presets": {
-      "e2e": { "done": "\\d+ (passed|failed)", "fail": "\\d+ failed" }
+  "shell": {
+    "watch": {
+      "presets": {
+        "e2e": { "done": "\\d+ (passed|failed)", "fail": "\\d+ failed" }
+      }
     }
   }
 }
 ```
 
 :::note[watch.auto is off by default]
-`watch: { "auto": true }` attaches a matching preset to every new shell. It stays off unless you ask
-for it — attaching rules to commands nobody opted into is a surprise, not a feature.
+`"shell": { "watch": { "auto": true } }` attaches a matching preset to every new shell. It stays
+off unless you ask for it — attaching rules to commands nobody opted into is a surprise, not a feature.
 :::
diff --git a/site/src/content/docs/start/first-session.mdx b/site/src/content/docs/start/first-session.mdx
index fe6cb9d8..75233bf7 100644
--- a/site/src/content/docs/start/first-session.mdx
+++ b/site/src/content/docs/start/first-session.mdx
@@ -27,7 +27,7 @@ ends, and it shows up in the panel and the sidebar.
 | Key | What it does |
 | --- | --- |
 | ctrl+x o | Toggle the panel under the conversation |
-| ctrl+x i | Open the full console |
+| ctrl+x j | Open the full console |
 | / | Filter the log, in the console |
 | ? | Details for the selected shell |
 
@@ -49,12 +49,13 @@ is about, it changes what needs changing, and `review_reply resolved=true` close
 only counts if the file actually changed. Your comments stay on the branch, so tomorrow's
 conversation still has them.
 
-## And the line under the prompt
+## And the table in the sidebar
 
-If you installed the bundle you already have the statusline: how full the context is, where the
-tokens went, what the session has cost, what has changed. It answers the questions you would
-otherwise ask by leaving — and every part of it is a segment you can reshape, recolour or write
-yourself.
+If you installed the bundle you already have the statusline, at the top of the sidebar: how full the
+context is, where the tokens went, a proxy's budget, what has changed. It answers the questions you
+would otherwise ask by leaving — and every part of it is a segment you can reshape, recolour or
+write yourself. Under it, Subagents, Shells and Trail each say `none yet` until there is something
+to show. `/cockpit-setup` asks you what you want there, and writes the config.
 
 ## What to try next
 
diff --git a/site/src/content/docs/start/install.md b/site/src/content/docs/start/install.md
index 3cdd01c5..6304b551 100644
--- a/site/src/content/docs/start/install.md
+++ b/site/src/content/docs/start/install.md
@@ -25,7 +25,19 @@ opencode plugin add opencode-cockpit@0.8.0
 
 This writes `"plugins"` in `opencode.json`, and OpenCode 2 loads both halves from there.
 
-Restart OpenCode afterwards.
+Restart OpenCode afterwards. **On OpenCode 2, restart its background service too**, after installing
+and after every update:
+
+```sh
+opencode service restart
+```
+
+OpenCode 2 runs the agent side in a background service that loads plugins once, when it starts, and
+keeps running when you close OpenCode. Until it restarts, the windows draw the new Cockpit while the
+agent keeps the old one's tools and skills. A window says so when it sees it — one toast, `Cockpit was
+updated — OpenCode's background service still runs the old one. Run: opencode service restart` — and
+[doctor](/opencode-cockpit/help/doctor/)'s Service check says the same from a terminal. Cockpit never
+restarts the service for you: that would cut every open window.
 
 ## A single bay
 
@@ -60,6 +72,14 @@ wins and Cockpit warns you which entry to remove.
 }
 ```
 
+## Setting it up
+
+Everything works with no configuration. To choose what shows, type `/cockpit-setup` — or just ask,
+"make my sidebar quieter". The agent reads what is installed and set now, fixes any name from before
+0.9, and writes `~/.config/opencode-cockpit/config.json` with you: which bays show, in the sidebar or
+at the bottom, in what order, quiet or present when empty. Then, if you want, it tunes Cockpit to how
+you work — see [Configuration](/opencode-cockpit/configuration/#the-easy-way-cockpit-setup).
+
 ## What happens on first run
 
 1. The plugin connects to `cockpitd`, starting it if it isn't running.
@@ -74,8 +94,8 @@ OpenCode resolves a plugin spec **once** and caches it for ever, so a bare `open
 `@latest` means the release that was newest the day you first installed it. That is why the commands
 above pin a version, and why `--force` is there: run the same line with a newer version to move.
 
-On OpenCode 2, change the version in your `opencode.json` entry — Cockpit's updater edits OpenCode 1's
-files only. On OpenCode 1 you rarely need to: once a day Cockpit checks every plugin you have — not just its own — and says so
+On OpenCode 2, change the version in your `opencode.json` entry, then run `opencode service restart`
+— Cockpit's updater edits OpenCode 1's files only. On OpenCode 1 you rarely need to: once a day Cockpit checks every plugin you have — not just its own — and says so
 when something is behind. `/plugins-update` shows what runs beside what your config says and what is
 published, and updates what you pick: it pins the new version through OpenCode's own `opencode plugin`,
 removes the stale cache, and reads every file back before calling it done.
diff --git a/site/src/content/docs/start/opencode-versions.md b/site/src/content/docs/start/opencode-versions.md
index 808eec8a..0d69ae01 100644
--- a/site/src/content/docs/start/opencode-versions.md
+++ b/site/src/content/docs/start/opencode-versions.md
@@ -30,6 +30,13 @@ tools and the interface. A single bay works the same way:
 opencode plugin add @opencode-cockpit/shell@0.8.0
 ```
 
+Then restart OpenCode's background service, which loads plugins only when it starts — after this
+install and after every update:
+
+```sh
+opencode service restart
+```
+
 Options go in the entry as an object — the v2 spelling of v1's `[name, options]` pair:
 
 ```json title="~/.config/opencode/opencode.json"
@@ -49,7 +56,7 @@ files on both.
 
 What does not carry over is **Cockpit before 0.6**: it runs on OpenCode 1 only. If OpenCode 2 lists
 Cockpit under `/plugins` as failed, change the version in your `opencode.json` entry to the newest
-release and restart. Edit that entry rather than running `opencode plugin add` beside it — `add`
+release, run `opencode service restart`, and restart OpenCode. Edit that entry rather than running `opencode plugin add` beside it — `add`
 writes a second one and leaves the old.
 
 ## Running both side by side
@@ -71,8 +78,10 @@ what you will notice:
 | --- | --- | --- |
 | **`shell_start` permission** | asks with your `bash` permission rules | runs without asking — v2 gives a plugin tool no way to ask |
 | **Tool calls in the chat** | `shell_start`, `review_list`… | `execute`, calling them in Code Mode — same tools, same results |
-| **Updating** | `/plugins-update`, or `npx opencode-cockpit@latest update` | change the version in `opencode.json`; Cockpit's updater edits OpenCode 1's files only |
-| **Statusline `lsp` segment** | language servers | empty — v2 does not expose them to plugins |
+| **Updating** | `/plugins-update`, or `npx opencode-cockpit@latest update` | change the version in `opencode.json`, then `opencode service restart`; Cockpit's updater edits OpenCode 1's files only |
+| **The agent side** | loads with each window | runs in a background service that keeps the Cockpit it started with until `opencode service restart` — a window toasts when it is older, and doctor's Service check says so |
+| **Status's `diagnostics`** | MCP servers and language servers | MCP servers only — v2 does not expose language servers to plugins |
+| **OpenCode's own sidebar blocks** | Context, MCP, LSP, Todo, Files, Footer — off in `tui.json` under `plugin_enabled` | Context, MCP, Footer — off in `cli.json` as `-opencode.sidebar.` in `plugins` ([ids](/opencode-cockpit/configuration/#opencodes-own-sidebar-blocks)) |
 | **Colours** | the theme | the same theme, except the subtle border grey, one shade lighter |
 
 :::caution[The permission difference]
diff --git a/site/src/content/docs/start/what-cockpit-is.md b/site/src/content/docs/start/what-cockpit-is.md
index 9f481a4a..4af6d05e 100644
--- a/site/src/content/docs/start/what-cockpit-is.md
+++ b/site/src/content/docs/start/what-cockpit-is.md
@@ -14,12 +14,16 @@ OpenCode is an excellent terminal agent flying without instruments. Cockpit is t
 | Bay | Your agent gains | You gain |
 | --- | --- | --- |
 | **[Shell](/opencode-cockpit/shell/overview/)** | Terminals that keep running — it starts them, waits for "ready", reads the part that matters | A live panel of every process, with health it reports itself |
-| **[Statusline](/opencode-cockpit/status/overview/)** | — | The session at a glance: how full the context is, where the tokens went, what changed, how long |
+| **[Statusline](/opencode-cockpit/status/overview/)** | A skill to design the line with you | The session at a glance: how full the context is, where the tokens went, what changed, how long |
 | **[Review](/opencode-cockpit/review/overview/)** | Comments it can read, answer and resolve — a resolve is checked against the file | The diff where the work happened, with notes on the lines they are about |
 | **[Updater](/opencode-cockpit/updater/overview/)** | — | Every plugin you have: what is really running, what is published, and an update checked against disk |
 | **[Subagents](/opencode-cockpit/subagents/overview/)** | Follow-ups that continue the subagent that did the work; tools to list, read and wait on its subagents | Every subagent in the sidebar with what it is doing now, its whole run in a pane, and a message away |
+| **[Trail](/opencode-cockpit/trail/overview/)** | Tools to record the PRs, tickets and pages it creates or changes, and to say which conversation made one | What a conversation made, in the sidebar, one click from the page |
 | **[Trust](/opencode-cockpit/trust/overview/)** | — | Permissions that learn: the exact same command approved three times is answered for you, and every answer is recorded |
 
+Every one of them can be set up by asking: `/cockpit-setup` has the agent read what is installed and
+write the config with you.
+
 Each is its own npm package with a switch in config. Take the suite or a single bay; either way it
 is the same daemon, the same config file and the same keys, so moving between them changes nothing
 you have already set up.
@@ -69,9 +73,12 @@ and the interface slots are identical either way.
 
 ```json title="~/.config/opencode-cockpit/config.json"
 {
-  "defaults": { "logFile": true },
-  "ui": { "dockOpen": true }
+  "sidebar": ["status", "subagents", "shell", "trail", "trust"],
+  "shell": { "defaults": { "logFile": true }, "dockOpen": true }
 }
 ```
 
+One section per bay, read by both halves of each; [Configuration](/opencode-cockpit/configuration/)
+has every key, or type `/cockpit-setup` and the agent writes it with you.
+
 Next: [Install](/opencode-cockpit/start/install/).
diff --git a/site/src/content/docs/status/commands.md b/site/src/content/docs/status/commands.md
index 03de98b7..4a4421b7 100644
--- a/site/src/content/docs/status/commands.md
+++ b/site/src/content/docs/status/commands.md
@@ -8,7 +8,7 @@ A statusline segment can be a shell command, fed the same JSON on stdin that Cla
 
 ```jsonc
 {
-  "statusline": {
+  "status": {
     "commands": { "mine": { "run": "~/.claude/statusline.sh", "intervalMs": 2000 } },
     "segments": [{ "type": "command", "name": "mine" }]
   }
@@ -62,7 +62,7 @@ Claude Code statuslines print one row per `echo`. Every row is kept; pick one wi
 
 ```jsonc
 {
-  "statusline": {
+  "status": {
     "commands": { "mine": { "run": "~/.claude/statusline.sh" } },
     "lines": [
       { "surface": "bottom", "segments": [{ "type": "command", "name": "mine", "row": 0 }] },
diff --git a/site/src/content/docs/status/configuration.md b/site/src/content/docs/status/configuration.md
index 61e0eaec..4cbdd065 100644
--- a/site/src/content/docs/status/configuration.md
+++ b/site/src/content/docs/status/configuration.md
@@ -3,12 +3,95 @@ title: Segments and layout
 description: Every built-in segment, the surfaces they sit on, and how a line survives a narrow terminal.
 ---
 
-Settings live in `~/.config/opencode-cockpit/config.json` for every project, `/.cockpit.json`
-for one, and on the plugin entry itself, which beats both.
+Settings live in the `status` section of `~/.config/opencode-cockpit/config.json` for every
+project, and of `/.cockpit.json` for one. Comments and trailing commas are fine. The keys
+every bay shares, the sidebar order and the names from before 0.9 are on
+[Configuration](/opencode-cockpit/configuration/).
+
+## Presets
+
+A whole line by name, built-ins only — nothing to install, nothing to write:
+
+```jsonc
+{ "status": { "preset": "default" } }
+```
+
+| Preset | Surface | What you get |
+| --- | --- | --- |
+| `sidebar` | sidebar | **the default**: a table — the window as one bar, the tokens in named rows, a proxy's budget, the branch's diff |
+| `minimal` | bottom | how full the context is, and what changed |
+| `default` | bottom | the bar, where the tokens went, what changed, how long |
+| `detailed` | bottom | everything the built-ins know, for a wide window |
+
+```
+Status
+████████████████
+tokens 85.2k · 43%
+in     265 · 0%
+out    60 · 0%
+cache  84.9k · 100%
+──────────────
+spend  $26.24
+avail  $173.76 · 87% left
+──────────────
+git    5f +312 -48
+```
+
+That is the `sidebar` preset, and what you get with no configuration at all. A row with nothing to
+say is not drawn — `spend` and `avail` with no proxy writing a budget, a hairline with nothing on
+one side of it. `{ "status": { "sidebar": false } }`, or `"surface": "bottom"`, draws the `default`
+line under the prompt instead. Anything you write beside a preset wins, so it is a starting point
+and not a mode; a name that is not a preset draws the surface's own line, with a `!` row naming the
+presets there are.
+
+## Changing a row or two
+
+`override` changes the preset's segments by name and keeps every other row, so the table goes on
+following the preset:
 
 ```jsonc
 {
-  "statusline": {
+  "status": {
+    "override": {
+      "git": { "against": "branch" },        // an object merges into that segment's settings
+      "write": false,                         // false drops it
+      "session.status": { "working": true }, // the turn's clock as well as a retry
+      "spend": "cost"                         // a name swaps it, in the same place
+    }
+  }
+}
+```
+
+A change applies to every segment of that name — `"sep": false` drops every hairline. With
+`segments` written too, the override applies to those; a line in `lines` takes its own. A project's
+`override` adds to the global one, key by key. A name that matches no segment is not silent:
+
+```
+! settings: override "gti" matches no segment in the sidebar preset — did you mean "git"?
+```
+
+Look at it before restarting: `bunx @opencode-cockpit/status preview --config ` reads the file
+as OpenCode will — `preset`, `sidebarRows`, `override` and the `!` rows — and stops on a file it
+cannot read or a flag it does not know. `--config -` reads it from stdin, as the file it will become
+(`--as global` or `--as project`), so a change can be seen before it is written anywhere.
+`--surface sidebar|bottom` draws there whatever the file says; the sidebar is 34 columns unless
+`--width` says otherwise. `--debug` names every row — `✓git` drew, `✗spend` drew nothing, `?gti` is
+no segment at all.
+
+```sh
+cat <<'EOF' | bunx @opencode-cockpit/status preview --config - --debug
+{ "status": { "override": { "git": { "against": "branch" } } } }
+EOF
+```
+
+The `status-setup` skill previews the same way, with the preview that came with your install —
+`cockpit_settings` names it under Previews — never `bunx`, which would fetch another release.
+
+## Your own line
+
+```jsonc
+{
+  "status": {
     "surface": "bottom",
     "separator": " │ ",
     "segments": [
@@ -22,7 +105,8 @@ for one, and on the plugin entry itself, which beats both.
 ```
 
 A segment is a built-in's name, or that name with settings. An unknown name is skipped rather than
-fatal: a config written against a newer version costs you a segment, not the line.
+fatal: a config written against a newer version costs you a segment, not the line. `segments` is the
+whole list and replaces the preset's; to change one row of a preset, `override` keeps the rest.
 
 ## Built-in segments
 
@@ -32,13 +116,18 @@ fatal: a config written against a newer version costs you a segment, not the lin
 | `git.branch` | current branch, dimmed on the default branch | |
 | `git.diff` | `+150 / -30` — what is uncommitted in the working tree | |
 | `model` | `claude-opus-5` | `full` |
-| `context` | how full the window is | `style`, `width`, `warnAt`, `dangerAt` |
-| `tokens` | `78.5k tok` | |
+| `context` | how full the window is | `style`: `percent` \| `bar` \| `solid` \| `gradient` \| `split`, `width`, `warnAt`, `dangerAt` |
+| `tokens` | `78.5k tok`; `tokens 85.2k · 43%` as a table row | `format`, `style`: `parts` \| `row` |
+| `title` | `Status`, bold: a column's heading | `text` |
+| `in` · `out` · `cache` · `write` | `cache  84.9k · 100%` — one part of the window and its share; nothing when zero | |
+| `sep` | a hairline between groups, drawn only with a row on either side | `width` |
+| `spend` · `avail` | `spend  $26.24`, `avail  $173.76 · 87% left` — a proxy's budget; nothing without one | `file` |
+| `git` | `git    5f +312 -48` — what is uncommitted; `"against": "branch"` counts the branch against where it forked (`… vs main`) | |
 | `cost` | session spend | `currency`, `showZero` |
 | `todo` | `3/7 todo` | `showComplete` |
-| `session.status` | `working 1m02s` since your prompt, or a retry and its countdown | |
+| `session.status` | `working 1m02s` since your prompt, or a retry and its countdown; `"working": false` (the sidebar preset) keeps only the retry | |
 | `session.time` | the conversation's age, or with `of: "turn"` how long the last answer took | `of`: `session` \| `turn`, `coarse` |
-| `diagnostics` | unhealthy LSP and MCP servers | |
+| `diagnostics` | `! name` for an unhealthy MCP or language server (OpenCode 2: MCP only); nothing while all are healthy | |
 | `version` | the bay's version | |
 | `text` | literal text | `value` |
 | `command` | a shell command's output | `name`, `row` |
@@ -85,7 +174,7 @@ them testable without a filesystem:
 
 ```jsonc
 {
-  "statusline": {
+  "status": {
     "modules": [""],
     "commands": { "tree": { "run": "git diff --shortstat", "intervalMs": 5000 } },
     "segments": [
@@ -103,42 +192,52 @@ a line — the `worktree` segment in `examples/bottom.ts` reads that and draws `
 
 ## Replacing OpenCode's own sidebar blocks
 
-The sidebar you see is not one panel — each block is an **internal plugin**, and `tui.json` can
-switch any of them off:
+The sidebar you see is not one panel — each block is a plugin of OpenCode's own, which its config
+can switch off. The names differ by version:
 
-```jsonc
-// ~/.config/opencode/tui.json
+```jsonc title="OpenCode 1 — ~/.config/opencode/tui.json"
 {
   "plugin": ["opencode-cockpit"],
   "plugin_enabled": { "internal:sidebar-context": false }
 }
 ```
 
-That removes OpenCode's own `Context / tokens / % used / spent` block, leaving the space to a
-`sidebar` line of your own. It is the honest way to avoid the same figure twice: rather than this
-bay staying quiet about what the host says, you turn off the half you would rather not read.
+```jsonc title="OpenCode 2 — ~/.config/opencode/cli.json"
+{ "plugins": ["opencode-cockpit", "-opencode.sidebar.context"] }
+```
+
+That removes OpenCode's own `Context / tokens / % used / spent` block, leaving the space to the
+table. It is the honest way to avoid the same figure twice: rather than this bay staying quiet about
+what the host says, you turn off the half you would rather not read. `/cockpit-setup` offers it when
+Status draws in the sidebar.
+
+On OpenCode 1 the other blocks can go the same way:
 
-| Plugin | What it draws |
+| Plugin (OpenCode 1) | What it draws |
 | --- | --- |
 | `internal:sidebar-context` | tokens, context percentage, spend |
 | `internal:sidebar-files` | files this session changed |
-| `internal:sidebar-todo` | the todo list |
 | `internal:sidebar-lsp` | language-server status |
 | `internal:sidebar-mcp` | MCP server status |
 | `internal:sidebar-footer` | the path and version at the bottom |
+| `internal:sidebar-todo` | the todo list — leave it on, nothing in Cockpit replaces it |
 | `internal:home-footer`, `internal:home-tips` | the home screen's furniture |
-| `internal:notifications` | toasts |
 
-`api.plugins.list()` prints the current set, so the list above can be checked rather than trusted.
+On OpenCode 2, `-internal:sidebar-context` does nothing — the block is `opencode.sidebar.context` —
+and its sidebar has three blocks of its own: `opencode.sidebar.context`, `opencode.sidebar.mcp` and
+`opencode.sidebar.footer`, each off with a `-` before it in `cli.json`'s `plugins`. It has no LSP or
+Todo block. Leave OpenCode 1's Todo block on: nothing in Cockpit replaces it. `api.plugins.list()` prints the current set, so the list above can be checked rather
+than trusted.
 
 ## The context segment
 
-Four styles, because a context meter is the segment people care most about.
+Five styles, because a context meter is the segment people care most about.
 
 | `style` | Draws |
 | --- | --- |
 | `percent` | `39% ctx` |
 | `bar` | a plain bar with end caps |
+| `solid` | one solid bar on a dark track, no figure — the `sidebar` table's |
 | `gradient` | a bar whose every cell is coloured by the level it stands for, green through amber to red |
 | `split` | one bar coloured by what fills it — cache, fresh input, output |
 
@@ -155,7 +254,7 @@ Both hide themselves where no context window was declared. A percentage needs a
 
 ```jsonc
 {
-  "statusline": {
+  "status": {
     "lines": [
       { "surface": "bottom", "segments": ["git.diff", "todo", "session.time"] },
       { "surface": "sidebar", "segments": ["context", "cost"] }
@@ -170,9 +269,10 @@ Each line takes its own settings:
 
 | Setting | Default |
 | --- | --- |
+| `override` | the section's `override` |
 | `separator` | `" · "` across, nothing down |
 | `stack` | `vertical` in the sidebar, `horizontal` elsewhere |
-| `maxRows` | `8`, vertical only |
+| `maxRows` | the bay's `sidebarRows` (8; the `sidebar` preset 14), vertical only |
 | `icons` | on |
 | `paddingLeft` / `Right` / `Top` / `Bottom` | per surface, to line up with OpenCode's own content |
 
@@ -192,17 +292,14 @@ On by default, in single-width glyphs — an emoji is two cells wide in most ter
 few, which is exactly what shears a fixed-width line. Turn them off with `"icons": false`, globally
 or per line, or set your own per segment with `"icon": "»"`.
 
-## sidebarOrder
+## Where it sits in the sidebar
 
-Where the line sits among the other bays in the sidebar. Lower draws first; the statusline defaults
-to 140, Subagents to 150 and Shell's list to 170, so the line is on top.
+The top-level `"sidebar"` list says, for every bay at once — Status first by default:
 
 ```json title="~/.config/opencode-cockpit/config.json"
-{
-  "statusline": { "surface": "sidebar", "sidebarOrder": 180 }
-}
+{ "sidebar": ["subagents", "shell", "status", "trail", "trust"] }
 ```
 
-That puts the line under the shells. `"sidebar": ["shell", "subagents", "status"]` at the top of the
-same file orders every bay at once. It has no effect on the bottom surface, where there is nothing
-to share the row with.
+That puts the table under the shells. It has no effect on the bottom surface, where there is nothing
+to share the row with. A `sidebarOrder` in the `status` section is no longer read; it is a `!` row
+pointing here. See [Configuration](/opencode-cockpit/configuration/#the-sidebar-order).
diff --git a/site/src/content/docs/status/drawing.md b/site/src/content/docs/status/drawing.md
index d215fb60..3e212741 100644
--- a/site/src/content/docs/status/drawing.md
+++ b/site/src/content/docs/status/drawing.md
@@ -82,7 +82,8 @@ rows(ctx: StatusContext): Piece[] {
 }
 ```
 
-A column keeps at most `maxRows` rows (default 8) — count them and raise it, or the extras vanish.
+A column keeps at most `sidebarRows` rows (default 8; a line in `lines` takes its own `maxRows`) —
+count them and raise it, or the extras vanish.
 The preview prints `↳ N dropped` when that happens.
 
 ## Alignment
@@ -110,22 +111,23 @@ find:
 
 ## Let an agent draw it
 
-Type **`/statusline`** in OpenCode. It draws nothing: it hands the agent already in your session a
-brief carrying what it cannot look up — which config file this project reads, what is drawing right
-now, your modules and any that failed to load, the preset and segment names — and ends by asking
-what you want it to show.
+Type **`/status-setup`** in OpenCode (`/statusline` until 0.9), or just ask for the change. It draws
+nothing: the agent loads the **`status-setup` skill** that ships with Status — the presets, every
+segment, the design rules this bay learned the expensive way, and the preview to check a line with —
+reads what is written now with `cockpit_settings`, and asks what you want it to show.
 
-The rules this bay learned the expensive way also ship **as a skill** inside the package, at
-`skills/statusline-design/`. Copy it in and the agent picks up the taste as well as the api:
+For another agent, Claude Code for example, the same skill ships in the package, at
+`skills/status-setup/` — the design rules are in its `references/design.md`. (`skills/statusline-design/`,
+its name before 0.9, is kept for 0.9 for anyone who copied it, and removed in 0.10.)
 
 ```sh
 # Claude Code, for this project or for every project
 mkdir -p .claude/skills
-cp -r node_modules/@opencode-cockpit/status/skills/statusline-design .claude/skills/
+cp -r node_modules/@opencode-cockpit/status/skills/status-setup .claude/skills/
 ```
 
-Its `description` fires on any request that mentions the statusline, a segment, or a module
-importing `@opencode-cockpit/status/segment`. What it carries is the taste, not the api: look at the
+Its `description` fires on any request to change what the statusline or the Status table shows.
+Outside OpenCode it has no `cockpit_settings` to read, so give it the file. What it carries is the taste, not the api: look at the
 thing before shipping it, `preview --watch` instead of restarting OpenCode, the six sample states a
 design gets wrong, the glyph traps above, and the rule that a number printed twice in one column is
 the thing the eye catches on.
diff --git a/site/src/content/docs/status/modules.md b/site/src/content/docs/status/modules.md
index e6f92c51..d3bd7034 100644
--- a/site/src/content/docs/status/modules.md
+++ b/site/src/content/docs/status/modules.md
@@ -26,7 +26,7 @@ export default {
 
 ```jsonc
 {
-  "statusline": {
+  "status": {
     "modules": ["~/.config/opencode-cockpit/statusline.ts"],
     "segments": ["burn", "git.diff"]
   }
@@ -87,7 +87,7 @@ empty box with no explanation.
 - **A segment that throws loses only its own place.** The rest of the line draws.
 - **A module that will not load says so on the line itself**, as a `!` row naming the file, along
   with a toast and an entry in OpenCode's log. Its segments never disappear silently.
-- **A column says how many rows did not fit**, as a dim `↳ N more — raise maxRows`. A row that
+- **A column says how many rows did not fit**, as a dim `↳ N more — raise sidebarRows`. A row that
   simply never appears reads as a broken segment, and is the more expensive thing to debug.
 
 ## Keeping history
@@ -121,17 +121,15 @@ session sitting at a steady 39% draws a flat wall of identical blocks.
 
 ## Worked examples
 
-Five ship with the package, every one loaded and asserted by the test suite so none of them can rot:
+Two ship with the package, both loaded and asserted by the test suite so neither can rot:
 
 - `examples/bottom.ts` — a complete line for a window with no sidebar: a capacity bar with a scale,
   a sparkline, spend per minute, cache share
-- `examples/sidebar.ts` — a quiet column **beside** OpenCode's own Context block
-- `examples/sidebar-full.ts` — a column that **replaces** that block, so it carries the percentage,
-  the token total and the spend the block carried
-- `examples/sidebar-budget.ts` — the same column as a table: a fixed label gutter, one bar with no
-  figure beside it, a budget read from a proxy, and the branch's whole diff
 - `examples/gallery.ts` — not a statusline: every technique the renderer can draw, labelled
 
+The sidebar examples (`sidebar.ts`, `sidebar-full.ts`, `sidebar-budget.ts`) became the `sidebar`
+preset in 0.9, built-ins only; a config still pointing at one gets a `!` row saying so.
+
 Copy one and cut it down. They are written to be edited, not run verbatim.
 
 ```sh
diff --git a/site/src/content/docs/status/overview.md b/site/src/content/docs/status/overview.md
index b9caf15a..cc095fa4 100644
--- a/site/src/content/docs/status/overview.md
+++ b/site/src/content/docs/status/overview.md
@@ -3,19 +3,49 @@ title: Statusline
 description: A statusline for OpenCode you can actually configure — and what it deliberately refuses to say.
 ---
 
-Statusline puts a line of live session state under your conversation, or a column of it in the
-sidebar. Bay 02, new in 0.3.0.
+Statusline puts a column of live session state at the top of the sidebar, or a line of it under
+your conversation. Bay 02, new in 0.3.0.
 
-![The statusline under an OpenCode conversation: a context bar at 40%, the token total with its cache, input and output parts, what is uncommitted, elapsed time and todo progress](/opencode-cockpit/media/statusline.png)
+## What it shows by default, and why
 
-That is the default line — what you get having written no configuration at all.
+With no configuration at all, a table in the sidebar:
 
-## What it shows by default, and why
+```
+Status
+████████████████
+tokens 85.2k · 43%
+in     265 · 0%
+out    60 · 0%
+cache  84.9k · 100%
+──────────────
+spend  $26.24
+avail  $173.76 · 87% left
+──────────────
+git    5f +312 -48
+```
+
+The `sidebar` preset, and the default since 0.9: how full the window is as one solid bar, the tokens
+broken into named rows with their share of it, a proxy's budget, and what is uncommitted — the work
+not saved anywhere yet (`"against": "branch"` counts the whole branch instead). A retry shows under
+the bar; the turn's own clock does not, OpenCode already shows one. Every number gets a word, in a
+fixed column so the figures line up; colour is a level (calm, then the warning, then the error),
+never a label. A row with nothing to say is not drawn: `write` with no cache writes, `spend` and
+`avail` with no proxy writing a budget ([Proxies](/opencode-cockpit/status/proxies/)),
+`diagnostics` while every MCP and language server is healthy (`! name` in red when one breaks). It sits
+beside OpenCode's own Context block; to keep only one, see
+[Replacing OpenCode's own sidebar blocks](/opencode-cockpit/status/configuration/#replacing-opencodes-own-sidebar-blocks).
+
+### At the bottom instead
+
+`{ "status": { "sidebar": false } }` — or `"surface": "bottom"` — draws the `default` line under the
+conversation:
+
+![The statusline under an OpenCode conversation: a context bar at 40%, the token total with its cache, input and output parts, what is uncommitted, elapsed time and todo progress](/opencode-cockpit/media/statusline.png)
 
 OpenCode's own furniture already carries a lot. Its footer has the path, the branch and the token
 count. Its sidebar has the context percentage and the spend. Its prompt has the agent and the model.
 
-The default line repeats one of those on purpose — the token count and the percentage — because it
+The bottom line repeats one of those on purpose — the token count and the percentage — because it
 says them better: a bar you read without looking, with the total's parts beside it, is a different
 instrument from `78.5K (39%)` in a corner. What stays out are the facts a second copy adds nothing
 to: the path, the branch, the model, the spend.
@@ -55,8 +85,8 @@ See [Proxies](/opencode-cockpit/status/proxies/).
 
 | `surface` | Where | Good for |
 | --- | --- | --- |
+| `sidebar` | the sidebar, stacked vertically — the default | a table: every figure with its word |
 | `bottom` | full-width line under the conversation | everything, when no sidebar is open |
-| `sidebar` | the sidebar, stacked vertically by default | trends and composition — the time dimension |
 
 There were three. The third sat inside the prompt box, which is both the narrowest place in the
 window and the one OpenCode already fills with the agent, the model and the elapsed time — so a line
@@ -68,7 +98,8 @@ Every segment carries a priority. A line too wide for its surface drops the lowe
 until it fits, so how full the context is survives a 60-column window and the version string does
 not. A hard right-cut would have kept whichever segments happened to sit on the left.
 
-A vertical line drops by `maxRows` instead, and each row is cut to the column's width.
+A column drops by `sidebarRows` instead (a line's own `maxRows`), says how many with a `↳ N more`
+row, and cuts each row to the column's width.
 
 ## Keys and commands
 
@@ -78,10 +109,14 @@ have given to a bay you actually operate would be charging you for the privilege
 
 | Command | Does |
 | --- | --- |
-| `/statusline` | Hands the agent a brief on your line: which config file this project reads, what is in it, and anything that failed to load |
-
-`/statusline` writes into the conversation rather than opening a panel, because the useful next step
-is usually "change this for me", and the agent needs to know what it is changing.
+| `/status-setup` | The agent sets your line up with you, with the `status-setup` skill that ships with Status: a preset to start from, the segments, the sidebar or the bottom, and the preview before it is called done |
+| `/cockpit-setup` | The same for every bay at once: which show, where, in what order — see [Configuration](/opencode-cockpit/configuration/) |
+
+`/status-setup` asks the agent rather than opening a panel, because the useful next step is usually
+"change this for me". The skill reads what is written now with `cockpit_settings`, so it knows what
+it is changing; asking in plain words ("put the statusline at the bottom") loads it too. From the
+home screen it opens a conversation. `/statusline`, its name until 0.9, still works for one release
+and says the new name.
 
 ## Three ways to configure it
 
diff --git a/site/src/content/docs/status/proxies.md b/site/src/content/docs/status/proxies.md
index 66238830..f4d806f2 100644
--- a/site/src/content/docs/status/proxies.md
+++ b/site/src/content/docs/status/proxies.md
@@ -49,7 +49,7 @@ estimate cannot match that. Read the real figure with a
 
 ```jsonc
 {
-  "statusline": {
+  "status": {
     "commands": {
       "budget": { "run": "curl -sf $LLM_PROXY/spend | jq -r .remaining", "intervalMs": 30000 }
     },
diff --git a/site/src/content/docs/subagents/overview.md b/site/src/content/docs/subagents/overview.md
index fc430227..8fe3bf18 100644
--- a/site/src/content/docs/subagents/overview.md
+++ b/site/src/content/docs/subagents/overview.md
@@ -16,36 +16,40 @@ Or through the bundle, where it is on by default.
 
 ## In the sidebar
 
-A **Subagents** block: every subagent of the conversation you are in, its type and task, and under
-it what it is doing now — with how many calls and how long the whole run has taken on the right.
-Working ones come first, oldest first, so a late runner never sits under a pile of finished ones.
+A **Subagents** block: every subagent of the conversation you are in, its agent and task, and under
+a working one what it is doing now — with how many calls and how long the run has taken on the
+right. Working ones come first, oldest first, so a late runner never sits under a pile of finished
+ones.
 
 ```
 Subagents    1 running · 1 needs you
+
 ⠹ explore Map the authentication fl…
   └ grep "session" sr… 6 calls · 51s
-â—‹ Fix the failing login test
+â—‹ general Fix the failing login test
   └ waiting for permi… 3 calls · 50s
-� Update README for t… 3 calls · 28s
+� general Update README for the… 28s
 ```
 
 A spinner is working and `○` waits on you — the two in colour, because they are the two that may
 need you; a red `â—�` failed. A finished subagent is a quiet `â—�` and one row; one you stopped says
 `stopped`, quietly too, since stopping is not failing. The heading counts every state, and keeps
-what needs you when the column is narrow. The agent is named unless it is `general`, the one a
-subagent is when nobody chose. A subagent that launched its own has them indented under it, and subagents with the same parent,
-agent and task — a helper asked again and again — are one entry with a count (`×6`). A finished
-nested one leaves the sidebar after `hideNestedAfter` seconds (30 by default); the heading still
-counts it and the pane still reaches it. Working subagents are always shown; finished ones fold
-into a count past `sidebarRows`, and leave after `hideFinishedAfter` minutes if you set it. No
-subagents, no block.
-
-The sidebar reads statusline, subagents, shells, top to bottom — `"sidebar"` in
-[Cockpit's config](/configuration/) reorders it.
+what needs you when the column is narrow. Every row names its agent, muted — `general` too — in one
+column as wide as the longest agent shown, eight cells at most, so the titles line up; a subagent
+with no title is named by its task's first words. A finished subagent says how long it ran and
+nothing else: its calls and rounds are in the pane. A subagent that launched its own has them indented under it, and subagents with the
+same parent, agent and task — a helper asked again and again — are one entry with a count (`×6`). A
+finished nested one leaves the sidebar after `hideNestedAfterSeconds` (30 by default); the heading
+still counts it and the pane still reaches it. Working subagents are always shown; finished ones
+fold into a count past `sidebarRows`, and leave after `hideFinishedAfterMinutes` if you set it. With
+none yet the block says so — its heading and `none yet` — unless `hideWhenEmpty` is on.
+
+The sidebar reads status, subagents, shells, trail, trust, top to bottom — `"sidebar"` in
+[Cockpit's config](/opencode-cockpit/configuration/#the-sidebar-order) reorders it.
 
 ## The pane
 
-Click a subagent — or press `ctrl+x w`, or run `/subagents` for the one working now — and its run
+Click a subagent — or press `ctrl+x d`, or run `/subagents` for the one working now — and its run
 opens in a pane on the right: half the window, or all of it with `w`. A click outside closes it.
 
 Under its name: the model, whether it runs in the background, who launched it, and its calls, steps
@@ -85,9 +89,10 @@ the run as it grows; scroll or move the cursor and it stays where you put it unt
 | `b` | Move it to the background, so the main agent carries on (OpenCode's own `ctrl+b`) |
 | `i` | Details: model, what it is denied, calls by tool, tokens, cost |
 | `w` | Half the window, or all of it (remembered) |
-| `[` `]` | Another subagent of this conversation |
+| `[` `]` · `�` `→` | Another subagent of this conversation |
 | `d` `u` · `g` `G` | Page down · up · to the start · follow the run |
-| `esc` `q` | Back to the conversation |
+| `?` | Every key the pane takes; `esc` hides them again |
+| `esc` `q` | Let go of the cursor, then back to the conversation |
 
 ## Follow-ups keep their context
 
@@ -156,18 +161,27 @@ on, under the same condition on OpenCode 1.
 
 ## Settings
 
-In the bundle's entry (`"subagents": { … }`) or this package's own:
+In the `subagents` section of `~/.config/opencode-cockpit/config.json`, or a project's
+`.cockpit.json` — read by both halves:
+
+```jsonc
+{ "subagents": { "sidebarRows": 6, "hideWhenEmpty": false, "hideFinishedAfterMinutes": 60 } }
+```
 
 | Setting | Default | |
 | --- | --- | --- |
 | `sidebarRows` | `6` | Subagents shown before the rest fold into a count, working ones first |
-| `hideFinishedAfter` | unset | Minutes a finished subagent stays in the sidebar; unset keeps it for the conversation |
-| `hideNestedAfter` | `30` | Seconds a finished *nested* subagent — one a subagent launched — stays in the sidebar; a negative number keeps them |
-| `sidebarOrder` | `150` | Where the block sits among sidebar blocks; lower draws first |
-| `keybinds` | `{ "cockpit.subagents.open": "w" }` | The key that opens the one working now |
+| `hideWhenEmpty` | `false` | With no subagents the block says `none yet` under its heading; `true` draws nothing |
+| `hideFinishedAfterMinutes` | unset | Minutes a finished subagent stays in the sidebar; unset keeps it for the conversation |
+| `hideNestedAfterSeconds` | `30` | Seconds a finished *nested* subagent — one a subagent launched — stays in the sidebar; a negative number keeps them |
+| `keybinds` | `{ "cockpit.subagents.open": "d" }` | The key that opens the one working now |
 | `guidance` | `true` | Tell the main agent about background subagents and follow-ups (agent side) |
+| `enabled` | `true` | `false` switches both halves off; so does `features.subagents: false` |
 
-Switch the whole bay off in the bundle with `{ "features": { "subagents": false } }`.
+Where the block sits is the top-level `"sidebar"` list's to say. The names from before 0.9 —
+`hideFinishedAfter`, `hideNestedAfter`, `sidebarOrder` — are no longer read: the block shows a `!`
+row naming the new one, and `/cockpit-setup` fixes it. See
+[Configuration](/opencode-cockpit/configuration/).
 
 ## Known limits
 
diff --git a/site/src/content/docs/trail/overview.md b/site/src/content/docs/trail/overview.md
new file mode 100644
index 00000000..94fa90ae
--- /dev/null
+++ b/site/src/content/docs/trail/overview.md
@@ -0,0 +1,122 @@
+---
+title: Trail
+description: What a conversation made — the PRs, tickets and pages your agent created or changed, kept per conversation, one click from the page, and which conversation made each.
+---
+
+A conversation produces things that outlive it: pull requests, often in more than one repository,
+tickets created or moved, a deploy, a page. Today the only record is the chat. Trail keeps it, per
+conversation, and answers both ways: *what did this conversation make?* and *which conversation
+made PR #33 — take me there.*
+
+```sh
+opencode plugin @opencode-cockpit/trail@0.9.0 --global --force     # OpenCode 1
+opencode plugin add @opencode-cockpit/trail@0.9.0                   # OpenCode 2
+```
+
+Or through the bundle, where it is on by default (`features.trail: false` turns it off).
+
+**There is nothing to set up.** People reach the same systems in different ways — `gh`, an MCP
+server, a company CLI — and the agent always knows what it just did. So the agent writes the trail
+and Cockpit keeps, orders and shows it. Trail has no GitHub or Jira client and stores no
+credentials.
+
+![The /trail dialog on All conversations: PR #42 under COM-1736, with the conversation that opened it](/opencode-cockpit/media/trail.png)
+
+## How things get into it
+
+- **The agent records them** with `trail_add`, right after it creates or changes something outside
+  the repository's files. Its system prompt says so on every request, subagents included. A
+  subagent's records belong to the conversation and name the subagent.
+- **A safety net, never automatic.** A PR or issue link in the output of something the agent *ran* —
+  a shell command, an MCP call — that it has not recorded is put to it on its next request as a
+  choice: *seen in output — record it if you created or changed it*. Links in files it read or pages
+  it fetched never count. Nothing is added without the agent or you.
+- **You add one** with `/link`, then paste the link, and a note if you like.
+
+The same link again updates its record — the actions become a history, `created → updated` — and a
+better title replaces the old one. Which system a thing belongs to comes from its link: GitHub,
+Jira, Confluence, Claude, Linear, or else its domain. Query parameters that look like secrets are
+dropped before anything is stored, and only `http(s)` links are ever opened.
+
+## In the sidebar
+
+On by default, after Shells. What this conversation made, grouped by the ticket it was for, newest
+work first: its name, its title, its system, what this conversation last did and when — `now`,
+`12m ago`, `2h ago`. A record with no ref gives its title the ref's column.
+
+```
+Trail                                    9
+
+COM-1801
+  a1b2c3d  Bump the pr…          12m ago
+  ENG-42   Retry the s…  Linear  15m ago ↗
+COM-1736   Bundle desy…    Jira   2h ago ↗
+  PR #33   0.8: Trust,…  GitHub   1h ago ↗
+  PR #12   Landing: Tr…  GitHub   2h ago ↗
++ 4 more · /trail
+```
+
+There is no
+status — where a PR stands now belongs to GitHub, one click away; a trail that said "open" for a
+merged PR would be worse than none.
+
+- **A row with `↗` opens the page** in your browser.
+- A row without a page opens `/trail` on it; `+ N more` opens `/trail`.
+- Empty, the block says `none yet`; `hideWhenEmpty` hides it until there is something to show.
+
+## `/trail`
+
+`/trail`, `ctrl+x f`, or the palette ("cockpit trail"): **This conversation** and **All
+conversations** in the project (`tab`), grouped the same way. Under a thing several conversations
+touched, each one is listed — a deleted conversation keeps its records, marked.
+
+| key | |
+| --- | --- |
+| `enter` | open the page |
+| `g` | go to the conversation that made it |
+| `c` | copy the link |
+| `x` | remove it from the trail |
+| `m` | copy the trail as a markdown list |
+| `/` | search title, ref, kind and system |
+| `esc` | close |
+
+## The agent's tools
+
+| tool | |
+| --- | --- |
+| `trail_add` | `title`, and `url` or `ref`; optional `kind`, `action`, `for`, `note` — all free text |
+| `trail_list` | this conversation's trail, or with `all` the project's and which conversation made each; `query` filters |
+
+`trail_list` answers with the same facts in the same order as `/trail`. What the conversation
+produced is also rebuilt into its system prompt on every request, from the trail itself, so the
+agent still knows after a long conversation is compacted.
+
+## Settings
+
+```jsonc
+// ~/.config/opencode-cockpit/config.json, or a project's .cockpit.json
+{
+  "trail": { "sidebar": true, "sidebarRows": 5, "hideWhenEmpty": false }
+}
+```
+
+`enabled` and `keybinds` work as in every bay; the block's place is the top-level `"sidebar"` list's
+to say. See [Configuration](/opencode-cockpit/configuration/).
+
+## Team conventions
+
+Optional. A few lines in your `AGENTS.md` shape what gets recorded — they do not make it happen.
+`/cockpit-setup`'s second phase offers your ticket prefix, read from your branch names and commits,
+and writes it there for you ([how](/opencode-cockpit/configuration/#the-easy-way-cockpit-setup)); or
+by hand:
+
+```md
+## Trail
+- Tickets are Jira keys like COM-1234: pass the PR's ticket as `for`.
+- Put the Confluence space in a page's title: "WEB · Release notes 0.8".
+```
+
+## Where it keeps things
+
+One append-only file per project, outside it, under `~/.local/share/opencode-cockpit/trail/`.
+Every window and the agent append to it; nothing is ever rewritten.
diff --git a/site/src/content/docs/trust/overview.md b/site/src/content/docs/trust/overview.md
index 8b7ae27f..7e630a43 100644
--- a/site/src/content/docs/trust/overview.md
+++ b/site/src/content/docs/trust/overview.md
@@ -268,9 +268,12 @@ In the bundle's entry (`"trust": { … }`), the package's own, or the `trust` se
 | `enabled` | `true` | `false` turns Trust off |
 | `sidebar` | `false` | Show the block in the sidebar. The palette's "Show or hide Trust in the sidebar" flips it for the session |
 | `sidebarRows` | `3` | Answers listed in the sidebar |
-| `sidebarOrder` | `160` | Where the block sits in the sidebar; lower draws first |
 | `keybinds` | `{ "cockpit.trust.ledger": "p" }` | The key that opens the ledger |
 
+Where the block sits is the top-level `"sidebar"` list's to say — Trust last by default; a
+`trust.sidebarOrder` from before 0.9 is no longer read. See
+[Configuration](/opencode-cockpit/configuration/#the-sidebar-order).
+
 ## Where it keeps what it learned
 
 `~/.local/share/opencode-cockpit/trust/-/events.ndjson` — outside the project, so it
diff --git a/site/src/content/docs/updater/overview.mdx b/site/src/content/docs/updater/overview.mdx
index 92eeda29..fb1ba7f0 100644
--- a/site/src/content/docs/updater/overview.mdx
+++ b/site/src/content/docs/updater/overview.mdx
@@ -80,6 +80,15 @@ It prints the same list and the same review, asks before writing anything, and r
 the same way. `--dry-run` shows the plan and writes nothing, `--only ` updates one plugin, and
 `--yes` does not ask.
 
+## On OpenCode 2
+
+The Updater edits OpenCode 1's files only. On OpenCode 2, `/plugins-update` says what to do instead:
+change the version in your `opencode.json` plugin entry, run `opencode service restart`, and restart
+OpenCode. The restart matters — OpenCode 2's background service loads plugins once, when it starts,
+and keeps the old agent side until it restarts. `npx opencode-cockpit@latest update` says the same
+when it finishes on a machine with OpenCode 2, and [doctor](/opencode-cockpit/help/doctor/) warns
+when the service still runs an older Cockpit.
+
 ## Where it will not reach
 
 - **A project's root `opencode.json`.** `opencode plugin` writes a project's `.opencode/` directory
@@ -100,4 +109,5 @@ again. To turn it off:
 { "updater": { "updateCheck": false } }
 ```
 
-Shell's old `ui.updateCheck: false` is honoured too.
+Shell's old `ui.updateCheck` is no longer read: a file that still has it gets a doctor line naming
+`updater.updateCheck`, and `/cockpit-setup` moves it here.
diff --git a/site/src/data/landing.ts b/site/src/data/landing.ts
index df9848d0..b788d954 100644
--- a/site/src/data/landing.ts
+++ b/site/src/data/landing.ts
@@ -11,6 +11,7 @@ export const nav = {
     { label: "Statusline", href: "#status" },
     { label: "Updater", href: "#updater" },
     { label: "Subagents", href: "#subagents" },
+    { label: "Trail", href: "#trail" },
     { label: "Trust", href: "#trust" },
     { label: "Platform", href: "#platform" },
     { label: "Install", href: "#install" },
@@ -37,9 +38,9 @@ export const hero = {
 
 export const specs = [
   { value: "1", label: "daemon, shared by every window" },
-  { value: "9", label: "agent tools" },
-  { value: "35", label: "watch presets" },
-  { value: "14", label: "statusline segments" },
+  { value: "18", label: "agent tools" },
+  { value: "34", label: "watch presets" },
+  { value: "23", label: "statusline segments" },
 ]
 
 export const compare = {
@@ -60,7 +61,7 @@ export const compare = {
 }
 
 export const platform = {
-  number: "08",
+  number: "09",
   kicker: "The platform",
   title: "What every capability inherits.",
   intro:
@@ -80,8 +81,8 @@ export const platform = {
       title: "One config for both halves",
       body:
         "On OpenCode 1, agent plugins are configured in opencode.json, interface plugins in tui.json. " +
-        "Cockpit reads a single file — global, then project, then plugin entry — and ignores a broken one " +
-        "rather than failing.",
+        "Cockpit reads one file for both — global, then project, one section per bay — and a file it " +
+        "cannot read is a ! row and the defaults, never a blank sidebar.",
     },
     {
       icon: "panel",
@@ -111,7 +112,7 @@ export const platform = {
 export const bays = {
   number: "01",
   kicker: "What is fitted",
-  title: "Six instruments today. One switch each.",
+  title: "Seven instruments today. One switch each.",
   intro:
     "Every bay is its own npm package with a switch in config. They share the daemon, the config " +
     "file and the keys, so the second costs nothing and moving between them changes nothing you " +
@@ -135,7 +136,7 @@ export const bays = {
         "A resolve is checked against the file before it counts",
         "Notes live on the branch, so they outlive the chat",
         "The agent can leave notes of its own",
-        "Uncommitted, the branch, or just this conversation",
+        "Uncommitted, or the whole branch against its parent",
       ],
       foot: ["3 agent tools", "12 filetypes", "@opencode-cockpit/review"],
       docs: "/review/overview/",
@@ -170,7 +171,7 @@ export const bays = {
         "Time and idle limits, with the reason recorded",
         "Reuses a finished shell instead of piling up",
       ],
-      foot: ["9 agent tools", "35 watch presets", "@opencode-cockpit/shell"],
+      foot: ["8 agent tools", "34 watch presets", "@opencode-cockpit/shell"],
       docs: "/shell/overview/",
       media: {
         kind: "casts" as const,
@@ -188,7 +189,7 @@ export const bays = {
       tagline: "The session, at a glance",
       state: "live",
       status: "Available",
-      gains: { agent: "—", you: "What the session is costing you" },
+      gains: { agent: "A skill to design the line with you", you: "What the session is costing you" },
       blurb:
         "How full is the context? Where did the tokens go? What has changed? OpenCode answers the " +
         "first in a corner and the rest not at all. The statusline answers them where you are already " +
@@ -196,18 +197,18 @@ export const bays = {
       points: [
         "A capacity bar that means something at a glance",
         "Tokens split into cache, input and output",
-        "Fourteen segments, or your own in TypeScript",
+        "A table in the sidebar by default, or a line under the prompt",
+        "Twenty-three segments, or your own in TypeScript",
         "Your Claude Code statusline script runs unchanged",
-        "Colours follow whatever theme you run",
         "Silent about anything the host already says better",
       ],
-      foot: ["14 segments", "2 surfaces", "@opencode-cockpit/status"],
+      foot: ["23 segments", "2 surfaces", "@opencode-cockpit/status"],
       docs: "/status/overview/",
       media: {
         kind: "image" as const,
         src: "/media/statusline.png",
         alt: "The statusline under an OpenCode conversation: a context bar at 40 per cent, the token total with its cache, input and output parts, the session diff, and elapsed time",
-        caption: "the default line · no configuration written at all",
+        caption: "the bottom line · { \"status\": { \"sidebar\": false } }",
       },
     },
     {
@@ -267,7 +268,7 @@ export const bays = {
         "Stop one, or move a blocking one to the background",
         "The same on OpenCode 1 and 2",
       ],
-      foot: ["sidebar + pane", "ctrl+x w · /subagents", "@opencode-cockpit/subagents"],
+      foot: ["sidebar + pane", "ctrl+x d · /subagents", "@opencode-cockpit/subagents"],
       docs: "/subagents/overview/",
       media: {
         kind: "casts" as const,
@@ -281,6 +282,44 @@ export const bays = {
         ],
       },
     },
+    {
+      id: "trail",
+      name: "Trail",
+      tagline: "What a conversation made — and which conversation made it",
+      state: "live",
+      status: "Available",
+      gains: {
+        agent: "Records the PRs, tickets and pages it creates, and can say which conversation made one",
+        you: "Everything a conversation made, in the sidebar, one click from the page",
+      },
+      blurb:
+        "A conversation opens pull requests in two repositories, moves a ticket, publishes a page — and " +
+        "the only record is the chat. Trail keeps it: the agent records what it creates or changes with " +
+        "whatever tools you use, and Cockpit orders it by the ticket it was for and puts it in the " +
+        "sidebar, a click from the page. Months later, `/trail` says which conversation opened PR #33 and " +
+        "takes you back into it.",
+      points: [
+        "No setup: the agent writes the trail, with gh, an MCP server, any tool",
+        "Grouped by the work — a ticket heads its PRs",
+        "Click a row: the page opens in your browser",
+        "Which conversation made it, and a jump back into it",
+        "A link it printed and never recorded is put back to it, as a choice",
+        "Copy a conversation's trail as markdown, for the PR or the standup",
+      ],
+      foot: ["sidebar · /trail · /link", "trail_add · trail_list", "@opencode-cockpit/trail"],
+      docs: "/trail/overview/",
+      media: {
+        kind: "casts" as const,
+        clips: [
+          {
+            cast: "trail",
+            label: "What a conversation shipped",
+            hint: "PR → recorded → asked from another conversation → g",
+            caption: "tapes/trail.ts · a real model turn — the prompt never mentions Trail",
+          },
+        ],
+      },
+    },
     {
       id: "trust",
       name: "Trust",
@@ -319,7 +358,7 @@ export const bays = {
 
 /** What is coming, and the reason it is next. */
 export const next = {
-  number: "09",
+  number: "10",
   kicker: "What is next",
   title: "One bay at a time, and only what can be built.",
   intro:
@@ -339,7 +378,7 @@ export const next = {
 }
 
 export const install = {
-  number: "10",
+  number: "11",
   kicker: "Install",
   title: "Two minutes, then ask it to start something.",
   modes: [
@@ -366,13 +405,15 @@ export const install = {
       command: "opencode plugin add opencode-cockpit@0.8.0",
       note:
         "The same package — it carries a half for each OpenCode. One entry in opencode.json " +
-        'loads both halves. What differs on OpenCode 2.',
+        "loads both halves. After installing or updating, run opencode service restart: " +
+        "OpenCode 2's background service loads plugins only when it starts. " +
+        'What differs on OpenCode 2.',
     },
   ],
   steps: [
     "Run the command above — on OpenCode 1 it writes both plugin entries, on OpenCode 2 one entry loads both halves.",
-    "Restart OpenCode. The daemon starts on first use and exits when idle.",
-    "Optional: put kinds, watch presets and defaults in ~/.config/opencode-cockpit/config.json.",
+    "Restart OpenCode — on OpenCode 2, opencode service restart as well. The daemon starts on first use and exits when idle.",
+    "Optional: type /cockpit-setup and the agent sets up ~/.config/opencode-cockpit/config.json with you — which bays show, where, in what order — then, if you want, tunes Cockpit to how your project works.",
     "Something off? npx opencode-cockpit@latest doctor checks your setup and prints the fix.",
   ],
 }
diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro
index 8047a595..0015919f 100644
--- a/site/src/pages/index.astro
+++ b/site/src/pages/index.astro
@@ -13,7 +13,7 @@ import Closing from "../components/landing/Closing.astro"
 ---