diff --git a/README.md b/README.md index b7abffa1..3bd1cab0 100644 --- a/README.md +++ b/README.md @@ -2,594 +2,92 @@ [![CI](https://github.com/Codestz/opencode-cockpit/actions/workflows/ci.yml/badge.svg)](https://github.com/Codestz/opencode-cockpit/actions/workflows/ci.yml) [![npm](https://img.shields.io/npm/v/opencode-cockpit?color=%23cb3837&label=opencode-cockpit)](https://www.npmjs.com/package/opencode-cockpit) -[![npm](https://img.shields.io/npm/v/@opencode-cockpit/shell?color=%23cb3837&label=%40opencode-cockpit%2Fshell)](https://www.npmjs.com/package/@opencode-cockpit/shell) -[![npm](https://img.shields.io/npm/v/@opencode-cockpit/status?color=%23cb3837&label=%40opencode-cockpit%2Fstatus)](https://www.npmjs.com/package/@opencode-cockpit/status) -[![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.** +**Your agent, on instruments.** Cockpit adds what [OpenCode](https://opencode.ai) doesn't ship with: +shells that keep running, subagents you can watch, a pull request in the terminal, and a trail of +everything each conversation made. -**[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) +![One OpenCode session with Cockpit: the tests run in the background, fail, and pass after a fix; subagents work in parallel; the PR lands in Trail under its ticket; the context bar fills](media/hero.gif) -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 -what it is doing. - -```sh -opencode plugin opencode-cockpit@0.9.0 --global --force -``` - ---- - -### 🖥️ Shell — terminals that keep running - -Your agent starts a dev server and the tool call blocks until you kill it. It backgrounds one -instead and loses the output. **Shell** gives it terminals with a real PTY that outlive the turn, -wait for a port or a pattern, and hand back the part that matters — and gives you a panel where -every one of them reports its own health. - -![The shells panel: a dev server running under the conversation](media/dock.gif) - -*A real recording. Every demo here is generated from a live OpenCode session by -[`bun run record`](CONTRIBUTING.md) and re-run on release, so none of them can drift from what -ships.* - -**8 agent tools · 34 watch presets · [docs](https://codestz.github.io/opencode-cockpit/shell/overview/) · [`@opencode-cockpit/shell`](packages/shell)** - ---- - -### 🔍 Review — a pull request in the terminal - -Reviewing what your agent wrote means reading a diff in a chat log and describing your objection in -prose. **Review** gives you the diff where the work happened, comments on the lines they are about, -and an agent that can read them, answer them and mark them resolved — which a chat message cannot do. - -Comments live on the branch rather than in the chat, so they outlive the conversation. `s` hands them -over; the agent fetches them with `review_list`, changes the code, and answers with -`review_reply resolved=true`. That resolve is **checked against the file**: a thread remembers the -lines it was written against, so "done" over an untouched file is recorded as a reply and the thread -stays open for you. - -**3 agent tools · 12 filetypes · [docs](https://codestz.github.io/opencode-cockpit/review/overview/) · [`@opencode-cockpit/review`](packages/review)** - ---- - -### 📊 Statusline — what the session is costing 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) - -*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.* - -**23 segments · 2 surfaces · [docs](https://codestz.github.io/opencode-cockpit/status/overview/) · [`@opencode-cockpit/status`](packages/status)** - ---- - -### 🔄 Updater — every plugin, and what it is really running - -OpenCode installs a plugin once and never resolves its spec again, so `@latest` quietly means *the -release that was newest the day you installed it* — and nothing anywhere says which one that was. -**Updater** lists every plugin you have, what is running beside what your config says and what is -published, and updates the ones you pick. It pins an exact version through OpenCode's own -`opencode plugin`, clears the stale cache, and reads every file back before calling it done. - -![The updater in OpenCode: a plugin frozen behind @latest at 1.2.3 and one pinned behind, reviewed, updated, and both confirmed on disk](media/updater.gif) - -`/plugins-update` inside OpenCode, or `npx opencode-cockpit@latest update` from a shell — which -works whatever version you are stuck on, because it comes from npm rather than from the copy that -cannot update itself. - -**Every plugin, not just this one · [docs](https://codestz.github.io/opencode-cockpit/updater/overview/) · [`@opencode-cockpit/updater`](packages/updater)** - ---- - -### 🛰️ Subagents — what they are doing, while they do it - -When the main agent hands work to a subagent you get one line in the chat and nothing about what it -is doing. **Subagents** puts every subagent in the sidebar with what it is doing right now — `grep -"session" src/auth/** 51s` — and a click opens its whole run in a pane: the task, its thinking, every -shell command and file change as a box with its output, and the answer as it is written. - -Then it makes them reusable. Ask the main agent for a follow-up on a subagent's work and it continues -*that* subagent — which already read the code — instead of starting a new one. Press `m` to message a -subagent yourself: the main agent is told what it answered, without a turn being spent on it. `x` -stops one (and tells the main agent why), `b` moves a blocking one to the background. The main agent -can read any of them in full with `subagents_read`, and wait on the ones in the background with -`subagents_wait`. - -![A subagent at work beside the conversation: its run in a pane, a question put to it from the pane, and the exchange added to the main conversation](media/subagents.gif) - -**Sidebar + pane · 3 agent tools · follow-ups keep context · OpenCode 1 and 2 · [docs](https://codestz.github.io/opencode-cockpit/subagents/overview/) · [`@opencode-cockpit/subagents`](packages/subagents)** - ---- - -### 🧭 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 -approving `docker compose -p prod down -v` because you once approved `docker compose -p cockpit up`. -**Trust** sits between: approve the *exact same* command three times in a row and it answers for -you — and records every answer, in the ledger and the log, and in the sidebar if you turn it on. A reject resets the count, `rm` and `git push` and -`--force` cost eight approvals instead of three, and a rule you wrote to be asked (`"git push *": -"ask"`) is never answered. `/trust` opens on what it did for you and what it is close to trusting; -`l` opens the ledger, every rule as a tree of families (`git -C x status` is `git status`) with a -card that says exactly what a rule answers, its history, and how to stop it. `w` trusts a whole -family, but only when you press it. - -![Trust's activity screen: five prompts answered today with the reason for each, commands one approval away from being trusted with their meters, a dangerous one at 5 of 8, and a warning about OpenCode's own broad "always" approvals](media/trust-activity.png) - -![Trust's ledger: a tree of command families on the left, and a card for the selected command with exactly what it answers, what still asks, its approval history and the buttons to revoke it or trust its family](media/trust-ledger.png) - -*Drawn by `bunx @opencode-cockpit/trust preview` from a sample project, the same rows the dialog draws.* - -**Sidebar + ledger · exact commands, per agent · OpenCode 1 and 2 · [docs](https://codestz.github.io/opencode-cockpit/trust/overview/) · [`@opencode-cockpit/trust`](packages/trust)** - ---- - -Each 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 already set up. - -## Shell, by example - -**"Start the dev server and wait until it's actually ready."** -The agent starts it in a real terminal and blocks on the port opening — not a guess, not a sleep: -``` -shell_start command="npm run dev" waitFor={ port: 5173 } -→ condition met: port is accepting connections - 1| VITE v7.3.1 ready in 431 ms -``` - -**"Run the tests, keep working, tell me if they fail."** -The suite runs in the background. When it exits, the agent is messaged once, with the error line -already picked out: -``` - -exited with code 1 after 48s -last output: 37| FAIL src/auth.test.ts > refresh token expiry -``` - -**"How is DB Monitoring doing?"** -Shells are shared across sessions and can be addressed by name: -``` -shell_read name="DB Monitoring" -``` - -**Logs that don't eat your context.** Colour codes stripped, progress-bar redraws collapsed to -their final frame, repeated lines folded to `(×12)`, and every read returns a cursor so the next -one only brings what's new. For full-screen programs (`vitest --ui`, `htop`, prompts) the agent can -ask for the *screen* instead of the log. - -**It notices breakage on its own.** Watch a process that never exits and the agent hears only about -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 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. - -**You can find things in a huge log.** `/` in the console filters the scrollback to matching lines, -keeping line numbers and highlighting matches — and output keeps the colours the program printed. - -**It can type.** Prompts, REPLs, migration wizards: `shell_send` sends text or named keys -(`ctrl+c`, `up`, `enter`) and returns whatever the program printed back. - -### Why not just `bash`? - -| | Built-in `bash` tool | Cockpit Shell | -|---|---|---| -| Long-running processes | Blocks until exit | Runs in the background, survives the turn | -| Knowing something is ready | Guess, or sleep and poll | Blocks on a port, a pattern, silence or exit | -| Interactive programs | Not possible (no TTY) | Real PTY: prompts, REPLs, ctrl+c | -| Reading output | Whole log, every time | Clean lines from a cursor, with grep | -| Noticing a break later | Never | Watchers report health changes | -| Your visibility | None until it finishes | Live panel, console and sidebar | -| After OpenCode restarts | Gone | Still running | - -## For you, not just the agent - -| Key / command | Does | -|---|---| -| `/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` | 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 | -| `/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. +*Drawn by Cockpit's own renderers, the same code that runs in your terminal: a test run failing and +passing in the background, subagents at work, the PR landing in Trail, the context filling. Every +bay, live, is on the [site](https://codestz.github.io/opencode-cockpit/).* ## Install -**Everything** +**OpenCode 1** ```sh opencode plugin opencode-cockpit@0.9.0 --global --force ``` -**Only what you want** - -```sh -opencode plugin @opencode-cockpit/shell@0.9.0 --global --force -``` - -The version is pinned on purpose. OpenCode resolves a plugin spec once and never again, so a -bare `opencode-cockpit` or `@latest` stays on whatever it installed first. `--force` replaces an -entry you already have, so the same line is also how you move to a newer release. - -**Stuck on an old version?** This runs outside OpenCode, from npm, so it works whatever you have -installed — and shows every plugin you have, not just this one: +**OpenCode 2** ```sh -npx opencode-cockpit@latest update # or: bunx opencode-cockpit@latest update +opencode plugin add opencode-cockpit@0.9.0 +opencode service restart # after installing, and after every update ``` -Restart OpenCode. Requires OpenCode 1.18+ or 2.0.15+ on macOS or Linux. Install a feature either through -`opencode-cockpit` or on its own — if both are configured, the first one loaded is used and -OpenCode warns you which entry to remove. +Requires OpenCode 1.18+ or 2.0.15+, on macOS or Linux. -**On OpenCode 2** the same packages load — one entry serves both versions. v2 reads `plugins` (not -`plugin`) from `opencode.json` for the agent side and from `cli.json` for the interface, and passes -options as an object: - -```json -{ - "plugins": [{ "package": "opencode-cockpit@0.9.0", "options": { "features": { "shell": true } } }] -} -``` +The version is pinned on purpose: OpenCode resolves a plugin once, so a bare name or `@latest` stays +on whatever it installed first. The same line moves you to a newer release. -An existing v1 `opencode.json` with `plugin` is read by OpenCode 2 as well. To update there, change -the version in that entry — `/plugins-update` and `npx opencode-cockpit update` edit OpenCode 1's -files only. - -**After installing or updating on OpenCode 2, restart its background service:** +Stuck on an old version? This runs from npm, outside OpenCode, so it works whatever you have +installed: ```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. `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 -``` - -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 -{ - "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 } -} +npx opencode-cockpit@latest update ``` -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). +## What you get -| 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` | +Seven bays, all on by default. Each one draws its block in the sidebar, or says `none yet` until it +has something. -**`shell`** — [Shell](packages/shell#configuration). +| Bay | What it gives you | Key or command | Docs | +| --- | --- | --- | --- | +| **Trail** | What every conversation shipped — PRs, tickets, deploys — and which one shipped it | `ctrl+x f` · `/trail` | [Trail](https://codestz.github.io/opencode-cockpit/trail/overview/) | +| **Shell** | Terminals that outlive the turn; the agent waits on a port or a pattern, you watch | `ctrl+x o` panel · `ctrl+x j` console | [Shell](https://codestz.github.io/opencode-cockpit/shell/overview/) | +| **Subagents** | Every helper in the sidebar with what it's doing now, and its whole run one key away | `ctrl+x d` | [Subagents](https://codestz.github.io/opencode-cockpit/subagents/overview/) | +| **Review** | Review changes like a pull request: notes on lines, which the agent reads and resolves | `ctrl+x v` | [Review](https://codestz.github.io/opencode-cockpit/review/overview/) | +| **Status** | How full the context is, where the tokens went, what changed, what it costs | `/status-setup` | [Status](https://codestz.github.io/opencode-cockpit/status/overview/) | +| **Trust** | Approve the same command three times and Trust answers it for you | `ctrl+x p` · `/trust` | [Trust](https://codestz.github.io/opencode-cockpit/trust/overview/) | +| **Updater** | Every plugin you have: what runs, what is published, and an update you pick | `/plugins-update` | [Updater](https://codestz.github.io/opencode-cockpit/updater/overview/) | -| 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` | +Every key is the same on OpenCode 1 and 2, and none of them is one of OpenCode's own. Each bay is +also published on its own, as `@opencode-cockpit/` — see +[Install](https://codestz.github.io/opencode-cockpit/start/install/). -**`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`. +## Set up in one sentence -**`trust`** — [Trust](packages/trust#settings). +Install, restart, then type `/cockpit-setup`. -| 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` | +The agent reads your settings, asks only what matters — which bays show, in what order, how quiet +when empty — and writes the smallest correct file. Every setting is in the +[Configuration](https://codestz.github.io/opencode-cockpit/configuration/) reference. -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.9.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 +## Something wrong? ```sh npx opencode-cockpit@latest doctor ``` -checks OpenCode, its config, Cockpit's logs and the daemon, and prints the fix for anything wrong — -on OpenCode 1 and 2, and when Cockpit will not load at all ([what it checks](https://codestz.github.io/opencode-cockpit/help/doctor/)). - -Everything Cockpit does inside OpenCode goes to one file — which OpenCode loaded which bay, and every -error with its stack: - -```sh -tail -50 ~/.cache/opencode-cockpit/cockpit.log -``` - -`COCKPIT_DEBUG=1 opencode` adds the detail. [Troubleshooting](https://codestz.github.io/opencode-cockpit/help/troubleshooting/) covers -the failures people hit and what to attach to an issue; [OpenCode 1 and 2](https://codestz.github.io/opencode-cockpit/start/opencode-versions/) -covers what differs between the two. - -## How it works - -``` -OpenCode TUI thread ── feature plugins (tui) ──┐ - ├── unix socket, JSON-RPC ── cockpitd ── processes -OpenCode server worker ─ feature plugins (server) ┘ -``` - -OpenCode runs its interface and its server in separate threads, so a plugin's two halves can't -share memory. Both talk to **`cockpitd`**, a small daemon that owns every long-lived process: it -starts on demand, is shared by every OpenCode window, upgrades itself when a newer plugin connects, -cleans up processes left by a crash, and exits when idle. That's why shells outlive OpenCode -restarts, and why one session can look at a shell another session started. - -Each shell's output feeds three views at once: a normalized **log** for the agent, an emulated -**screen** for you, and a raw ring buffer so a panel opened late can catch up. - -### Packages - -| Package | What it is | Docs | -|---|---|---| -| [`opencode-cockpit`](packages/opencode) | The bundle: every bay, each switchable | [README](packages/opencode/README.md) | -| [`@opencode-cockpit/shell`](packages/shell) | Bay 01 — background terminals | [README](packages/shell/README.md) · [docs](https://codestz.github.io/opencode-cockpit/shell/overview/) | -| [`@opencode-cockpit/status`](packages/status) | Bay 02 — the statusline | [README](packages/status/README.md) · [docs](https://codestz.github.io/opencode-cockpit/status/overview/) | -| [`@opencode-cockpit/review`](packages/review) | Bay 03 — a pull request in the terminal | [README](packages/review/README.md) · [docs](https://codestz.github.io/opencode-cockpit/review/overview/) | -| [`@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) | - -## What is being worked on +It checks OpenCode, its config, Cockpit's logs and the daemon, and prints the fix for anything +wrong. [Troubleshooting](https://codestz.github.io/opencode-cockpit/help/troubleshooting/) covers the +rest. -Not a roadmap of promises — the next thing, and why it is next. +## Links -**Review and the console in OpenCode 2's panel.** OpenCode 2 has a side panel of its own — with -focus, a width that follows the window, and a full-screen toggle. Review and the full-screen console -draw their own today; on OpenCode 2 they can live in the host's, and behave like the rest of its -interface. +- [Documentation](https://codestz.github.io/opencode-cockpit/) — start with + [What Cockpit is](https://codestz.github.io/opencode-cockpit/start/what-cockpit-is/) +- [OpenCode 1 and 2](https://codestz.github.io/opencode-cockpit/start/opencode-versions/) — what + differs between the two +- [Changelog](CHANGELOG.md) +- [Troubleshooting](https://codestz.github.io/opencode-cockpit/help/troubleshooting/) +- [Contributing](CONTRIBUTING.md) ## Contributing @@ -599,9 +97,7 @@ OpenCode. ```sh bun install -bun run check # lint, typecheck, tests (real PTYs, real daemon) -bun run pack:check # pack, install the tarballs, run a shell through them -bun run smoke:tui # drive a real OpenCode against the packed plugin +bun run check # lint, typecheck, tests (real PTYs, real daemon) ``` ## License diff --git a/media/hero.gif b/media/hero.gif new file mode 100644 index 00000000..8d30ced3 Binary files /dev/null and b/media/hero.gif differ diff --git a/packages/opencode/README.md b/packages/opencode/README.md index 081937f7..bf54b53a 100644 --- a/packages/opencode/README.md +++ b/packages/opencode/README.md @@ -14,7 +14,7 @@ finish, and paste the whole log back into their context. Cockpit gives your agen developer actually has — long-running terminals, a way to wait for "ready", and output it can read without drowning in it — and gives *you* a live view of all of it, inside OpenCode. -![The shells panel: a dev server running under the conversation](https://raw.githubusercontent.com/Codestz/opencode-cockpit/main/media/dock.gif) +![One OpenCode session with Cockpit: background tests failing then passing, subagents at work, the PR landing in Trail, the context filling](https://raw.githubusercontent.com/Codestz/opencode-cockpit/main/media/hero.gif) *A real recording — every demo here is generated from a live OpenCode session by [`bun run record`](https://github.com/Codestz/opencode-cockpit/blob/main/CONTRIBUTING.md), and re-run on release, so none of them can drift from what diff --git a/packages/shell/src/cli/preview.ts b/packages/shell/src/cli/preview.ts index 2b8afd05..d277b2d6 100644 --- a/packages/shell/src/cli/preview.ts +++ b/packages/shell/src/cli/preview.ts @@ -12,24 +12,14 @@ */ import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui" -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 { fold, sidebarRow } from "../tui/lib/sidebar.ts" +import { sidebarBlock } from "../tui/lib/sidebar.ts" import { badgeText, displayCommand, kindColor, kindOf, - STATE, statusDetail, tailRuns, truncate, @@ -112,66 +102,9 @@ interface SidebarState { 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 - const counts = summaryRuns(tally, Math.max(8, width - "Shells ".length)) - const used = "Shells".length + counts.reduce((n, part) => n + part.text.length, 0) - const heading: Row = [ - { text: "Shells", bold: true }, - { text: " ".repeat(Math.max(1, width - used)) }, - ...counts.map((part): Run => ({ text: part.text, tone: part.tone ?? "muted" })), - ] - return [ - fitRow(heading, width), - ...Array.from({ length: HEADING_GAP }, () => fitRow([], width)), - ...shown.map((shell): Row => { - const row = sidebarRow(shell, SAMPLE_NOW, 2, width) - const kind = hex(kindColor(theme, kindOf(shell))) - return [ - { text: row.rule, fg: kind }, - { text: row.label, fg: kind, bold: true }, - { text: row.title }, - { text: row.watch, fg: hex(watchColor(theme, shell)) }, - { text: row.detail, tone: "muted" }, - ] - }), - ] -} +/** The block as `components/sidebar.tsx` draws it: the shared builder, at the sample's moment. */ +const sidebar = (list: readonly ShellInfo[], width: number, state: SidebarState = {}): Row[] => + sidebarBlock({ list, now: SAMPLE_NOW, frame: 2, width, ...state }) // --- the dock ------------------------------------------------------------------------------------ diff --git a/packages/shell/src/cli/samples.ts b/packages/shell/src/cli/samples.ts index dbfbd153..26d0afe1 100644 --- a/packages/shell/src/cli/samples.ts +++ b/packages/shell/src/cli/samples.ts @@ -94,9 +94,14 @@ export const SAMPLE_LIST: ShellInfo[] = [ SHELLS.done, ] +/** One second apart, from 12:04:10 on: the clock only moves forward, however many lines there are. */ +const clock = (i: number): string => { + const at = 4 * 60 + 10 + i + return `12:${String(Math.floor(at / 60)).padStart(2, "0")}:${String(at % 60).padStart(2, "0")}` +} + const vite = (i: number): ScreenRun[] => [ - { text: "12:04:1" }, - { text: String(i % 10) }, + { text: clock(i) }, { text: " PM " }, { text: "[vite]", fg: "#56b6c2", bold: true }, { text: " hmr update ", fg: "#7fd88f" }, diff --git a/packages/shell/src/tui/lib/sidebar.ts b/packages/shell/src/tui/lib/sidebar.ts index 763c2096..8452e4bc 100644 --- a/packages/shell/src/tui/lib/sidebar.ts +++ b/packages/shell/src/tui/lib/sidebar.ts @@ -11,8 +11,18 @@ * in `kindColor` and `watchColor`, and the preview paints through the same two. */ +import { + emptyBlock, + FEWER_TEXT, + HEADING_GAP, + moreText, + type State as Shared, + summaryRuns, + warnRows, +} from "@opencode-cockpit/client/design" import type { ShellInfo } from "@opencode-cockpit/protocol/shell" -import { badgeText, type Kind, kindOf, shortDetail, watchLabel } from "./view.ts" +import type { Row, Run } from "./console.ts" +import { badgeText, type Kind, kindOf, kindTone, STATE, shortDetail, watchLabel, watchTone } from "./view.ts" export interface SidebarRow { kind: Kind @@ -131,3 +141,94 @@ export function sidebarRow(shell: ShellInfo, now: number, frame: number, width: const badgeOnly = badge.slice(0, Math.max(0, width)).padEnd(Math.max(0, width)) return { ...row, rule: badgeOnly.slice(0, 1), label: badgeOnly.slice(1), title: "", watch: "", detail: "" } } + +export interface BlockInput { + list: readonly ShellInfo[] + now: number + /** The spinner's clock. */ + frame: number + width: number + /** Expanded by a click on `+ N more`. */ + showAll?: boolean + hideWhenEmpty?: boolean + /** Settings to fix, as sentences (client/settings `noticeText`): `!` rows under the block. */ + notices?: readonly string[] +} + +/** Exactly `width` cells: cut with `…`, or padded. */ +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 text = run.text.length > room ? `${run.text.slice(0, Math.max(0, room - 1))}…` : run.text + out.push({ ...run, text }) + used += text.length + } + if (used < width) out.push({ text: " ".repeat(width - used) }) + return out +} + +/** + * The Shells block as rows of tones, as `components/sidebar.tsx` draws it: the name left and every + * count flush right with no row of air, the shells folded to the ones still worth a look, `+ N more` + * under them, and any settings notice last. The preview CLI prints it and the site draws it; a theme + * turns the tones into colours. + */ +export function sidebarBlock(input: BlockInput): Row[] { + const { list, now, frame, width } = input + const warnings = (input.notices ?? []).flatMap((text) => warnRows(text, width).map((row) => row as Row)) + if (list.length === 0) + return [ + ...emptyBlock("Shells", width, input.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: input.showAll === true, + rows: 5, + expandedRows: 12, + }) + const tally: Partial> = {} + for (const shell of list) tally[STATE[kindOf(shell)]] = (tally[STATE[kindOf(shell)]] ?? 0) + 1 + const counts = summaryRuns(tally, Math.max(8, width - "Shells ".length)) + const used = "Shells".length + counts.reduce((n, part) => n + part.text.length, 0) + const heading: Row = [ + { text: "Shells", bold: true }, + { text: " ".repeat(Math.max(1, width - used)) }, + ...counts.map((part): Run => ({ text: part.text, tone: part.tone ?? "muted" })), + ] + const rows = folding.shown.map((shell): Row => { + const row = sidebarRow(shell, now, frame, width) + const tone = kindTone(kindOf(shell)) + return fit( + [ + { text: row.rule, tone }, + { text: row.label, tone, bold: true }, + { text: row.title }, + { text: row.watch, tone: watchTone(shell) }, + { text: row.detail, tone: "muted" }, + ], + width, + ) + }) + const toggle = folding.toggle + ? [ + fit( + [{ text: ` ${folding.toggle === "fewer" ? FEWER_TEXT : moreText(folding.more)}`, tone: "muted" }], + width, + ), + ] + : [] + return [ + fit(heading, width), + ...Array.from({ length: HEADING_GAP }, () => fit([], width)), + ...rows, + ...toggle, + ...warnings, + ] +} diff --git a/packages/shell/src/tui/lib/view.ts b/packages/shell/src/tui/lib/view.ts index 44997221..e78b71f8 100644 --- a/packages/shell/src/tui/lib/view.ts +++ b/packages/shell/src/tui/lib/view.ts @@ -145,20 +145,23 @@ export function watchLabel(s: ShellInfo): string { return `${name} ${mark}` } -export function watchColor(theme: TuiThemeCurrent, s: ShellInfo) { +/** A watcher's health as a tone: what it says, before any theme decides how that looks. */ +export function watchTone(s: ShellInfo): "success" | "error" | "accent" | "muted" { switch (s.watch?.status) { case "ok": - return theme.success + return "success" case "fail": - return theme.error + return "error" /** Pending: still unmistakably a watcher, in the colour the console uses for what is live. */ case "pending": - return theme.accent + return "accent" default: - return theme.textMuted + return "muted" } } +export const watchColor = (theme: TuiThemeCurrent, s: ShellInfo) => toneColor(theme, watchTone(s)) + /** * Compact detail for narrow lists: how long it ran, in the sidebar's one kind of time — or, for a * failure, the exit code, which is the fact worth the room. diff --git a/packages/shell/test/sidebar-block.test.ts b/packages/shell/test/sidebar-block.test.ts new file mode 100644 index 00000000..15deff93 --- /dev/null +++ b/packages/shell/test/sidebar-block.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test" +import { EMPTY_TEXT } from "@opencode-cockpit/client/design" +import { SAMPLE_LIST, SAMPLE_NOW, SHELLS } from "../src/cli/samples.ts" +import type { Row } from "../src/tui/lib/console.ts" +import { sidebarBlock } from "../src/tui/lib/sidebar.ts" + +const text = (row: Row) => row.map((run) => run.text).join("") +const block = (list = SAMPLE_LIST, width = 36, showAll = false) => + sidebarBlock({ list, now: SAMPLE_NOW, frame: 0, width, showAll }) + +describe("the Shells block, as rows of tones", () => { + test("every row is exactly its width", () => { + for (const width of [24, 30, 36, 48]) + for (const showAll of [false, true]) + for (const row of block(SAMPLE_LIST, width, showAll)) expect(text(row).length).toBe(width) + }) + + test("the heading names the block and counts every state, flush right", () => { + const heading = text(block()[0]) + expect(heading.startsWith("Shells")).toBe(true) + expect(heading.trimEnd()).toMatch(/running/) + }) + + test("folded: what still needs a look, then `+ N more`; expanded: all of them and `− fewer`", () => { + const folded = block().map(text) + expect(folded.at(-1)).toMatch(/\+ \d+ more/) + expect(folded.some((row) => row.includes("DONE"))).toBe(false) + const open = block(SAMPLE_LIST, 36, true).map(text) + expect(open.some((row) => row.includes("DONE"))).toBe(true) + }) + + test("a state is a tone, not a colour: a failed shell's mark is the error tone", () => { + const failed = block([SHELLS.failed]).find((row) => text(row).includes("FAIL")) + expect(failed?.find((run) => run.text.includes("FAIL"))?.tone).toBe("error") + expect(failed?.some((run) => run.fg !== undefined)).toBe(false) + }) + + test("empty: the client's block — heading, its row of air, `none yet`", () => { + expect(block([]).map((row) => text(row).trim())).toEqual(["Shells", "", EMPTY_TEXT]) + }) +}) diff --git a/site/.gitignore b/site/.gitignore index 33ff25bc..afd7f0c7 100644 --- a/site/.gitignore +++ b/site/.gitignore @@ -1,7 +1,6 @@ dist/ .astro/ node_modules/ -public/casts/ -# synced from media/ by scripts/sync.ts, like the casts above -public/media/ src/content/docs/help/changelog.md +# built from engine/ by scripts/engine.ts +public/engine/ diff --git a/site/astro.config.mjs b/site/astro.config.mjs index 11ccfa83..99d71c11 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -26,6 +26,8 @@ export default defineConfig({ components: { // The landing page is ours; Starlight owns everything under /docs. SiteTitle: "./src/components/DocsTitle.astro", + // Adds Geist and Geist Mono, which Starlight would not otherwise load. + Head: "./src/components/docs/Head.astro", }, sidebar: [ { diff --git a/site/bun.lock b/site/bun.lock index 66e0e4e2..e125ec0b 100644 --- a/site/bun.lock +++ b/site/bun.lock @@ -6,11 +6,13 @@ "name": "@opencode-cockpit/site", "dependencies": { "@astrojs/starlight": "^0.36.0", - "asciinema-player": "^3.10.0", "astro": "^5.14.0", "mermaid": "^12.0.0", "sharp": "^0.34.0", }, + "devDependencies": { + "playwright-core": "1", + }, }, }, "packages": { @@ -266,12 +268,6 @@ "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], - "@solid-primitives/refs": ["@solid-primitives/refs@1.1.4", "", { "dependencies": { "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-bLjwIs6ZPu8NQnuw04sU3Zc8qKSpbc0umUU/O4SHf6oWOdO4+dHY8vb1T7C4b/Tg103+9WGr6sEE0+NFlbaB/A=="], - - "@solid-primitives/transition-group": ["@solid-primitives/transition-group@1.1.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-gnHS0OmcdjeoHN9n7Khu8KNrOlRc8a2weETDt2YT6o1zeW/XtUC6Db3Q9pkMU/9cCKdEmN4b0a/41MKAHRhzWA=="], - - "@solid-primitives/utils": ["@solid-primitives/utils@6.4.1", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-ISSB5QX1qP2ynrheIpYwc4oKR5Ny4siNuUyf1qZniy+Il+p/PtDB0QK1Dnle8noiHpwRD3gpPdubOC3qI/Zamg=="], - "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], @@ -386,8 +382,6 @@ "array-iterate": ["array-iterate@2.0.1", "", {}, "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg=="], - "asciinema-player": ["asciinema-player@3.17.0", "", { "dependencies": { "@babel/runtime": "^7.21.0", "solid-js": "^1.3.0", "solid-transition-group": "^0.2.3" } }, "sha512-JbjNJmA2TLIeYNaOEja+kVSzXadKoqpIzVVmfBGNj2DmdtE/vExBCnkE8NYEcpaQcDvUYg8Ltt0urT80frACmw=="], - "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], "astro": ["astro@5.18.2", "", { "dependencies": { "@astrojs/compiler": "^2.13.0", "@astrojs/internal-helpers": "0.7.6", "@astrojs/markdown-remark": "6.3.11", "@astrojs/telemetry": "3.3.0", "@capsizecss/unpack": "^4.0.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.3.0", "acorn": "^8.15.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "boxen": "8.0.1", "ci-info": "^4.3.1", "clsx": "^2.1.1", "common-ancestor-path": "^1.0.1", "cookie": "^1.1.1", "cssesc": "^3.0.0", "debug": "^4.4.3", "deterministic-object-hash": "^2.0.2", "devalue": "^5.6.2", "diff": "^8.0.3", "dlv": "^1.1.3", "dset": "^3.1.4", "es-module-lexer": "^1.7.0", "esbuild": "^0.27.3", "estree-walker": "^3.0.3", "flattie": "^1.1.1", "fontace": "~0.4.0", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "magic-string": "^0.30.21", "magicast": "^0.5.1", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "p-limit": "^6.2.0", "p-queue": "^8.1.1", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.3", "prompts": "^2.4.2", "rehype": "^13.0.2", "semver": "^7.7.3", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "svgo": "^4.0.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tsconfck": "^3.1.6", "ultrahtml": "^1.6.0", "unifont": "~0.7.3", "unist-util-visit": "^5.0.0", "unstorage": "^1.17.4", "vfile": "^6.0.3", "vite": "^6.4.1", "vitefu": "^1.1.1", "xxhash-wasm": "^1.1.0", "yargs-parser": "^21.1.1", "yocto-spinner": "^0.2.3", "zod": "^3.25.76", "zod-to-json-schema": "^3.25.1", "zod-to-ts": "^1.2.0" }, "optionalDependencies": { "sharp": "^0.34.0" }, "bin": { "astro": "astro.js" } }, "sha512-TnFwLnAXty5MXKPDGuKXqK4AMBXG+FH6RUdK7Oyc3gyfNoFIthT+4eRbzOK43bdRlLaZuxgciDSjgtggZ3OtGQ=="], @@ -460,8 +454,6 @@ "csso": ["csso@5.0.5", "", { "dependencies": { "css-tree": "~2.2.0" } }, "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ=="], - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - "cytoscape": ["cytoscape@3.34.3", "", {}, "sha512-yfYGhRcGAntq6YBD583j4n0Eg3jIxvWmZtz/5uz9UYkeIStSlMxuUja+ec5j3iBD8nv1rwaOAYMW09tBdkSeaQ=="], "cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="], @@ -898,6 +890,8 @@ "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + "playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="], + "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], "points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="], @@ -982,10 +976,6 @@ "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "seroval": ["seroval@1.5.6", "", {}, "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA=="], - - "seroval-plugins": ["seroval-plugins@1.5.6", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ=="], - "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], "shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], @@ -996,10 +986,6 @@ "smol-toml": ["smol-toml@1.8.0", "", {}, "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ=="], - "solid-js": ["solid-js@1.9.15", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.4", "seroval-plugins": "~1.5.4" } }, "sha512-EeiY2xfpZJqPLjXspVEKjAII4yv8NyG//NxZ3IpOFHdUNnnTyL0uJOeS9LWGvA7cFCz5y94cjFwYlmw5Luncsg=="], - - "solid-transition-group": ["solid-transition-group@0.2.3", "", { "dependencies": { "@solid-primitives/refs": "^1.0.5", "@solid-primitives/transition-group": "^1.0.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-iB72c9N5Kz9ykRqIXl0lQohOau4t0dhel9kjwFvx81UZJbVwaChMuBuyhiZmK24b8aKEK0w3uFM96ZxzcyZGdg=="], - "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], diff --git a/site/engine/bays/review.ts b/site/engine/bays/review.ts new file mode 100644 index 00000000..62fbee2c --- /dev/null +++ b/site/engine/bays/review.ts @@ -0,0 +1,67 @@ +/** + * Review: the pane (ctrl+x v) over the product's own fixture, driven the way a person drives it — + * the cursor walks the files, one is marked viewed, a note goes on a line, it is handed over, and the + * agent answers and resolves it. Every state is Review's own model (open, reply, resolve), and + * resolving is checked as it is in the bay: the line must really have changed. + */ +import { FIXTURES } from "../../../packages/review/src/core/fixtures.ts" +import { emptyReview, open, put, type Review, toggleRead } from "../../../packages/review/src/core/model/review.ts" +import { waitingOnAgent } from "../../../packages/review/src/core/model/submit.ts" +import { resolve } from "../../../packages/review/src/core/model/thread.ts" +import { layout } from "../../../packages/review/src/core/view/layout.ts" + +const { changes } = FIXTURES.turn +const files = changes.files.map((f) => f.path) +const NOTE = "this swallows the parse error" +const ANSWER = "It rethrows now, with the file's path — fixed in config.ts:14." + +/** One moment of the walk: what the pane shows, and the caption for it. */ +export interface Step { + caption: string + /** Typing a note: the draft so far, drawn over the pane as Review's note dialog does. */ + draft?: string +} + +/** The walk, in order; `frame(i)` draws step i. */ +export const STEPS: Step[] = [ + { caption: "ctrl+x v · what changed, file by file" }, + { caption: "space · viewed — the next unread file opens" }, + { caption: "tab · into the diff" }, + { caption: "j · to the line it's about" }, + { caption: "c · a note on this line", draft: NOTE }, + { caption: "enter · saved — waiting on the agent" }, + { caption: "s · handed over: the agent reads it with review_list" }, + { caption: "the agent changed the line and resolved it — checked, not trusted" }, + { caption: "space · viewed — 2 of 3" }, +] + +export function frame(i: number, width: number, height: number) { + let review: Review = emptyReview() + const at = 1_790_000_000_000 + if (i >= 1) review = toggleRead(review, files[0]) + if (i >= 5) review = open(review, { file: files[1], line: 1 }, NOTE, "you", at) + if (i >= 7) { + const thread = review.threads[0] + // the agent's edit: the line the note is on is not what it was + review = put(review, resolve(thread, { author: "agent", body: ANSWER, at: at + 60_000 }, "changed by the agent\n")) + } + if (i >= 8) review = toggleRead(review, files[1]) + const onFile = i === 0 ? files[0] : i >= 8 ? files[2] : files[1] + const state = { + file: onFile, + cursor: onFile, + context: 3, + pane: i >= 2 && i < 8 ? "diff" : "files", + ...(i >= 3 && i < 8 ? { line: 1 } : {}), + ...(i >= 5 && i < 8 && review.threads[0] ? { thread: review.threads[0].id } : {}), + waiting: waitingOnAgent(review).length, + } + return layout(changes, review, state as never, { width, height }) +} + +/** A changed screenshot: before → after, in the terminal, the changed pixels lit. */ +export function image(width: number, height: number, index = 1) { + const { changes: imgs, looks } = FIXTURES.images + const file = imgs.files[index % imgs.files.length].path + return layout(imgs, emptyReview(), { file, cursor: file, context: 3, waiting: 0, looks } as never, { width, height }) +} diff --git a/site/engine/bays/shell.ts b/site/engine/bays/shell.ts new file mode 100644 index 00000000..9609cb30 --- /dev/null +++ b/site/engine/bays/shell.ts @@ -0,0 +1,96 @@ +/** Shell: the console (ctrl+x j) over the product's sample shells and their screens, and the sidebar block. */ +import type { ShellInfo } from "@opencode-cockpit/protocol/shell" +import * as samples from "../../../packages/shell/src/cli/samples.ts" +import { consoleRows } from "../../../packages/shell/src/tui/lib/console.ts" +import { sidebarBlock } from "../../../packages/shell/src/tui/lib/sidebar.ts" + +const { SHELLS, SAMPLE_LIST, SAMPLE_NOW, SAMPLE_PROJECT, SAMPLE_LOG, DEV_SCREEN, TEST_SCREEN, BUILD_SCREEN } = samples +export const NOW = SAMPLE_NOW + +type Screen = typeof DEV_SCREEN +type Styled = NonNullable[number] + +/** A test run long enough to watch scroll: passing files, then the sample's own failure and summary. */ +const TEST_RUN: Screen = (() => { + const passing = ["cart/total", "cart/discount", "cart/tax", "checkout/address", "checkout/payment", "checkout/review", "auth/login", "auth/logout", "auth/refresh", "api/orders", "api/products", "api/users", "ui/button", "ui/modal", "ui/table"] + const lines = [ + ...passing.map((f, i) => ` ✓ src/${f}.test.ts (${4 + ((i * 7) % 11)} tests) ${9 + ((i * 13) % 40)}ms`), + ...TEST_SCREEN.text.split("\n").slice(2), + ] + return { ...TEST_SCREEN, text: lines.join("\n") } +})() + +const SCREENS = { running: DEV_SCREEN, failed: TEST_RUN, done: BUILD_SCREEN } as const +export type Which = keyof typeof SCREENS + +/** How many lines each program prints before it settles. */ +export const length = (which: Which) => SCREENS[which].text.split("\n").length + +/** + * The screen after `k` lines have arrived — the way a terminal fills, and scrolls once it is full. + * A program's own colours (the dev server's) are kept with their lines. + */ +function reveal(screen: Screen, k: number): Screen { + const text = screen.text.split("\n") + const from = Math.max(0, Math.min(k, text.length) - screen.rows) + const to = Math.min(k, text.length) + const styled = screen.styled?.slice(from, to) as Styled[] | undefined + return { ...screen, text: text.slice(from, to).join("\n"), ...(styled ? { styled } : {}), cursor: { x: 0, y: Math.max(0, to - from - 1) } } +} + +/** + * The console (ctrl+x j) on one of the sample shells, `k` lines into its output. Until its output is + * all there, a shell that will fail or finish is still running — the badge and the keys say so. + */ +export function screen(which: Which, width: number, height: number, frame: number, now = SAMPLE_NOW, k = Number.POSITIVE_INFINITY) { + const settled = k >= length(which) + const shell = settled || which === "running" ? SHELLS[which] : { ...SHELLS[which], status: "running", exitCode: undefined, endedAt: undefined, summary: undefined, pid: 4400 } + return consoleRows({ + shell, now, frame, project: SAMPLE_PROJECT, screen: reveal(SCREENS[which], k), log: SAMPLE_LOG, view: "screen", up: 0, + typing: false, colors: true, filter: "", searching: false, draft: "", + keys: { shell: true, running: shell.status === "running", view: "screen", filtered: false, count: SAMPLE_LIST.length, scope: "session", finished: 3 }, + position: `${SAMPLE_LIST.indexOf(SHELLS[which]) + 1}/${SAMPLE_LIST.length}`, width, height, fill: false, + } as never) +} + +/** The Shells block for any list of shells, at `now`. */ +export const sidebar = (list: readonly ShellInfo[], now: number, frame: number, width: number, hideWhenEmpty = false) => + sidebarBlock({ list, now, frame, width, hideWhenEmpty }) + +/** + * The console's log view of the dev server, as search leaves it: `/` and a query being typed + * (`draft`), or the log filtered to it, matches lit — what the console does with SAMPLE_LOG. + */ +export function log(width: number, height: number, frame: number, search: { draft?: string; filter?: string }) { + const filter = search.filter ?? "" + const searching = search.draft !== undefined + return consoleRows({ + shell: SHELLS.running, now: SAMPLE_NOW, frame, project: SAMPLE_PROJECT, screen: DEV_SCREEN, + log: filter ? SAMPLE_LOG.filter((line) => line.text.includes(filter)) : SAMPLE_LOG, + view: "log", up: 0, typing: false, colors: true, filter, searching, draft: search.draft ?? "", + keys: { shell: true, running: true, view: "log", filtered: Boolean(filter), count: SAMPLE_LIST.length, scope: "session", finished: 3 }, + position: `${SAMPLE_LIST.indexOf(SHELLS.running) + 1}/${SAMPLE_LIST.length}`, width, height, fill: false, + } as never) +} + +/** A sample shell to build a scripted one from: a dev server, its type checker healthy. */ +export const devServer = (startedAt: number): ShellInfo => ({ + ...SHELLS.running, + startedAt, + watch: { preset: "tsc", status: "ok", runs: 4, since: startedAt }, +}) + +/** A test run, as the hero scripts it: running, then exited — failed with its line, or passed. */ +export function testRun(run: number, startedAt: number, ended?: { at: number; failed?: string }): ShellInfo { + return { + ...SHELLS.done, + id: "sh_test", + title: "bun test", + args: ["-c", "bun test"], + run, + startedAt, + ...(ended + ? { status: "exited", exitCode: ended.failed ? 1 : 0, endedAt: ended.at, ...(ended.failed ? { summary: ended.failed } : {}) } + : { status: "running", exitCode: undefined, endedAt: undefined, pid: 4300 + run }), + } +} diff --git a/site/engine/bays/status.ts b/site/engine/bays/status.ts new file mode 100644 index 00000000..b3e6b879 --- /dev/null +++ b/site/engine/bays/status.ts @@ -0,0 +1,50 @@ +/** + * Status: the sidebar table and the line under the prompt, drawn from a live context. `at(p)` is one + * session `p` of the way through a long turn: the window filling from a seventh to nearly full, the + * tokens it went on, the diff growing, the spend — so the bar crosses the gauge's two steps (0.75, + * 0.9) the way it does in a real session, not in jumps between fixtures. + */ +import { FIXTURES } from "../../../packages/status/src/core/fixtures.ts" +import { fit, fitColumn } from "../../../packages/status/src/core/render.ts" +import { buildSegments } from "../../../packages/status/src/core/segments.ts" +import type { Run } from "../paint.ts" + +/* SIDEBAR_SEGMENTS (status/src/core/config.ts), copied: config.ts reaches for the settings file. */ +const SIDEBAR = ["title", { type: "context", style: "solid", width: 16, icon: "" }, { type: "session.status", priority: 95, icon: "", working: false }, "diagnostics", { type: "tokens", style: "row", icon: "" }, "in", "out", "cache", "write", "sep", "spend", "avail", "sep", "git"].map((e) => (typeof e === "string" ? { type: e } : e)) +const LINE = [{ type: "context", style: "bar", width: 14, icon: "" }, { type: "tokens", format: "tk {total}", icon: "" }, { type: "git.diff", icon: "" }, "todo", "session.status"].map((e) => (typeof e === "string" ? { type: e } : e)) + +const base = FIXTURES.busy.ctx +const lerp = (a: number, b: number, p: number) => Math.round(a + (b - a) * p) + +/** The session `p` (0..1) of the way through. */ +function at(p: number, width: number) { + const s = base.session + const cache = lerp(24_000, 178_000, p) + return { + ...base, + // the turn's clock runs with the session: a minute and a half more by the end + now: base.now + Math.round(p * 90_000), + width, + session: { + ...s, + status: "busy", + cost: Math.round((0.18 + 3.1 * p) * 100) / 100, + messages: lerp(6, 64, p), + tokens: { input: lerp(900, 6_400, p), output: lerp(300, 3_900, p), reasoning: 0, cache: { read: cache, write: lerp(1_200, 4_100, p) } }, + diff: { files: lerp(1, 9, p), additions: lerp(12, 286, p), deletions: lerp(2, 61, p) }, + todo: { total: 6, completed: Math.min(6, Math.floor(p * 7)) }, + }, + } +} + +export const table = (p: number, width: number, rows = 14) => + fitColumn(buildSegments(at(p, width) as never, SIDEBAR as never, { icons: true, debug: false }), width, rows).segments.map((s) => s.runs as Run[]) + +export function line(p: number, width: number): Run[][] { + const out: Run[] = [] + fit(buildSegments(at(p, width) as never, LINE as never, { icons: true, debug: false }), width, " │ ").segments.forEach((s, i) => { + if (i) out.push({ text: " │ ", tone: "border" }) + out.push(...(s.runs as Run[])) + }) + return [out] +} diff --git a/site/engine/bays/subagents.ts b/site/engine/bays/subagents.ts new file mode 100644 index 00000000..609ccff9 --- /dev/null +++ b/site/engine/bays/subagents.ts @@ -0,0 +1,32 @@ +/** Subagents: the product's recorded run, replayed up to any moment — the sidebar block and the pane. */ +import type { Change } from "../../../packages/subagents/src/core/model/changes.ts" +import { applyAll, emptyModel, subagentsOf } from "../../../packages/subagents/src/core/model/model.ts" +import { SAMPLE_NOW, SAMPLE_ROOT, sample } from "../../../packages/subagents/src/core/sample.ts" +import { screenRows } from "../../../packages/subagents/src/core/view/screen.ts" +import { sidebarLines } from "../../../packages/subagents/src/core/view/sidebar.ts" + +export { SAMPLE_NOW } +const CHANGES = sample() +/** The run's first minute: what `at` a second of replay maps to. */ +export const START = SAMPLE_NOW - 60_000 + +/** The changes up to `now`. They are not in time order, and a part with no time takes the last seen. */ +function upTo(now: number): Change[] { + let last = 0 + return CHANGES.filter((c) => (last = (c as { at?: number }).at ?? last) <= now) +} +const nodesAt = (now: number) => subagentsOf(applyAll(emptyModel(), upTo(now)), SAMPLE_ROOT) + +export const sidebar = (now: number, width: number, frame: number) => + sidebarLines({ nodes: nodesAt(now), width, now, frame, limit: 6 }).map((line) => line.row) + +/** One subagent's pane (ctrl+x d), as it looked at `now`. */ +export function pane(now: number, width: number, height: number, frame: number, which = 0) { + const nodes = nodesAt(now) + const node = nodes[Math.min(which, nodes.length - 1)] + if (!node) return [] + return screenRows({ + session: node.session, nodes, launcher: "build", width, height, now, frame, + open: new Set(), closed: new Set(), thinking: false, details: false, + }).rows +} diff --git a/site/engine/bays/trail.ts b/site/engine/bays/trail.ts new file mode 100644 index 00000000..e4d1b9ae --- /dev/null +++ b/site/engine/bays/trail.ts @@ -0,0 +1,36 @@ +/** Trail: its own sample trails, its own trail_add, its own sidebar block and /trail dialog. */ +import { arrange, conversationThings } from "../../../packages/trail/src/core/model.ts" +import { SAMPLE_NOW, SAMPLE_SESSION, SAMPLES } from "../../../packages/trail/src/core/sample.ts" +import { emptyState, type State } from "../../../packages/trail/src/core/store.ts" +import { runAdd } from "../../../packages/trail/src/core/tools.ts" +import { type DialogInput, dialogRows } from "../../../packages/trail/src/core/view/dialog.ts" +import { sidebarRows } from "../../../packages/trail/src/core/view/sidebar.ts" + +export { emptyState, SAMPLE_NOW, SAMPLE_SESSION } +export type { State } + +let counter = 0 +/** The agent's trail_add call, as the tool runs it. */ +export function add(state: State, args: Record, at: number, session = SAMPLE_SESSION) { + return runAdd(state, args, { + session, + rootSession: session, + sessionTitle: "Fix the bundle desync", + by: "agent", + at, + id: `ev_site_${++counter}`, + } as never) +} + +export const sidebar = (state: State, session: string, width: number, now: number, limit = 6) => + sidebarRows({ width, arranged: arrange(conversationThings(state, session)), now, limit }).rows + +/** A finished trail from the product's samples ("busy": nine records, two conversations). */ +export function sample(name = "busy") { + const { state, session } = (SAMPLES[name] as () => { state: State; session: string })() + return { + sidebar: (width: number, limit = 6) => sidebar(state, session, width, SAMPLE_NOW, limit), + dialog: (input: Partial & { width: number; height: number }) => + dialogRows({ state, session, now: SAMPLE_NOW, project: "opencode-cockpit", tab: "this", ...input }), + } +} diff --git a/site/engine/bays/trust.ts b/site/engine/bays/trust.ts new file mode 100644 index 00000000..afb0d900 --- /dev/null +++ b/site/engine/bays/trust.ts @@ -0,0 +1,80 @@ +/** + * Trust: its own engine, run live. `live()` starts from a session where a few commands are already + * trusted; the page then asks, approves and asks again through the same calls OpenCode's events + * drive, and draws the sidebar block and ctrl+x p from what the engine made of them. + */ +import { createEngine } from "../../../packages/trust/src/core/engine.ts" +import type { Request } from "../../../packages/trust/src/core/keys.ts" +import { rulesFrom } from "../../../packages/trust/src/core/rules.ts" +import { SAMPLE_NOW, SAMPLE_ROOT, SAMPLE_SETTINGS, SAMPLES } from "../../../packages/trust/src/core/sample.ts" +import { activityRows } from "../../../packages/trust/src/core/view/activity.ts" +import { explorerRows } from "../../../packages/trust/src/core/view/explorer.ts" +import { sidebarRows } from "../../../packages/trust/src/core/view/sidebar.ts" + +const RULES = rulesFrom({ permission: { bash: { "*": "ask" }, edit: "ask" } }) + +export function live() { + const engine = createEngine(SAMPLE_SETTINGS) + let clock = SAMPLE_NOW - 3_600_000 + let n = 0 + /** The agent asks; Trust judges. Answered by Trust, or left to you as a pending prompt. */ + const ask = (line: string) => { + const request: Request = { + id: `per_site_${++n}`, + sessionID: "ses_site", + permission: "bash", + patterns: [line], + always: [`${line.split(" ").slice(0, 2).join(" ")} *`], + call: `call_${n}`, + } + const { judgement, event } = engine.ask({ request, context: { line, root: SAMPLE_ROOT }, agent: "build", rules: RULES, at: clock }) + engine.load([event]) + clock += 1_500 + if (judgement.answer) { + const answered = engine.answered(request.id, clock) + if (answered) engine.load([answered]) + engine.replied({ requestID: request.id, reply: "once", at: clock + 5 }) + } + return { id: request.id, answered: judgement.answer, why: judgement.why, progress: judgement.progress } + } + /** You pressed "allow once". */ + const approve = (id: string) => { + clock += 2_000 + engine.load(engine.replied({ requestID: id, reply: "once", at: clock }).events) + clock += 40_000 + } + // what was earned before the page opened: two commands this session trusts already + for (const line of ["git status --short", "ls -la"]) + for (let i = 0; i < 3; i++) approve(ask(line).id) + for (let i = 0; i < 4; i++) ask("git status --short") + + return { + ask, + approve, + now: () => clock, + sidebar: (width: number) => + sidebarRows({ width, recent: engine.recent(), count: engine.count(), pending: engine.pending(), state: engine.state, limit: 4 } as never), + activity: (width: number, height: number) => + activityRows({ state: engine.state, settings: SAMPLE_SETTINGS, now: clock, history: engine.history, width, height, project: "app" } as never).rows, + } +} + +/** A finished afternoon from the product's samples, for a settled frame. */ +export function activity(name: string, width: number, height: number) { + const { engine } = SAMPLES[name]() + return activityRows({ state: engine.state, settings: SAMPLE_SETTINGS, now: SAMPLE_NOW, history: engine.history, width, height, project: "app" } as never).rows +} + +/** + * The ledger (l in ctrl+x p): every family and command Trust has seen, and a card for the one under + * the cursor. `step` walks the cursor down the tree, opening the family it is in. + */ +export function ledger(name: string, width: number, height: number, step: number) { + const { engine } = SAMPLES[name]() + const reading = { state: engine.state, settings: SAMPLE_SETTINGS, now: SAMPLE_NOW, history: engine.history, width, height, project: "app" } + const first = explorerRows({ ...reading, open: new Set(), full: new Set(), filter: "" } as never) as { model: { nodes: { key: string; kind: string; family?: { key: string } }[] } } + const nodes = first.model.nodes.filter((node) => node.kind === "family" || node.kind === "command") + const node = nodes[step % Math.max(1, nodes.length)] + const open = new Set(node?.family ? [node.family.key] : []) + return explorerRows({ ...reading, open, full: new Set(), filter: "", ...(node ? { selected: node.key } : {}) } as never).rows +} diff --git a/site/engine/bays/updater.ts b/site/engine/bays/updater.ts new file mode 100644 index 00000000..42b5261a --- /dev/null +++ b/site/engine/bays/updater.ts @@ -0,0 +1,32 @@ +/** Updater: /plugins-update over four plugins — two behind, one unreachable, one local. */ +import { buildPlan } from "../../../packages/updater/src/core/plan.ts" +import { parseSpec } from "../../../packages/updater/src/core/spec.ts" +import { keyRow, listRows, titleRow } from "../../../packages/updater/src/core/view/layout.ts" + +const FILE = { path: "/home/me/.config/opencode/opencode.json", scope: "global", owner: "config" } + +export function list(width: number, cursor: number, selected: readonly string[], latest: string) { + const plans = buildPlan({ + plugins: [ + { name: "opencode-cockpit", source: "npm", running: "0.8.0" }, + { name: "@acme/opencode-lint-rules", source: "npm", running: "1.2.0" }, + { name: "oc-offline", source: "npm", running: "2.0.0" }, + { name: "/home/me/work/plugins/shell-experiments", source: "file" }, + ], + entries: [ + { file: FILE, spec: parseSpec("opencode-cockpit") }, + { file: FILE, spec: parseSpec("@acme/opencode-lint-rules@latest") }, + { file: FILE, spec: parseSpec("oc-offline") }, + ], + published: new Map([["opencode-cockpit", latest], ["@acme/opencode-lint-rules", "1.3.0"]]), + cacheDirs: new Map(), + } as never) + const gap = { runs: [{ text: " ".repeat(width) }] } + return [ + titleRow("Plugins", "what runs · what is published", width), + gap, + ...listRows(plans, width, { cursor, selected: new Set(selected) } as never, "/home/me"), + gap, + keyRow([["space", "Select"], ["enter", "Update"], ["esc", "Close"]] as never, width), + ] +} diff --git a/site/engine/paint.ts b/site/engine/paint.ts new file mode 100644 index 00000000..c023ef9d --- /dev/null +++ b/site/engine/paint.ts @@ -0,0 +1,52 @@ +/** + * Rows → HTML. Every bay draws rows of runs; this only turns a run's tone *name* into a class + * (`t-accent`, `f-selected`) — what that looks like is the site's theme, in engine.css. A bay that + * carries its own colour (a program's output in Shell, an image in Review) keeps it inline. + * + * Review and Updater wrap a row as `{ runs }`; the rest are the runs themselves. + */ +export interface Run { + text: string + tone?: string + fill?: string + bold?: boolean + faint?: boolean + dim?: boolean + italic?: boolean + fg?: string + bg?: string + color?: string | number + background?: string | number +} +export type AnyRow = readonly Run[] | { runs: readonly Run[] } + +const esc = (s: string) => s.replace(/&/g, "&").replace(//g, ">") +const hex = (c: string | number | undefined) => (typeof c === "number" ? `#${c.toString(16).padStart(6, "0")}` : c) + +function run(r: Run): string { + const cls = [ + r.tone && `t-${r.tone}`, + r.bold && "b", + (r.faint || r.dim) && "dim", + r.italic && "i", + r.fill && r.fill !== "none" && `f-${r.fill}`, + ] + .filter(Boolean) + .join(" ") + const fg = hex(r.fg ?? r.color) + const bg = hex(r.bg ?? r.background) + const style = fg || bg ? ` style="${fg ? `color:${fg};` : ""}${bg ? `background:${bg};` : ""}"` : "" + return cls || style ? `${esc(r.text)}` : esc(r.text) +} + +export function paint(rows: readonly AnyRow[]): string { + return rows + .map((row) => { + const runs = "runs" in row ? row.runs : row + return `
${runs.map(run).join("") || " "}
` + }) + .join("") +} + +/** A row of nothing, `width` wide. */ +export const blank = (width: number): Run[] => [{ text: " ".repeat(width) }] diff --git a/site/engine/zlib-stub.ts b/site/engine/zlib-stub.ts new file mode 100644 index 00000000..11570bac --- /dev/null +++ b/site/engine/zlib-stub.ts @@ -0,0 +1,3 @@ +// Review's PNG reader imports node:zlib; the site never decodes a PNG, so it gets a stub that says so. +export const inflate = (_: unknown, cb: (e: Error) => void) => cb(new Error("zlib unavailable in browser")) +export const inflateSync = () => { throw new Error("zlib unavailable in browser") } diff --git a/site/package.json b/site/package.json index c104593a..9bca57d1 100644 --- a/site/package.json +++ b/site/package.json @@ -3,16 +3,19 @@ "private": true, "type": "module", "scripts": { - "sync": "bun scripts/sync.ts", + "sync": "bun scripts/sync.ts && bun scripts/engine.ts", "dev": "bun run sync && astro dev", "build": "bun run sync && astro build", - "preview": "astro preview" + "preview": "astro preview", + "hero": "bun scripts/hero.ts" }, "dependencies": { "@astrojs/starlight": "^0.36.0", - "asciinema-player": "^3.10.0", "astro": "^5.14.0", "mermaid": "^12.0.0", "sharp": "^0.34.0" + }, + "devDependencies": { + "playwright-core": "1" } } diff --git a/site/scripts/engine.ts b/site/scripts/engine.ts new file mode 100644 index 00000000..1db0ab80 --- /dev/null +++ b/site/scripts/engine.ts @@ -0,0 +1,52 @@ +/** + * Bundles the bays' own renderers for the browser, so the landing page draws each bay with the code + * that draws it in OpenCode: one module per bay in public/engine/, plus the chunks they share, and a + * section fetches its bay only when it is about to be seen. + * + * bun scripts/engine.ts (runs before dev and build, after sync) + * + * Bun rather than Vite: Trust reaches for node:path at runtime, which Bun polyfills and Vite does not. + */ +import { rmSync } from "node:fs" +import { join, resolve } from "node:path" + +const site = resolve(import.meta.dir, "..") +const packages = resolve(site, "..", "packages") +const out = join(site, "public", "engine") +const bays = ["trail", "subagents", "shell", "review", "status", "trust", "updater"] + +rmSync(out, { recursive: true, force: true }) +const result = await Bun.build({ + entrypoints: [join(site, "engine", "paint.ts"), ...bays.map((bay) => join(site, "engine", "bays", `${bay}.ts`))], + root: join(site, "engine"), + outdir: out, + target: "browser", + format: "esm", + splitting: true, + minify: true, + naming: { entry: "[dir]/[name].js", chunk: "chunks/[name]-[hash].js" }, + // Shell's relativeCwd reads $HOME; a visitor has none. + define: { "process.env.HOME": '""' }, + plugins: [ + { + name: "workspace-source", + setup(build) { + // Review's PNG reader imports node:zlib; the page never decodes a PNG. + build.onResolve({ filter: /^node:zlib$/ }, () => ({ path: join(site, "engine", "zlib-stub.ts") })) + // Packages import each other by name; the site reads their source, not a build. + build.onResolve({ filter: /^@opencode-cockpit\// }, ({ path }) => { + const [, pkg, sub] = path.match(/^@opencode-cockpit\/([^/]+)\/?(.*)$/) ?? [] + return { path: join(packages, pkg, "src", `${sub || "index"}.ts`) } + }) + }, + }, + ], +}) +if (!result.success) { + for (const log of result.logs) console.error(log) + process.exit(1) +} +const kb = (n: number) => `${(n / 1024).toFixed(1)} KB` +const entries = result.outputs.filter((o) => o.kind === "entry-point") +const chunks = result.outputs.filter((o) => o.kind === "chunk") +console.log(`engine: ${entries.map((o) => `${o.path.split("/engine/")[1]} ${kb(o.size)}`).join(" · ")} · ${chunks.length} shared chunks ${kb(chunks.reduce((n, o) => n + o.size, 0))}`) diff --git a/site/scripts/hero.ts b/site/scripts/hero.ts new file mode 100644 index 00000000..a01b4bc1 --- /dev/null +++ b/site/scripts/hero.ts @@ -0,0 +1,67 @@ +/** + * Writes media/hero.gif — the landing's hero window through one loop — for the README, which cannot + * run the page. It is a recording of the bays' own renderers, so regenerate it when they change: + * + * bun run build && bun scripts/hero.ts (needs Google Chrome and ffmpeg) + * + * One loop exactly, from the hero's own second zero (LOOP, src/scripts/landing.ts), so the GIF wraps + * without a seam; screenshots of the window, not a screen recording, so there is no clock to line up; + * 840 px wide, 6 frames a second and 48 colours keep it near 1.6 MB with every tone still its own. + */ +import { spawn } from "node:child_process" +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join, resolve } from "node:path" +import { chromium } from "playwright-core" + +const LOOP = 28 +const PORT = 4399 +const site = resolve(import.meta.dir, "..") +const out = resolve(site, "..", "media", "hero.gif") +const chrome = process.env.CHROME ?? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" +const scratch = mkdtempSync(join(tmpdir(), "cockpit-hero-")) + +const preview = spawn("bunx", ["astro", "preview", "--port", String(PORT)], { cwd: site, stdio: "ignore" }) +const run = (cmd: string, args: string[]) => + new Promise((done, fail) => spawn(cmd, args, { stdio: "inherit" }).on("exit", (code) => (code === 0 ? done() : fail(new Error(`${cmd} exited ${code}`))))) + +try { + await new Promise((wait) => setTimeout(wait, 3000)) + const browser = await chromium.launch({ executablePath: chrome, headless: true }) + const page = await browser.newPage({ viewport: { width: 1280, height: 760 } }) + await page.goto(`http://localhost:${PORT}/opencode-cockpit/`, { waitUntil: "networkidle" }) + // the window alone: no nav over it, no labels beside it + await page.evaluate(() => { + document.documentElement.style.scrollBehavior = "auto" + for (const el of document.querySelectorAll(".nav, .callout")) el.style.display = "none" + const win = document.querySelector(".deck .window")! + scrollTo(0, win.getBoundingClientRect().top + scrollY - 20) + }) + const win = page.locator(".deck .window") + // start on a fresh loop, at the hero's own second zero + await page.waitForFunction(() => document.getElementById("hero-work")?.textContent === "working 1s", undefined, { polling: 20 }) + await page.waitForFunction(() => document.getElementById("hero-work")?.textContent === "working 0s", undefined, { polling: 20, timeout: (LOOP + 5) * 1000 }) + // one loop of frames, each lasting until the next was taken + const frames: { file: string; at: number }[] = [] + const start = Date.now() + while (Date.now() - start < LOOP * 1000) { + const file = join(scratch, `${String(frames.length).padStart(4, "0")}.png`) + const at = Date.now() - start + await win.screenshot({ path: file }) + frames.push({ file, at }) + const next = (frames.length * 1000) / 6 + await new Promise((wait) => setTimeout(wait, Math.max(0, next - (Date.now() - start)))) + } + await browser.close() + const list = frames + .map((frame, i) => `file '${frame.file}'\nduration ${(((frames[i + 1]?.at ?? LOOP * 1000) - frame.at) / 1000).toFixed(3)}`) + .join("\n") + const concat = join(scratch, "frames.txt") + await Bun.write(concat, `${list}\nfile '${frames.at(-1)!.file}'\n`) + await run("ffmpeg", ["-v", "error", "-y", "-f", "concat", "-safe", "0", "-i", concat, "-vf", + "fps=6,scale=840:-1:flags=lanczos,split[a][b];[a]palettegen=max_colors=48:stats_mode=diff[p];[b][p]paletteuse=dither=none:diff_mode=rectangle", out]) + console.log(`wrote ${out} — ${frames.length} frames`) +} finally { + preview.kill() + rmSync(scratch, { recursive: true, force: true }) +} diff --git a/site/scripts/sync.ts b/site/scripts/sync.ts index 9f90ec66..303c1a10 100644 --- a/site/scripts/sync.ts +++ b/site/scripts/sync.ts @@ -1,35 +1,15 @@ /** - * Copies the two things the site needs from the repository root, so neither is kept in two places: - * the recorded sessions (tapes/*.cast) and the changelog the release script maintains. + * Copies the changelog the release script maintains into the docs, so it is kept in one place. The + * site has no recordings to copy: its windows are drawn by the bays' own renderers (scripts/engine.ts). * * bun scripts/sync.ts (runs before dev and build) */ -import { copyFileSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs" +import { mkdirSync, readFileSync, writeFileSync } from "node:fs" import { join, resolve } from "node:path" const root = resolve(import.meta.dir, "..", "..") const site = resolve(import.meta.dir, "..") -const casts = join(site, "public", "casts") -mkdirSync(casts, { recursive: true }) -const tapes = readdirSync(join(root, "tapes")).filter((f) => f.endsWith(".cast")) -/** - * Published as `.cast.json`, not `.cast`. - * - * A cast is JSON, but nothing serving it knows that from the extension: GitHub Pages calls an unknown - * one `application/octet-stream` and does not compress those. The recordings are 80% escape sequences - * and compress about 15:1 — the review tape is 750KB served raw and 50KB served as JSON. Same bytes, - * same player, one suffix. - */ -for (const file of tapes) copyFileSync(join(root, "tapes", file), join(casts, `${file}.json`)) - -// Screenshots live in media/ alongside the recordings, and are served from the site's own origin -// so a page renders the same locally as it does once deployed. -const shots = join(site, "public", "media") -mkdirSync(shots, { recursive: true }) -const images = readdirSync(join(root, "media")).filter((f) => f.endsWith(".png")) -for (const file of images) copyFileSync(join(root, "media", file), join(shots, file)) - // The changelog is a docs page, but the release script owns the file: import it with a title. const changelog = readFileSync(join(root, "CHANGELOG.md"), "utf8") .replace(/^# Changelog\n+/, "") @@ -47,4 +27,4 @@ ${changelog}` mkdirSync(join(site, "src", "content", "docs", "help"), { recursive: true }) writeFileSync(join(site, "src", "content", "docs", "help", "changelog.md"), page) -console.log(`synced ${tapes.length} casts, ${images.length} images and the changelog`) +console.log("synced the changelog") diff --git a/site/src/components/Cast.astro b/site/src/components/Cast.astro deleted file mode 100644 index 9dbf1fb1..00000000 --- a/site/src/components/Cast.astro +++ /dev/null @@ -1,87 +0,0 @@ ---- -/** - * Plays a recorded session. Text, not video: sharp at any size, selectable, and a few tens of - * kilobytes. Used on the landing page and inside docs pages. - * - * - */ -interface Props { - /** File in public/casts, without the extension. */ - name: string - caption?: string - speed?: number - idle?: number - autoplay?: boolean - loop?: boolean -} -const { name, caption, speed = 1.4, idle = 0.9, autoplay = true, loop = true } = Astro.props -const src = `${import.meta.env.BASE_URL.replace(/\/$/, "")}/casts/${name}.cast.json` -const id = `cast-${name}-${Math.random().toString(36).slice(2, 8)}` ---- - -
-
- {caption &&
{caption}
} -
- - - - - - diff --git a/site/src/components/DocsTitle.astro b/site/src/components/DocsTitle.astro index 074e61aa..983108f6 100644 --- a/site/src/components/DocsTitle.astro +++ b/site/src/components/DocsTitle.astro @@ -1,19 +1,20 @@ --- -/** Starlight's site title, replaced so the docs carry the same mark as the landing page. */ +/** Starlight's site title, replaced so the docs carry the landing nav's brand: the mark, "Cockpit", and a docs tag. */ import Logo from "./Logo.astro" const base = import.meta.env.BASE_URL --- - + Cockpit docs diff --git a/site/src/components/Live.astro b/site/src/components/Live.astro new file mode 100644 index 00000000..4873dd09 --- /dev/null +++ b/site/src/components/Live.astro @@ -0,0 +1,35 @@ +--- +/** + * A live window in the docs: a bay drawn by its own renderer, playing one of the players in + * src/scripts/live.ts — the same windows the landing page plays, so the docs cannot drift from the + * product or from the site. + * + * + */ +import "../styles/engine.css" +import "../styles/live.css" + +interface Props { + scene: string + caption: string + /** Character size: smaller for a pane that needs its columns. */ + size?: number + /** Window height in pixels. */ + height?: number +} +const { scene, caption, size = 12, height = 400 } = Astro.props +--- + +
+
+ + +
+
+ + diff --git a/site/src/components/docs/Head.astro b/site/src/components/docs/Head.astro new file mode 100644 index 00000000..712c1f1a --- /dev/null +++ b/site/src/components/docs/Head.astro @@ -0,0 +1,14 @@ +--- +/** + * Starlight's , plus the two faces the docs set type in. The landing page loads the same + * families from Base.astro; without this the docs silently fall back to the system font. + */ +import Default from "@astrojs/starlight/components/Head.astro" +--- + + + + diff --git a/site/src/components/landing/Bay.astro b/site/src/components/landing/Bay.astro deleted file mode 100644 index 2e4fa461..00000000 --- a/site/src/components/landing/Bay.astro +++ /dev/null @@ -1,162 +0,0 @@ ---- -/** - * One bay, given a section of its own: what it is, what it gives you, and it running. - * - * Every bay renders through this, so fitting another is an entry in `bays.items` rather than a - * redesign of the page. Media is either the recordings (Shell) or a screenshot (Statusline); the - * shape of the section does not change between them. - */ -import { bays } from "../../data/landing" - -interface Props { - id: string - number: string -} -const { id, number } = Astro.props -const bay = bays.items.find((item) => item.id === id)! -const base = import.meta.env.BASE_URL.replace(/\/$/, "") -const casts = bay.media.kind === "casts" ? bay.media.clips : [] ---- -
-
-

{number} {bay.tagline}

-

{bay.name}

-

{bay.blurb}

-

- {/* A bay that gives the agent nothing says so by leaving the side out, not by printing a dash. */} - {bay.gains.agent !== "—" && agent {bay.gains.agent}} - you {bay.gains.you} -

-
- -
-
    - {bay.points.map((point) => ( -
  • →{point}
  • - ))} -
- - {bay.media.kind === "image" && ( -
- {bay.media.alt} -
{bay.media.caption}
-
- )} - - {bay.media.kind === "casts" && ( -
-
- {casts.map((clip, i) => ( - - ))} -
-
-
-

{casts[0]?.caption}

-
-
- )} -
- - -
- - - - - - diff --git a/site/src/components/landing/Closing.astro b/site/src/components/landing/Closing.astro deleted file mode 100644 index 30ffb5fa..00000000 --- a/site/src/components/landing/Closing.astro +++ /dev/null @@ -1,35 +0,0 @@ ---- -import { closing, nav } from "../../data/landing" -const base = import.meta.env.BASE_URL ---- -
-
-

{closing.title}

-

{closing.body}

- -
-
- - - - diff --git a/site/src/components/landing/Compare.astro b/site/src/components/landing/Compare.astro deleted file mode 100644 index ed7c967a..00000000 --- a/site/src/components/landing/Compare.astro +++ /dev/null @@ -1,33 +0,0 @@ ---- -import SectionHead from "./SectionHead.astro" -import { compare } from "../../data/landing" ---- -
- -
-
- {compare.columns[0]}{compare.columns[1]} -
- {compare.rows.map(([then, now], i) => ( -
- {String(i + 1).padStart(2, "0")}{then} - →{now} -
- ))} -
-
- - diff --git a/site/src/components/landing/Feature.astro b/site/src/components/landing/Feature.astro new file mode 100644 index 00000000..0d7af31c --- /dev/null +++ b/site/src/components/landing/Feature.astro @@ -0,0 +1,23 @@ +--- +import type { Feature } from "../../data/landing" +import { inline } from "../../lib/inline" +import Window from "./Window.astro" +interface Props { + feature: Feature + flip?: boolean +} +const { feature, flip = false } = Astro.props +--- + +
+
+
{feature.eyebrow}
+

{feature.title} {feature.accent}

+

+

    + {feature.points.map((point) =>
  • {point.key}{point.text}
  • )} +
+ {feature.id[0].toUpperCase() + feature.id.slice(1)} docs → +
+ +
diff --git a/site/src/components/landing/Final.astro b/site/src/components/landing/Final.astro new file mode 100644 index 00000000..c3a1cfff --- /dev/null +++ b/site/src/components/landing/Final.astro @@ -0,0 +1,14 @@ +--- +import { final, install } from "../../data/landing" +import Install from "./Install.astro" +const base = import.meta.env.BASE_URL +--- + +
+

{final.title} {final.accent}

+ + +
{install.requires}
+
diff --git a/site/src/components/landing/Fitted.astro b/site/src/components/landing/Fitted.astro deleted file mode 100644 index 402b7297..00000000 --- a/site/src/components/landing/Fitted.astro +++ /dev/null @@ -1,42 +0,0 @@ ---- -/** The index: what is fitted, in one glance, each linking to its own section below. */ -import SectionHead from "./SectionHead.astro" -import { bays } from "../../data/landing" ---- -
- - - -
- - diff --git a/site/src/components/landing/Hero.astro b/site/src/components/landing/Hero.astro index 94a9d0e0..675b0273 100644 --- a/site/src/components/landing/Hero.astro +++ b/site/src/components/landing/Hero.astro @@ -1,71 +1,41 @@ --- -/** - * The hook, and nothing else. - * - * It used to carry a hand-built mock of the panel on the right — a drawing of the product, beside - * the product's own recordings and screenshots further down. The real thing is a better argument - * than an illustration of it, so the space goes to the sentence instead. - */ -import { hero } from "../../data/landing" +import { bays, hero } from "../../data/landing" +import Install from "./Install.astro" --- -
-

{hero.eyebrow}

-

{hero.title[0]} {hero.title[1]} {hero.title[2]}

+ +
+
{hero.tag}
+

{hero.title}{hero.accent}

{hero.body}

+ + -
- Install Cockpit - See what it does +
+ +
+ +
+
+ +
+
+ build · claude-opus12%working 0s +
+
+ +
SHELLS background runs
+
SUBAGENTS helpers, live
+
TRAIL what it made
+
the conversation OPENCODE
+
context, filling STATUS
+

{hero.proof}

-
- ${hero.install} - +
+ {bays.map((bay) => {bay.name}{bay.what})}
-
- - - - + diff --git a/site/src/components/landing/Install.astro b/site/src/components/landing/Install.astro index 9694f4e1..1df9728b 100644 --- a/site/src/components/landing/Install.astro +++ b/site/src/components/landing/Install.astro @@ -1,56 +1,27 @@ --- -import SectionHead from "./SectionHead.astro" +/** + * The install command for both OpenCodes, from the published version. OpenCode 1 is shown first; + * the script remembers a visitor's choice. OpenCode 2 also needs its background service restarted. + */ import { install } from "../../data/landing" +interface Props { + compact?: boolean +} +const { compact = false } = Astro.props +const { v1, v2 } = install --- -
- -
- {install.modes.map((m, i) => ( - - ))} +
+
+ +
- -
- {install.modes.map((m, i) => ( - - ))} -
- {install.steps.map((s, i) => ( -
{String(i + 1).padStart(2, "0")}
- ))} -
+
+ $ {v1.command} +
-
- - - - + + diff --git a/site/src/components/landing/Nav.astro b/site/src/components/landing/Nav.astro index 93aa78ca..cb40f228 100644 --- a/site/src/components/landing/Nav.astro +++ b/site/src/components/landing/Nav.astro @@ -1,63 +1,15 @@ --- +import { github, nav } from "../../data/landing" import Logo from "../Logo.astro" -import { nav } from "../../data/landing" const base = import.meta.env.BASE_URL +const href = (to: string) => (to.startsWith("#") || to.startsWith("http") ? to : `${base}${to}`) --- -