diff --git a/.gitignore b/.gitignore index 5ead7ed7..8cc0e26f 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ media/social/ # OpenCode project-local config and plugins (created by OpenCode in a session here) /.opencode/ + +# preview snapshots for refactors (scripts/golden.ts) +/.golden/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e47773a..4d77ca89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,40 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +### Changed + +- **Cockpit has a home: [cockpit.codestz.dev](https://cockpit.codestz.dev).** The docs are restyled + to match a new landing page, and every window on both is drawn live by the bays' own renderers + instead of a recording. The README is short and visitor-first, each package's README shows its bay + in a GIF made from that window, and every npm page links to its docs. +- **A name Cockpit does not read is said the same way, whatever its age.** 0.9 kept recognising the + names from before it for one release; they are now unknown names like a typo. A top-level one is + `! settings: "statusline" is not a setting: did you mean "status"?` in Status's column and in + doctor; one inside a bay's section (`review.sidebarOrder`) is named by `/cockpit-setup`, like any + key the bay does not read. Their values were not read in 0.9 and still are not. The site's + configuration page keeps the table of old names and new ones, for anyone moving an old file. + +### Fixed + +- **Trust and the updater load on OpenCode 2 when installed on their own.** OpenCode 2 sets up a + plugin's interface only once its agent half has loaded, and these two had none — so + `opencode plugin add @opencode-cockpit/trust` (or `/updater`), as the install docs say, loaded + nothing at all. Each now has an agent half with nothing of its own; with it, `/cockpit-setup` + works from either alone, too. The bundle was never affected. +- **Doctor checks Status's agent half too.** Status has had one since 0.9 (its `/status-setup` + command and skill), and doctor's list of bays with two halves did not know it; a test now fails + when a bay with an agent half is missing from it. + +### Removed + +- **`/statusline`.** Its name until 0.9, kept for one release; `/status-setup` is the command. +- **The `statusline-design` skill folder.** Kept in 0.9 for anyone who had copied it; + `skills/status-setup/` carries the same design rules, in `references/design.md`. +- **Six entry points nothing imported:** `@opencode-cockpit/review/frame` (broken since it was + added: it pointed at a file that was never built there), `trail/core`, `trust/core`, + `updater/core`, `status/fixtures` and `client/elements`. Cockpit's own packages never used them; + `status/segment`, for your own segments, stays. + ## [0.9.0] - 2026-10-04 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index edea33d7..8122520b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -137,7 +137,10 @@ The router refuses methods missing from the contract, and handler params are typ Files are grouped by role, not by type, and entry points stay thin: - `core/` — pure logic with no I/O and no JSX, imported by both halves (finding, classifying, - formatting). + formatting). It never touches the filesystem, git, the network or another process; the clock or an + environment variable is fine as a parameter's default, which a test passes instead. +- `io/` — what a bay reads and writes outside itself: its files, git, processes it spawns. Both halves + use it; it may import `core/`, never the other way round. - `core/config.ts` — one loader for every setting: global file → project `.cockpit.json` → plugin options, merged section-wise. Both halves call `loadConfig()` at startup and read from the result; nothing else parses config, and a broken file resolves to `{}` instead of throwing. @@ -156,7 +159,7 @@ must carry explicit types or stay module-private, or `tsc` cannot name them in d 1. `packages/` named `@opencode-cockpit/`, exporting `./server` and/or `./tui` whose default export is a plugin built from factories (`createServer`, `createTui`) that accept a `source` label and start with `claimFeature`. -2. Add it to `BAYS` in `packages/client/src/settings.ts` — the bundle's `FEATURES`, the settings +2. Add it to `BAYS` in `packages/client/src/settings/index.ts` — the bundle's `FEATURES`, the settings file and doctor all read that one list — and call its factories in the bundle's `server.ts` / `tui.ts`. 3. Add the directory to `PACKAGES` in `scripts/pack-check.ts` and to the publish loop in diff --git a/biome.json b/biome.json index 53df3f0e..b2dcc4bb 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,6 @@ { "$schema": "https://biomejs.dev/schemas/2.5.13/schema.json", + "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, "files": { "includes": ["**", "!**/node_modules", "!**/dist", "!**/types", "!**/*.tsbuildinfo", "!site", "!media"] }, diff --git a/bunfig.toml b/bunfig.toml index 3a6f4e53..641ff13c 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,4 +1,7 @@ [test] +# Tests live in packages/; a worktree under .claude/ (an agent's) holds a second copy of them, and +# running both at once collides on the real daemons and terminals they start. +root = "packages" # Tests run real PTYs and a real daemon; shared CI runners can be slow to spawn processes. timeout = 20000 preload = ["./scripts/test-env.ts"] diff --git a/packages/client/package.json b/packages/client/package.json index eb88dbf5..45d55d9c 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -29,64 +29,60 @@ "default": "./dist/feature.js" }, "./host": { - "types": "./types/host.d.ts", - "default": "./dist/host.js" + "types": "./types/opencode/host/index.d.ts", + "default": "./dist/opencode/host/index.js" }, "./server": { - "types": "./types/server.d.ts", - "default": "./dist/server.js" + "types": "./types/opencode/server/index.d.ts", + "default": "./dist/opencode/server/index.js" }, "./log": { "types": "./types/log.d.ts", "default": "./dist/log.js" }, "./sidebar": { - "types": "./types/sidebar.d.ts", - "default": "./dist/sidebar.js" + "types": "./types/opencode/sidebar.d.ts", + "default": "./dist/opencode/sidebar.js" }, "./design": { "types": "./types/design.d.ts", "default": "./dist/design.js" }, - "./elements": { - "types": "./types/elements.d.ts", - "default": "./dist/elements.js" - }, "./settings": { - "types": "./types/settings.d.ts", - "default": "./dist/settings.js" + "types": "./types/settings/index.d.ts", + "default": "./dist/settings/index.js" }, "./catalog": { - "types": "./types/catalog.d.ts", - "default": "./dist/catalog.js" + "types": "./types/settings/catalog.d.ts", + "default": "./dist/settings/catalog.js" }, "./setup": { - "types": "./types/setup.d.ts", - "default": "./dist/setup.js" + "types": "./types/setup/index.d.ts", + "default": "./dist/setup/index.js" }, "./brief": { - "types": "./types/brief.d.ts", - "default": "./dist/brief.js" + "types": "./types/setup/brief.d.ts", + "default": "./dist/setup/brief.js" }, "./jsonc": { - "types": "./types/jsonc.d.ts", - "default": "./dist/jsonc.js" + "types": "./types/settings/jsonc.d.ts", + "default": "./dist/settings/jsonc.js" }, "./plugin-entries": { - "types": "./types/plugin-entries.d.ts", - "default": "./dist/plugin-entries.js" + "types": "./types/settings/plugin-entries.d.ts", + "default": "./dist/settings/plugin-entries.js" }, "./opener": { "types": "./types/opener.d.ts", "default": "./dist/opener.js" }, "./checks": { - "types": "./types/checks.d.ts", - "default": "./dist/checks.js" + "types": "./types/settings/checks.d.ts", + "default": "./dist/settings/checks.js" }, "./service": { - "types": "./types/service.d.ts", - "default": "./dist/service.js" + "types": "./types/opencode/service.d.ts", + "default": "./dist/opencode/service.js" } }, "files": [ diff --git a/packages/client/skills/cockpit-setup/SKILL.md b/packages/client/skills/cockpit-setup/SKILL.md index 4c07c859..90b591d0 100644 --- a/packages/client/skills/cockpit-setup/SKILL.md +++ b/packages/client/skills/cockpit-setup/SKILL.md @@ -1,6 +1,6 @@ --- name: cockpit-setup -description: Set up opencode-cockpit ("Cockpit") with the user — which of its bays run, which show a block in OpenCode's sidebar, in what order, how quiet they are when empty — fix settings from before 0.9, and, when they want it, tune Cockpit to how they work (a tour of its keys and the project's conventions (dev server, ticket keys) written to AGENTS.md). Use it whenever the user runs /cockpit-setup or asks to configure, tidy or change Cockpit or its sidebar, even in passing - "make my sidebar quieter", "hide the shells block when it's empty", "move trail above subagents", "turn subagents off", "show trust in the sidebar", "configure cockpit", "tell cockpit our dev server is bun dev", "our tickets are COM-…", or a question about ~/.config/opencode-cockpit/config.json or .cockpit.json. To design what the Status line itself shows, use the status-setup skill instead. +description: Set up opencode-cockpit ("Cockpit") with the user — which of its bays run, which show a block in OpenCode's sidebar, in what order, how quiet they are when empty — fix settings it cannot read, and, when they want it, tune Cockpit to how they work (a tour of its keys and the project's conventions (dev server, ticket keys) written to AGENTS.md). Use it whenever the user runs /cockpit-setup or asks to configure, tidy or change Cockpit or its sidebar, even in passing - "make my sidebar quieter", "hide the shells block when it's empty", "move trail above subagents", "turn subagents off", "show trust in the sidebar", "configure cockpit", "tell cockpit our dev server is bun dev", "our tickets are COM-…", or a question about ~/.config/opencode-cockpit/config.json or .cockpit.json. To design what the Status line itself shows, use the status-setup skill instead. --- # Setting up Cockpit @@ -25,11 +25,12 @@ If the tool is not there, Cockpit's agent side is not loaded: say so, and point ## 2. Fix the notices first -If the tool lists notices, fix them before anything else, in the file each one names: move the value -to the new name and remove the old key (an old order number is just removed — the order is the -top-level `sidebar` list). These names are **not read**, so the value under each is doing nothing -today. Tell the person what you changed in one line each, e.g. `"statusline" → "status"`. Keep their -comments and every other key. +If the tool lists notices, fix them before anything else, in the file each one names. A key that is +not a setting is **not read**, so the value under it is doing nothing today: when the notice says +what was meant (`did you mean "status"?`), move the value there; otherwise ask the person. A value of +the wrong kind falls back to the default — write it in the kind the notice asks for. Tell the person +what you changed in one line each, e.g. `"statusline" → "status"`. Keep their comments and every +other key. ## 3. Offer a starting point diff --git a/packages/client/skills/cockpit-setup/references/settings.md b/packages/client/skills/cockpit-setup/references/settings.md index 80d2fd3f..f6edcb61 100644 --- a/packages/client/skills/cockpit-setup/references/settings.md +++ b/packages/client/skills/cockpit-setup/references/settings.md @@ -1,4 +1,4 @@ - + # Cockpit settings reference @@ -121,28 +121,3 @@ Settings are read when OpenCode starts: a change applies after a restart. | --- | --- | --- | --- | | `enabled` | boolean | `true` | the bay runs at all, both halves. `false` turns it off entirely: no block, no commands, no tools | | `updateCheck` | boolean | `true` | check for plugin updates once a day and say so | - -## Names from before 0.9 - -Not read at all. Each one found is a notice in `cockpit_settings` and a `!` row in its bay. In the -same file, move the value to the new name and remove the old one: - -| Old | New | -| --- | --- | -| `statusline` | `status` | -| `status.maxRows` | `status.sidebarRows` | -| `watch` | `shell.watch` | -| `kinds` | `shell.kinds` | -| `defaults` | `shell.defaults` | -| `lifecycle` | `shell.lifecycle` | -| `notify` | `shell.notify` | -| `guidance` | `shell.guidance` | -| `listRunningShells` | `shell.listRunningShells` | -| `ui.` | `shell.` | -| `ui.historyMinutes` | `shell.hideFinishedAfterMinutes` | -| `ui.updateCheck` | `updater.updateCheck` | -| `ui.sidebarOrder` | the top-level `sidebar` list (remove it) | -| `.sidebarOrder` | the top-level `sidebar` list (remove it) | -| `subagents.hideFinishedAfter` | `subagents.hideFinishedAfterMinutes` | -| `subagents.hideNestedAfter` | `subagents.hideNestedAfterSeconds` | -| Status keys at the file's root (`preset`, `segments`, `enabled`…) | the same keys under `status` | diff --git a/packages/client/src/cli/preview.ts b/packages/client/src/cli/preview.ts index a5c4c7b2..a0d16887 100644 --- a/packages/client/src/cli/preview.ts +++ b/packages/client/src/cli/preview.ts @@ -12,8 +12,8 @@ import { readFileSync } from "node:fs" import { emptyBlock, GLYPH, type ToneRun } from "../design.ts" -import { loadSettings, noticeText, type SettingsNotice } from "../settings.ts" -import { settingsReport, settingsText } from "../setup.ts" +import { loadSettings, noticeText, type SettingsNotice } from "../settings/index.ts" +import { settingsReport, settingsText } from "../setup/index.ts" const HEX: Record = { text: "#eeeeee", @@ -54,7 +54,7 @@ const widths = arg("--width") ? [Number(arg("--width"))] : [24, 36] const config = arg("--config") const sample = { statusline: { preset: "sidebar" }, - shell: { sidebarOrder: 170 }, + shell: { sidebar: [1] }, sidebar: ["status", "shells", "trust"], } const settings = loadSettings({ diff --git a/packages/client/src/cli/reference.ts b/packages/client/src/cli/reference.ts index a0d4e905..b77c7cda 100644 --- a/packages/client/src/cli/reference.ts +++ b/packages/client/src/cli/reference.ts @@ -9,8 +9,8 @@ import { writeFileSync } from "node:fs" import { join } from "node:path" -import { settingsReference } from "../catalog.ts" -import { SETUP_SKILL_DIR } from "../setup.ts" +import { settingsReference } from "../settings/catalog.ts" +import { SETUP_SKILL_DIR } from "../setup/index.ts" const file = join(SETUP_SKILL_DIR, "references", "settings.md") writeFileSync(file, settingsReference()) diff --git a/packages/client/src/conventions.ts b/packages/client/src/conventions.ts deleted file mode 100644 index 9cf42671..00000000 --- a/packages/client/src/conventions.ts +++ /dev/null @@ -1,403 +0,0 @@ -/** - * The second half of `/cockpit-setup`: making Cockpit fit how a person works. What the agent needs - * for it, read fresh — what this project runs that never ends, which ticket keys its history uses, - * where its pull requests go, what the instruction files say now — and the one write it makes: a - * marked `## Cockpit conventions` section in an `AGENTS.md`. - * - * Conventions only. How to use Cockpit is already in every request's system prompt (each bay's - * guidance); what a project's own instructions add is the part no bay can know — "the dev server is - * `bun dev`", "tickets are COM-…". - * - * The section is written here rather than by the agent's edit tool because it has to be the same - * section every time: a rerun replaces it in place, everything around it is kept byte for byte, and a - * model asked to "update the section" in someone's instructions file is one rewrite away from - * tidying the rest. - */ - -import { execFileSync } from "node:child_process" -import { join } from "node:path" - -// ── The section ──────────────────────────────────────────────────────────────────────────────── - -export const SECTION_START = - "" -export const SECTION_END = "" -export const SECTION_HEADING = "## Cockpit conventions" - -/** Matches a start marker however its tail was edited: the prefix is what marks it. */ -const START = //g -const END = //g - -export interface Section { - /** Offsets of the start marker and just past the end marker. */ - start: number - end: number - /** What is between the heading and the end marker, trimmed. */ - body: string -} - -export type Sections = { ok: true; sections: Section[] } | { ok: false; line: number } - -const lineAt = (text: string, offset: number) => text.slice(0, offset).split("\n").length - -/** Every marked section in a file, in order. A start with no end is an error naming its line. */ -export function findSections(text: string): Sections { - const sections: Section[] = [] - const ends = [...text.matchAll(END)] - for (const start of text.matchAll(START)) { - const from = start.index ?? 0 - if (sections.some((section) => from < section.end)) continue - const end = ends.find((each) => (each.index ?? 0) > from) - if (!end) return { ok: false, line: lineAt(text, from) } - const stop = (end.index ?? 0) + end[0].length - const inner = text.slice(from + start[0].length, end.index).trim() - const body = inner.startsWith(SECTION_HEADING) ? inner.slice(SECTION_HEADING.length).trim() : inner - sections.push({ start: from, end: stop, body }) - } - return { ok: true, sections } -} - -/** The body as the agent hands it, without a heading of its own (the section brings one). */ -export function cleanBody(body: string): string { - const text = body.replaceAll("\r\n", "\n").trim() - return text.startsWith(SECTION_HEADING) ? text.slice(SECTION_HEADING.length).trim() : text -} - -export function sectionText(body: string, eol = "\n"): string { - return [SECTION_START, SECTION_HEADING, "", ...cleanBody(body).split("\n"), SECTION_END].join(eol) -} - -export type WriteAction = "created" | "added" | "updated" | "unchanged" | "removed" | "absent" - -export type Written = - | { ok: true; action: WriteAction; text: string | undefined; merged: number } - | { ok: false; error: string } - -/** Cuts `[start, end)` and the blank line that set it apart, so removing a section undoes adding it. */ -function cut(text: string, start: number, end: number): string { - let from = start - while (from > 0 && (text[from - 1] === "\n" || text[from - 1] === "\r")) from-- - const before = text.slice(0, from) - let after = text.slice(end) - if (from === 0) after = after.replace(/^\r?\n/, "") - return before.length > 0 && after.length === 0 ? `${before}\n` : before + after -} - -/** - * The file with its section set to `body`: replaced where it is, added at the end where there is - * none, removed when `body` is empty. Everything outside the section is kept as it was. Several - * sections (two runs that raced, a paste) become one, where the first was. `text` undefined is a file - * that does not exist; `text: undefined` back means delete it — it held nothing but the section. - */ -export function writeSection(text: string | undefined, body: string): Written { - const content = cleanBody(body) - const found = findSections(text ?? "") - if (!found.ok) - return { - ok: false, - error: `the Cockpit section that starts at line ${found.line} has no end marker. Add \`${SECTION_END}\` on its own line where the section ends (or remove the start marker), then call this again.`, - } - /** CRLF only for a file written that way throughout; a stray `\r\n` in a LF file is not a style. */ - const eol = text?.includes("\r\n") && !/(^|[^\r])\n/.test(text) ? "\r\n" : "\n" - const [first, ...extra] = found.sections - if (!first) { - if (!content) return { ok: true, action: "absent", text, merged: 0 } - if (text === undefined || text.trim() === "") - return { - ok: true, - action: text === undefined ? "created" : "added", - text: `${sectionText(content, eol)}${eol}`, - merged: 0, - } - const sep = text.endsWith("\n") ? eol : `${eol}${eol}` - return { ok: true, action: "added", text: `${text}${sep}${sectionText(content, eol)}${eol}`, merged: 0 } - } - let out = text as string - for (const section of [...extra].reverse()) out = cut(out, section.start, section.end) - if (!content) { - out = cut(out, first.start, first.end) - return { ok: true, action: "removed", text: out.trim() === "" ? undefined : out, merged: extra.length } - } - out = out.slice(0, first.start) + sectionText(content, eol) + out.slice(first.end) - const action = extra.length === 0 && first.body === content && out === text ? "unchanged" : "updated" - return { ok: true, action, text: out, merged: extra.length } -} - -// ── Where it goes ────────────────────────────────────────────────────────────────────────────── - -export type InstructionScope = "project" | "global" - -export interface InstructionFile { - scope: InstructionScope - path: string - exists: boolean - /** The section's body when there is one; several are reported as a count. */ - sections: number - body?: string - /** The line of a start marker with no end. */ - unclosed?: number - /** - * OpenCode 1 reads the first file it finds of a list and stops there: `AGENTS.md` before the - * project's `CLAUDE.md` (and `CONTEXT.md`), the global `AGENTS.md` before `~/.claude/CLAUDE.md`. - * Read off 1.18.32's instruction loader. So creating this file stops OpenCode 1 reading that one. - */ - shadows?: string -} - -export function instructionPaths( - directory: string, - env: Readonly>, - home: string, -): Record { - return { - project: join(directory, "AGENTS.md"), - global: join(env.XDG_CONFIG_HOME || join(home, ".config"), "opencode", "AGENTS.md"), - } -} - -export function readInstructions( - opencode: 1 | 2, - directory: string, - env: Readonly>, - home: string, - read: (path: string) => string | undefined, -): InstructionFile[] { - const paths = instructionPaths(directory, env, home) - const fallbacks: Record = { - project: [join(directory, "CLAUDE.md"), join(directory, "CONTEXT.md")], - global: [join(home, ".claude", "CLAUDE.md")], - } - return (["project", "global"] as const).map((scope) => { - const path = paths[scope] - const text = read(path) - const found = findSections(text ?? "") - const shadows = - opencode === 1 && text === undefined - ? fallbacks[scope].find((file) => read(file) !== undefined) - : undefined - return { - scope, - path, - exists: text !== undefined, - sections: found.ok ? found.sections.length : 0, - ...(found.ok && found.sections[0] ? { body: found.sections[0].body } : {}), - ...(found.ok ? {} : { unclosed: found.line }), - ...(shadows ? { shadows } : {}), - } - }) -} - -// ── The project ──────────────────────────────────────────────────────────────────────────────── - -/** A command that does not end on its own: a dev server, a watcher, `docker compose up`. */ -export interface LongRunning { - command: string - /** Where it was found, as the person would recognise it: `package.json "dev": "vite"`. */ - from: string - /** A short name for the shell: the script's or the target's. */ - name: string -} - -export interface ProjectFacts { - /** `bun`, `pnpm`, `yarn` or `npm`, from the lockfile; undefined with no package.json. */ - packageManager?: string - longRunning: LongRunning[] - /** The package.json scripts not counted as long-running. */ - otherScripts: string[] - /** Ticket prefixes seen in branch names and recent commit subjects, most used first. */ - tickets: { prefix: string; count: number; example: string }[] - /** Where pull requests go: each remote as `host/owner/repo`, credentials removed. */ - remotes: { name: string; repo: string }[] -} - -const LONG_NAME = - /^(dev|start|serve|server|watch|preview|storybook)$|[:_-](dev|watch|serve|server)$|^(dev|watch|serve)[:_-]/ -const LONG_COMMAND = - /--watch\b|(^|\s)watch(\s|$)|\bnodemon\b|\bvite\b(?!\s+build)|\bnext (dev|start)\b|\bnuxt dev\b|\bastro dev\b|\bstorybook dev\b|\bwebpack serve\b|\bdocker[- ]compose up\b|\bwrangler dev\b|\bng serve\b|\bexpo start\b|\btsx watch\b|\bbun --hot\b|\bbun --watch\b/ - -const isLong = (name: string, command: string) => LONG_NAME.test(name) || LONG_COMMAND.test(command) - -const LOCKFILES: [string, string][] = [ - ["bun.lock", "bun"], - ["bun.lockb", "bun"], - ["pnpm-lock.yaml", "pnpm"], - ["yarn.lock", "yarn"], - ["package-lock.json", "npm"], -] - -const runScript = (manager: string, script: string) => - manager === "yarn" ? `yarn ${script}` : `${manager} run ${script}` - -function packageScripts( - directory: string, - read: (path: string) => string | undefined, -): Pick { - const text = read(join(directory, "package.json")) - if (text === undefined) return { longRunning: [], otherScripts: [] } - let scripts: Record = {} - let declared: string | undefined - try { - const parsed = JSON.parse(text) as { scripts?: Record; packageManager?: unknown } - scripts = parsed.scripts ?? {} - if (typeof parsed.packageManager === "string") declared = parsed.packageManager.split("@")[0] - } catch { - return { longRunning: [], otherScripts: [] } - } - const manager = - LOCKFILES.find(([file]) => read(join(directory, file)) !== undefined)?.[1] ?? declared ?? "npm" - const longRunning: LongRunning[] = [] - const otherScripts: string[] = [] - for (const [name, command] of Object.entries(scripts)) { - if (typeof command !== "string") continue - if (isLong(name, command)) - longRunning.push({ - command: runScript(manager, name), - from: `package.json "${name}": "${command}"`, - name, - }) - else otherScripts.push(name) - } - return { packageManager: manager, longRunning, otherScripts } -} - -/** `make dev`, `make watch`…: targets named for a server or a watcher, or whose recipe is one. */ -function makeTargets(directory: string, read: (path: string) => string | undefined): LongRunning[] { - const text = read(join(directory, "Makefile")) ?? read(join(directory, "makefile")) - if (text === undefined) return [] - const out: LongRunning[] = [] - const lines = text.split("\n") - lines.forEach((line, at) => { - const target = /^([A-Za-z][\w.-]*)\s*:(?!=)/.exec(line)?.[1] - if (!target || target.startsWith(".")) return - const recipe: string[] = [] - for (const next of lines.slice(at + 1)) { - if (!next.startsWith("\t")) break - recipe.push(next.trim()) - } - if (isLong(target, recipe.join(" "))) - out.push({ command: `make ${target}`, from: `Makefile target "${target}"`, name: target }) - }) - return out -} - -const COMPOSE_FILES = ["compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"] - -function composeUp(directory: string, read: (path: string) => string | undefined): LongRunning[] { - for (const file of COMPOSE_FILES) { - const text = read(join(directory, file)) - if (text === undefined) continue - const services: string[] = [] - let inServices = false - for (const line of text.split("\n")) { - if (/^\S/.test(line)) inServices = /^services:\s*$/.test(line) - else if (inServices) { - const name = /^ {2}([\w.-]+):\s*$/.exec(line)?.[1] - if (name) services.push(name) - } - } - const named = services.length > 0 ? ` (services: ${services.join(", ")})` : "" - return [{ command: "docker compose up", from: `${file}${named}`, name: "compose" }] - } - return [] -} - -function procfile(directory: string, read: (path: string) => string | undefined): LongRunning[] { - return ["Procfile.dev", "Procfile"].flatMap((file) => - (read(join(directory, file)) ?? "").split("\n").flatMap((line) => { - const match = /^([\w-]+):\s*(.+)$/.exec(line.trim()) - return match - ? [{ command: (match[2] as string).trim(), from: `${file} "${match[1]}"`, name: match[1] as string }] - : [] - }), - ) -} - -/** Looks like a ticket key but is a standard or an encoding. */ -const NOT_TICKETS = new Set([ - "UTF", - "ISO", - "SHA", - "RFC", - "HTTP", - "TLS", - "CVE", - "ES", - "IE", - "MD", - "AES", - "RSA", - "WCAG", - "PEP", -]) - -export function ticketPrefixes(lines: readonly string[]): ProjectFacts["tickets"] { - const seen = new Map() - for (const line of lines) { - for (const match of line.matchAll(/\b([A-Z][A-Z0-9]{1,9})-(\d{1,6})\b/g)) { - const prefix = match[1] as string - if (NOT_TICKETS.has(prefix)) continue - const entry = seen.get(prefix) ?? { count: 0, example: match[0] } - entry.count++ - seen.set(prefix, entry) - } - } - return [...seen.entries()] - .map(([prefix, { count, example }]) => ({ prefix, count, example })) - .filter((ticket) => ticket.count >= 2) - .sort((a, b) => b.count - a.count) - .slice(0, 5) -} - -/** `git@github.com:acme/app.git`, `https://user:tok@github.com/acme/app` → `github.com/acme/app`. */ -export function repoOf(url: string): string { - const scp = /^[\w.-]+@([^:/]+):(.+)$/.exec(url) - const path = scp ? `${scp[1]}/${scp[2]}` : url.replace(/^[a-z+]+:\/\//i, "").replace(/^[^@/]*@/, "") - return path.replace(/\.git$/, "").replace(/\/+$/, "") -} - -/** Runs git in the project, or says nothing. */ -export type GitRun = (args: string[]) => string | undefined - -export function gitIn(directory: string): GitRun { - return (args) => { - try { - return execFileSync("git", args, { - cwd: directory, - encoding: "utf8", - timeout: 3000, - stdio: ["ignore", "pipe", "ignore"], - }) - } catch { - return undefined - } - } -} - -export function projectFacts( - directory: string, - read: (path: string) => string | undefined, - git: GitRun = gitIn(directory), -): ProjectFacts { - const scripts = packageScripts(directory, read) - const history = [ - ...(git(["branch", "--all", "--format=%(refname:short)"]) ?? "").split("\n"), - ...(git(["log", "-200", "--format=%s"]) ?? "").split("\n"), - ] - const remotes = new Map() - for (const line of (git(["remote", "-v"]) ?? "").split("\n")) { - const [name, url] = line.split(/\s+/) - if (name && url && !remotes.has(name)) remotes.set(name, repoOf(url)) - } - return { - ...(scripts.packageManager ? { packageManager: scripts.packageManager } : {}), - longRunning: [ - ...scripts.longRunning, - ...makeTargets(directory, read), - ...composeUp(directory, read), - ...procfile(directory, read), - ], - otherScripts: scripts.otherScripts, - tickets: ticketPrefixes(history), - remotes: [...remotes.entries()].map(([name, repo]) => ({ name, repo })), - } -} diff --git a/packages/client/src/client.ts b/packages/client/src/daemon/client.ts similarity index 100% rename from packages/client/src/client.ts rename to packages/client/src/daemon/client.ts diff --git a/packages/client/src/connection.ts b/packages/client/src/daemon/connection.ts similarity index 100% rename from packages/client/src/connection.ts rename to packages/client/src/daemon/connection.ts diff --git a/packages/client/src/spawn.ts b/packages/client/src/daemon/spawn.ts similarity index 100% rename from packages/client/src/spawn.ts rename to packages/client/src/daemon/spawn.ts diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 13935c9f..c1339ff4 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -4,6 +4,6 @@ export { type ConnectionState, compareBuilds, type OutdatedDaemon, -} from "./client.ts" +} from "./daemon/client.ts" +export type { SpawnOptions } from "./daemon/spawn.ts" export { claimedFeatures, claimFeature, duplicateFeatureMessage, type FeatureClaim } from "./feature.ts" -export type { SpawnOptions } from "./spawn.ts" diff --git a/packages/client/src/elements.tsx b/packages/client/src/opencode/elements.tsx similarity index 96% rename from packages/client/src/elements.tsx rename to packages/client/src/opencode/elements.tsx index 1c9030b6..34ab9a4d 100644 --- a/packages/client/src/elements.tsx +++ b/packages/client/src/opencode/elements.tsx @@ -1,7 +1,7 @@ /** @jsxImportSource @opentui/solid */ import type { ColorInput } from "@opentui/core" import type { JSX } from "solid-js" -import { EMPTY_TEXT } from "./design.ts" +import { EMPTY_TEXT } from "../design.ts" /** * Plain text as an element OpenCode 1 can place in one of its dialogs. diff --git a/packages/client/src/opencode/host/index.ts b/packages/client/src/opencode/host/index.ts new file mode 100644 index 00000000..0441581a --- /dev/null +++ b/packages/client/src/opencode/host/index.ts @@ -0,0 +1,214 @@ +/** + * What a Cockpit TUI bay needs from OpenCode, whichever OpenCode it is. + * + * OpenCode 2 replaced the plugin API (docs/opencode/v2.md). Rather than write every bay twice, a bay + * talks to `Host` — exactly the ~30 calls the bays make, named the way v1 names them — and each + * version supplies one: `fromV1(api)` wraps the v1 API almost as it is, `fromV2(ctx)` builds the same + * shape on the v2 context. `dualTui` turns one bay into an entry both versions load: v1 calls + * `tui(api)`, v2 calls `setup(ctx)`. + * + * Nothing of OpenCode is imported at runtime: the v2 context is described by structural types. Here + * is the shape and the entry; `v1.ts` and `v2.ts` build it, `keymap.ts` has the key helpers. + */ + +import type { TuiPluginApi, TuiThemeCurrent } from "@opencode-ai/plugin/tui" +import type { CliRenderer, KeyEvent } from "@opentui/core" +import type { JSX } from "solid-js" +import { cockpitVersion, createLog, type Log } from "../../log.ts" +import { registerSetup } from "../../setup/palette.ts" +import { registerServiceCheck } from "../service.ts" +import { fromV1 } from "./v1.ts" +import { fromV2, type V2Context } from "./v2.ts" + +export { type Binding, type BindingValue, bindingLookup } from "./keymap.ts" +export { fromV1, useApiLayer } from "./v1.ts" +export { fromV2, layerToV2, themeFromV2, type V2Context, v2Group } from "./v2.ts" + +type V1Layer = Parameters[0] +export type Layer = V1Layer +export type Theme = TuiThemeCurrent + +/** A key the host saw, and the way to keep it from anything else. */ +export interface InterceptContext { + event: KeyEvent + consume: (options?: { preventDefault?: boolean; stopPropagation?: boolean }) => void +} + +export interface SelectOption { + title: string + value: Value + description?: string + footer?: string + category?: string + disabled?: boolean +} + +/** Which slot a render goes into. The names are v1's; `fromV2` maps them onto v2's paths. */ +export type SlotName = "app_bottom" | "sidebar_content" | "home_bottom" | "session_prompt_right" +export type SlotRender = (input?: { sessionID?: string }) => JSX.Element + +export interface Host { + /** Which OpenCode this is. Bays should rarely need it. */ + readonly version: 1 | 2 + readonly renderer: CliRenderer + readonly theme: { readonly current: Theme } + readonly state: { + readonly path: { readonly directory: string; readonly worktree: string } + readonly vcs: { readonly branch?: string; readonly default_branch?: string } | undefined + } + readonly route: { readonly current: { name: string; params?: Record } } + readonly kv: { + get(key: string, fallback: T): T + set(key: string, value: unknown): void + } + readonly ui: { + toast(options: { + title?: string + message: string + variant?: "info" | "success" | "warning" | "error" + duration?: number + }): void + readonly dialog: { + replace(render: () => JSX.Element, onClose?: () => void): void + clear(): void + setSize(size: "medium" | "large" | "xlarge"): void + readonly depth: number + } + select(options: { + title: string + placeholder?: string + current?: Value + options: SelectOption[] + }): Promise + /** + * Text from the person. `rich` draws above the field where the host can (v1's component dialog); + * elsewhere `description` says it in words. + */ + prompt(options: { + title: string + description?: string + rich?: () => JSX.Element + placeholder?: string + value?: string + }): Promise + confirm(options: { title: string; message: string }): Promise + } + readonly keymap: { + /** A global layer, until the returned function disposes it. */ + registerLayer(layer: Layer): () => void + /** A layer owned by the calling component, for as long as it is mounted. */ + useLayer(layer: () => Layer): void + /** Every key before the keymap sees it; `consume` keeps it from everyone else. */ + intercept(handler: (context: InterceptContext) => void, options?: { priority?: number }): () => void + /** The key a command is bound to, formatted for a hint. */ + shortcut(command: string): string + } + readonly slots: { + register(input: { order?: number; slots: Partial> }): void + } + readonly lifecycle: { onDispose(fn: () => void): void } + /** The shared log (`cockpit.log`), scoped `tui`; a bay takes `log.child("shell")`. */ + readonly log: Log + /** Hands text to a conversation, as if the person had sent it. */ + promptSession(sessionID: string, text: string): Promise + /** The v1 API itself, for the calls that have no v2 equivalent. */ + readonly v1?: TuiPluginApi + /** The v2 context itself, likewise. */ + readonly v2?: V2Context +} + +export type Start = (host: Host, options: Record | undefined) => Promise | void + +/** + * A TUI entry both OpenCodes load: v1 calls `tui(api, options)`, v2 calls `setup(ctx)` and runs the + * returned cleanup when it unloads the plugin. + */ +export function dualTui(id: string, start: Start) { + /** + * What loaded, where, and anything that stopped it: written before the bay runs, so a bay that never + * draws still says it was loaded and on which OpenCode, and one that throws leaves its stack. + */ + const run = async ( + host: Host, + options: Record | undefined, + opencode: string | undefined, + ) => { + host.log.info("start", { + entry: id, + opencode: host.version, + opencodeVersion: opencode, + cockpit: cockpitVersion(), + }) + /** `/cockpit-setup`: every entry offers it, the first in a window registers it (setup/). */ + try { + registerSetup(host, id) + } catch (error) { + host.log.warn("setup: not registered", { entry: id, error }) + } + /** OpenCode 2: one toast when the background service still runs an older Cockpit (service.ts). */ + try { + registerServiceCheck(host, id) + } catch (error) { + host.log.warn("service: not checked", { entry: id, error }) + } + try { + await start(host, options) + } catch (error) { + host.log.error("start failed", { entry: id, error }) + throw error + } + } + return { + id, + tui: async (api: TuiPluginApi, options?: unknown) => { + const host = fromV1(api, createLog("tui")) + await run(host, options as Record | undefined, api.app?.version) + }, + setup: async (ctx: V2Context) => { + const cleanups: (() => void)[] = [] + const host = fromV2(ctx, (fn) => cleanups.push(fn), createLog("tui")) + await run(host, ctx.options as Record, undefined) + return () => { + for (const fn of cleanups.reverse()) { + try { + fn() + } catch (error) { + // one bay's cleanup failing must not keep the others from running + host.log.warn("cleanup failed", { entry: id, error }) + } + } + } + }, + } +} + +/** + * Text pasted into the terminal (`ctrl+v`, `cmd+v`), for a surface with a text field of its own. + * + * A paste arrives as one `paste` event with the bytes, not as keys, so a field fed by `intercept` + * never saw it — it went to OpenCode's prompt underneath instead. `handler` answers whether it took + * the text; taken, nobody else gets it. Both OpenCodes hand over the same OpenTUI renderer. + */ +export function onPaste(host: Pick, handler: (text: string) => boolean): () => void { + const input = host.renderer.keyInput as unknown as { + prependListener(event: "paste", fn: (event: PasteLike) => void): void + off(event: "paste", fn: (event: PasteLike) => void): void + } + const decoder = new TextDecoder() + const listener = (event: PasteLike) => { + const text = event.text ?? (event.bytes ? decoder.decode(event.bytes) : "") + if (!text || !handler(text)) return + event.preventDefault?.() + event.stopPropagation?.() + } + input.prependListener("paste", listener) + return () => input.off("paste", listener) +} + +interface PasteLike { + bytes?: Uint8Array + /** Older OpenTUI releases carried the text itself. */ + text?: string + preventDefault?: () => void + stopPropagation?: () => void +} diff --git a/packages/client/src/opencode/host/keymap.ts b/packages/client/src/opencode/host/keymap.ts new file mode 100644 index 00000000..2124cd8e --- /dev/null +++ b/packages/client/src/opencode/host/keymap.ts @@ -0,0 +1,33 @@ +/** + * Keys, without `@opentui/keymap`: the two helpers the bays used, re-made from nothing. + */ + +/** + * OpenCode 2 lets a plugin import `@opentui/core` and `solid-js` and nothing else of OpenTUI: an + * import of `@opentui/keymap` fails, and a plugin whose module fails to load is skipped without a + * word (measured on 2.0.15). So the two keymap helpers the bays used are re-made here from nothing. + */ + +/** A configured key: a key string, several, an object with a `key`, or `false`/"none" for unbound. */ +export type BindingValue = string | false | { key: string } | readonly (string | { key: string })[] +export interface Binding { + key: string + cmd: string + [field: string]: unknown +} + +/** `createBindingLookup` as the bays used it: config `{ command: key }` → `{ key, cmd }` bindings. */ +export function bindingLookup(config: Readonly>) { + const byCommand = new Map() + for (const [cmd, value] of Object.entries(config)) { + if (value === undefined || value === false || value === "none") continue + const items = (Array.isArray(value) ? value : [value]) as (string | { key: string })[] + const bindings = items.map((item) => (typeof item === "string" ? { key: item, cmd } : { ...item, cmd })) + if (bindings.length > 0) byCommand.set(cmd, bindings) + } + return { + get: (command: string): Binding[] => byCommand.get(command) ?? [], + gather: (_name: string, commands: readonly string[]): Binding[] => + commands.flatMap((command) => byCommand.get(command) ?? []), + } +} diff --git a/packages/client/src/opencode/host/v1.ts b/packages/client/src/opencode/host/v1.ts new file mode 100644 index 00000000..5507231e --- /dev/null +++ b/packages/client/src/opencode/host/v1.ts @@ -0,0 +1,129 @@ +/** + * OpenCode 1's plugin API as a `Host`: wrapped almost as it is. + */ + +import type { TuiDialogSelectOption, TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { CliRenderer } from "@opentui/core" +import { createComponent, type JSX, onCleanup } from "solid-js" +import { type Log, silentLog } from "../../log.ts" +import { textElement } from "../elements.tsx" +import type { Host, Layer, SelectOption } from "./index.ts" + +/** A v1 layer owned by the calling component: registered now, disposed when the component goes. */ +export function useApiLayer(api: TuiPluginApi, layer: () => Layer): void { + const dispose = api.keymap.registerLayer(layer()) + onCleanup(dispose) +} + +export function fromV1(api: TuiPluginApi, log: Log = silentLog): Host { + const pick = ( + options: Parameters[0] & { options: SelectOption[] }, + ): Promise => + new Promise((resolve) => { + let done = false + const finish = (value: Value | undefined) => { + if (done) return + done = true + resolve(value) + } + api.ui.dialog.replace( + () => + createComponent( + api.ui.DialogSelect as (props: never) => JSX.Element, + { + title: options.title, + ...(options.placeholder ? { placeholder: options.placeholder } : {}), + ...(options.current !== undefined ? { current: options.current } : {}), + options: options.options as TuiDialogSelectOption[], + /** Answer first: clearing fires the close handler, which would settle it as cancelled. */ + onSelect: (option: TuiDialogSelectOption) => { + finish(option.value) + api.ui.dialog.clear() + }, + } as never, + ), + () => finish(undefined), + ) + }) + + return { + version: 1, + renderer: api.renderer as CliRenderer, + theme: api.theme, + state: api.state as Host["state"], + route: api.route as Host["route"], + kv: api.kv as Host["kv"], + ui: { + toast: (options) => api.ui.toast(options), + dialog: api.ui.dialog, + select: pick, + prompt: (options) => + new Promise((resolve) => { + api.ui.dialog.replace( + () => + createComponent( + api.ui.DialogPrompt as (props: never) => JSX.Element, + { + title: options.title, + ...(options.rich + ? { description: options.rich } + : options.description + ? { description: textElement(options.description) } + : {}), + placeholder: options.placeholder ?? "", + value: options.value ?? "", + /** Answer first: clearing fires the close handler, which would settle it as cancelled. */ + onConfirm: (text: string) => { + resolve(text) + api.ui.dialog.clear() + }, + onCancel: () => { + resolve(undefined) + api.ui.dialog.clear() + }, + } as never, + ), + () => resolve(undefined), + ) + }), + confirm: (options) => + new Promise((resolve) => { + api.ui.dialog.replace( + () => + createComponent( + api.ui.DialogConfirm as (props: never) => JSX.Element, + { + title: options.title, + message: options.message, + onConfirm: () => { + resolve(true) + api.ui.dialog.clear() + }, + onCancel: () => { + resolve(false) + api.ui.dialog.clear() + }, + } as never, + ), + () => resolve(false), + ) + }), + }, + keymap: { + registerLayer: (layer) => api.keymap.registerLayer(layer), + useLayer: (layer) => useApiLayer(api, layer), + intercept: (handler, options) => api.keymap.intercept("key", handler as never, options), + shortcut: (command) => { + const bindings = api.keymap.getCommandBindings({ visibility: "registered", commands: [command] }) + return api.keys.formatBindings(bindings.get(command)) ?? "" + }, + }, + slots: { register: (input) => api.slots.register(input as never) }, + lifecycle: api.lifecycle, + log, + promptSession: async (sessionID, text) => { + await api.client.session.promptAsync({ sessionID, parts: [{ type: "text", text }] }) + }, + v1: api, + } +} diff --git a/packages/client/src/host.ts b/packages/client/src/opencode/host/v2.ts similarity index 53% rename from packages/client/src/host.ts rename to packages/client/src/opencode/host/v2.ts index a157dbff..8b53a1ae 100644 --- a/packages/client/src/host.ts +++ b/packages/client/src/opencode/host/v2.ts @@ -1,271 +1,13 @@ /** - * What a Cockpit TUI bay needs from OpenCode, whichever OpenCode it is. - * - * OpenCode 2 replaced the plugin API (docs/opencode/v2.md). Rather than write every bay twice, a bay - * talks to `Host` — exactly the ~30 calls the bays make, named the way v1 names them — and each - * version supplies one: `fromV1(api)` wraps the v1 API almost as it is, `fromV2(ctx)` builds the same - * shape on the v2 context. `dualTui` turns one bay into an entry both versions load: v1 calls - * `tui(api)`, v2 calls `setup(ctx)`. - * - * Nothing of OpenCode is imported at runtime here, and nothing of OpenTUI but the two helpers the - * v1 half already relied on: the v2 context is described by the structural types below. + * OpenCode 2's CLI plugin context as a `Host`: the same shape built on the v2 context, which is + * described by the structural types here — nothing of OpenCode is imported at runtime. */ -import type { TuiDialogSelectOption, TuiPluginApi, TuiThemeCurrent } from "@opencode-ai/plugin/tui" +import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui" import type { CliRenderer, KeyEvent } from "@opentui/core" -import { createComponent, createRoot, getOwner, type JSX, type Owner, onCleanup } from "solid-js" -import { textElement } from "./elements.tsx" -import { cockpitVersion, createLog, type Log, silentLog } from "./log.ts" -import { registerServiceCheck } from "./service.ts" -import { registerSetup } from "./setup/palette.ts" - -type V1Layer = Parameters[0] -export type Layer = V1Layer -export type Theme = TuiThemeCurrent - -/** A key the host saw, and the way to keep it from anything else. */ -export interface InterceptContext { - event: KeyEvent - consume: (options?: { preventDefault?: boolean; stopPropagation?: boolean }) => void -} - -export interface SelectOption { - title: string - value: Value - description?: string - footer?: string - category?: string - disabled?: boolean -} - -/** Which slot a render goes into. The names are v1's; `fromV2` maps them onto v2's paths. */ -export type SlotName = "app_bottom" | "sidebar_content" | "home_bottom" | "session_prompt_right" -export type SlotRender = (input?: { sessionID?: string }) => JSX.Element - -export interface Host { - /** Which OpenCode this is. Bays should rarely need it. */ - readonly version: 1 | 2 - readonly renderer: CliRenderer - readonly theme: { readonly current: Theme } - readonly state: { - readonly path: { readonly directory: string; readonly worktree: string } - readonly vcs: { readonly branch?: string; readonly default_branch?: string } | undefined - } - readonly route: { readonly current: { name: string; params?: Record } } - readonly kv: { - get(key: string, fallback: T): T - set(key: string, value: unknown): void - } - readonly ui: { - toast(options: { - title?: string - message: string - variant?: "info" | "success" | "warning" | "error" - duration?: number - }): void - readonly dialog: { - replace(render: () => JSX.Element, onClose?: () => void): void - clear(): void - setSize(size: "medium" | "large" | "xlarge"): void - readonly depth: number - } - select(options: { - title: string - placeholder?: string - current?: Value - options: SelectOption[] - }): Promise - /** - * Text from the person. `rich` draws above the field where the host can (v1's component dialog); - * elsewhere `description` says it in words. - */ - prompt(options: { - title: string - description?: string - rich?: () => JSX.Element - placeholder?: string - value?: string - }): Promise - confirm(options: { title: string; message: string }): Promise - } - readonly keymap: { - /** A global layer, until the returned function disposes it. */ - registerLayer(layer: Layer): () => void - /** A layer owned by the calling component, for as long as it is mounted. */ - useLayer(layer: () => Layer): void - /** Every key before the keymap sees it; `consume` keeps it from everyone else. */ - intercept(handler: (context: InterceptContext) => void, options?: { priority?: number }): () => void - /** The key a command is bound to, formatted for a hint. */ - shortcut(command: string): string - } - readonly slots: { - register(input: { order?: number; slots: Partial> }): void - } - readonly lifecycle: { onDispose(fn: () => void): void } - /** The shared log (`cockpit.log`), scoped `tui`; a bay takes `log.child("shell")`. */ - readonly log: Log - /** Hands text to a conversation, as if the person had sent it. */ - promptSession(sessionID: string, text: string): Promise - /** The v1 API itself, for the calls that have no v2 equivalent. */ - readonly v1?: TuiPluginApi - /** The v2 context itself, likewise. */ - readonly v2?: V2Context -} - -/* ─── keys, without @opentui/keymap ───────────────────────────────────────────────────────────── */ - -/** - * OpenCode 2 lets a plugin import `@opentui/core` and `solid-js` and nothing else of OpenTUI: an - * import of `@opentui/keymap` fails, and a plugin whose module fails to load is skipped without a - * word (measured on 2.0.15). So the two keymap helpers the bays used are re-made here from nothing. - */ - -/** A configured key: a key string, several, an object with a `key`, or `false`/"none" for unbound. */ -export type BindingValue = string | false | { key: string } | readonly (string | { key: string })[] -export interface Binding { - key: string - cmd: string - [field: string]: unknown -} - -/** `createBindingLookup` as the bays used it: config `{ command: key }` → `{ key, cmd }` bindings. */ -export function bindingLookup(config: Readonly>) { - const byCommand = new Map() - for (const [cmd, value] of Object.entries(config)) { - if (value === undefined || value === false || value === "none") continue - const items = (Array.isArray(value) ? value : [value]) as (string | { key: string })[] - const bindings = items.map((item) => (typeof item === "string" ? { key: item, cmd } : { ...item, cmd })) - if (bindings.length > 0) byCommand.set(cmd, bindings) - } - return { - get: (command: string): Binding[] => byCommand.get(command) ?? [], - gather: (_name: string, commands: readonly string[]): Binding[] => - commands.flatMap((command) => byCommand.get(command) ?? []), - } -} - -/** A v1 layer owned by the calling component: registered now, disposed when the component goes. */ -export function useApiLayer(api: TuiPluginApi, layer: () => Layer): void { - const dispose = api.keymap.registerLayer(layer()) - onCleanup(dispose) -} - -/* ─── v1 ─────────────────────────────────────────────────────────────────────────────────────── */ - -export function fromV1(api: TuiPluginApi, log: Log = silentLog): Host { - const pick = ( - options: Parameters[0] & { options: SelectOption[] }, - ): Promise => - new Promise((resolve) => { - let done = false - const finish = (value: Value | undefined) => { - if (done) return - done = true - resolve(value) - } - api.ui.dialog.replace( - () => - createComponent( - api.ui.DialogSelect as (props: never) => JSX.Element, - { - title: options.title, - ...(options.placeholder ? { placeholder: options.placeholder } : {}), - ...(options.current !== undefined ? { current: options.current } : {}), - options: options.options as TuiDialogSelectOption[], - /** Answer first: clearing fires the close handler, which would settle it as cancelled. */ - onSelect: (option: TuiDialogSelectOption) => { - finish(option.value) - api.ui.dialog.clear() - }, - } as never, - ), - () => finish(undefined), - ) - }) - - return { - version: 1, - renderer: api.renderer as CliRenderer, - theme: api.theme, - state: api.state as Host["state"], - route: api.route as Host["route"], - kv: api.kv as Host["kv"], - ui: { - toast: (options) => api.ui.toast(options), - dialog: api.ui.dialog, - select: pick, - prompt: (options) => - new Promise((resolve) => { - api.ui.dialog.replace( - () => - createComponent( - api.ui.DialogPrompt as (props: never) => JSX.Element, - { - title: options.title, - ...(options.rich - ? { description: options.rich } - : options.description - ? { description: textElement(options.description) } - : {}), - placeholder: options.placeholder ?? "", - value: options.value ?? "", - /** Answer first: clearing fires the close handler, which would settle it as cancelled. */ - onConfirm: (text: string) => { - resolve(text) - api.ui.dialog.clear() - }, - onCancel: () => { - resolve(undefined) - api.ui.dialog.clear() - }, - } as never, - ), - () => resolve(undefined), - ) - }), - confirm: (options) => - new Promise((resolve) => { - api.ui.dialog.replace( - () => - createComponent( - api.ui.DialogConfirm as (props: never) => JSX.Element, - { - title: options.title, - message: options.message, - onConfirm: () => { - resolve(true) - api.ui.dialog.clear() - }, - onCancel: () => { - resolve(false) - api.ui.dialog.clear() - }, - } as never, - ), - () => resolve(false), - ) - }), - }, - keymap: { - registerLayer: (layer) => api.keymap.registerLayer(layer), - useLayer: (layer) => useApiLayer(api, layer), - intercept: (handler, options) => api.keymap.intercept("key", handler as never, options), - shortcut: (command) => { - const bindings = api.keymap.getCommandBindings({ visibility: "registered", commands: [command] }) - return api.keys.formatBindings(bindings.get(command)) ?? "" - }, - }, - slots: { register: (input) => api.slots.register(input as never) }, - lifecycle: api.lifecycle, - log, - promptSession: async (sessionID, text) => { - await api.client.session.promptAsync({ sessionID, parts: [{ type: "text", text }] }) - }, - v1: api, - } -} - -/* ─── v2 ─────────────────────────────────────────────────────────────────────────────────────── */ +import { createRoot, getOwner, type JSX, type Owner } from "solid-js" +import { type Log, silentLog } from "../../log.ts" +import type { Host, Layer, SelectOption, SlotName, SlotRender, Theme } from "./index.ts" /** The parts of OpenCode 2's CLI plugin context used here (`@opencode/plugin/tui/context`). */ export interface V2Context { @@ -700,101 +442,3 @@ export function fromV2(ctx: V2Context, onCleanup: (fn: () => void) => void, log: v2: ctx, } } - -/* ─── one entry, both versions ───────────────────────────────────────────────────────────────── */ - -export type Start = (host: Host, options: Record | undefined) => Promise | void - -/** - * A TUI entry both OpenCodes load: v1 calls `tui(api, options)`, v2 calls `setup(ctx)` and runs the - * returned cleanup when it unloads the plugin. - */ -export function dualTui(id: string, start: Start) { - /** - * What loaded, where, and anything that stopped it: written before the bay runs, so a bay that never - * draws still says it was loaded and on which OpenCode, and one that throws leaves its stack. - */ - const run = async ( - host: Host, - options: Record | undefined, - opencode: string | undefined, - ) => { - host.log.info("start", { - entry: id, - opencode: host.version, - opencodeVersion: opencode, - cockpit: cockpitVersion(), - }) - /** `/cockpit-setup`: every entry offers it, the first in a window registers it (setup.ts). */ - try { - registerSetup(host, id) - } catch (error) { - host.log.warn("setup: not registered", { entry: id, error }) - } - /** OpenCode 2: one toast when the background service still runs an older Cockpit (service.ts). */ - try { - registerServiceCheck(host, id) - } catch (error) { - host.log.warn("service: not checked", { entry: id, error }) - } - try { - await start(host, options) - } catch (error) { - host.log.error("start failed", { entry: id, error }) - throw error - } - } - return { - id, - tui: async (api: TuiPluginApi, options?: unknown) => { - const host = fromV1(api, createLog("tui")) - await run(host, options as Record | undefined, api.app?.version) - }, - setup: async (ctx: V2Context) => { - const cleanups: (() => void)[] = [] - const host = fromV2(ctx, (fn) => cleanups.push(fn), createLog("tui")) - await run(host, ctx.options as Record, undefined) - return () => { - for (const fn of cleanups.reverse()) { - try { - fn() - } catch (error) { - // one bay's cleanup failing must not keep the others from running - host.log.warn("cleanup failed", { entry: id, error }) - } - } - } - }, - } -} - -/** - * Text pasted into the terminal (`ctrl+v`, `cmd+v`), for a surface with a text field of its own. - * - * A paste arrives as one `paste` event with the bytes, not as keys, so a field fed by `intercept` - * never saw it — it went to OpenCode's prompt underneath instead. `handler` answers whether it took - * the text; taken, nobody else gets it. Both OpenCodes hand over the same OpenTUI renderer. - */ -export function onPaste(host: Pick, handler: (text: string) => boolean): () => void { - const input = host.renderer.keyInput as unknown as { - prependListener(event: "paste", fn: (event: PasteLike) => void): void - off(event: "paste", fn: (event: PasteLike) => void): void - } - const decoder = new TextDecoder() - const listener = (event: PasteLike) => { - const text = event.text ?? (event.bytes ? decoder.decode(event.bytes) : "") - if (!text || !handler(text)) return - event.preventDefault?.() - event.stopPropagation?.() - } - input.prependListener("paste", listener) - return () => input.off("paste", listener) -} - -interface PasteLike { - bytes?: Uint8Array - /** Older OpenTUI releases carried the text itself. */ - text?: string - preventDefault?: () => void - stopPropagation?: () => void -} diff --git a/packages/client/src/opencode/server/index.ts b/packages/client/src/opencode/server/index.ts new file mode 100644 index 00000000..7be67027 --- /dev/null +++ b/packages/client/src/opencode/server/index.ts @@ -0,0 +1,256 @@ +/** + * What a Cockpit server half needs from OpenCode, whichever OpenCode it is — `host.ts` for the agent + * side. + * + * A feature is written once as a `ServerStart`: given a `ServerHost` it answers with its tools and the + * few hooks it uses (`ServerParts`). `dualServer` turns that into an entry both versions load: v1 calls + * `server(input)` and gets hooks back, v2 calls `setup(ctx)` and the parts are registered on its + * domains. Tools stay written with v1's `tool()`, whose arguments are zod — v2 is handed their JSON + * Schema, and the arguments are parsed here so defaults apply the same on both. + * + * Only `tool` (for its zod) is imported from OpenCode at runtime; the v2 context is described by the + * structural types in `v2.ts`. This file is the entry both load; `parts.ts` is what a feature + * answers with, `v1.ts` and `v2.ts` build the host, `skills.ts` reads a skill's folder. + */ + +import type { Hooks, PluginInput, ToolDefinition } from "@opencode-ai/plugin" +import { cockpitVersion, createLog, type Log } from "../../log.ts" +import { setupServer } from "../../setup/server.ts" +import { recordAgent } from "../service.ts" +import { composeParts, type ServerHost, type ServerParts, type ServerStart, type ToolCall } from "./parts.ts" +import { readSkill } from "./skills.ts" +import { registerSurfaces } from "./surfaces.ts" +import { partsToV1Hooks, serverFromV1 } from "./v1.ts" +import { commandText, serverFromV2, toolToV2, type V2Event, type V2ServerContext, v2ToolCall } from "./v2.ts" + +export * from "./parts.ts" +export * from "./skills.ts" +export { keyText, openText, type Surface, surfacesLine } from "./surfaces.ts" +export { addToV1Config, partsToV1Hooks, serverFromV1, v1ToolText } from "./v1.ts" +export * from "./v2.ts" + +/** + * Every tool call through the log: a failure with its tool and stack — tools fail in front of the + * agent, not the person, so this is the only record — and, with `COCKPIT_DEBUG`, every call and how + * long it took. + */ +function loggedTools(tools: Record | undefined, log: Log) { + if (!tools) return undefined + const out: Record = {} + for (const [name, def] of Object.entries(tools)) { + out[name] = { + ...def, + execute: async (args, context) => { + const started = performance.now() + try { + const result = await def.execute(args, context) + log.debug("tool", { tool: name, ms: Math.round(performance.now() - started) }) + return result + } catch (error) { + log.warn("tool failed", { tool: name, ms: Math.round(performance.now() - started), error }) + throw error + } + }, + } + } + return out +} + +/** + * v2's event stream, kept open. A stream that ends or throws — a reloaded location, a restarted + * service — used to stay closed, and every session deleted after it left its shells behind until + * OpenCode restarted. It is opened again, waiting longer each time it fails straight away, and a + * stream that ran a while starts the wait over. + */ +export async function follow( + ctx: Pick, + signal: AbortSignal, + log: Log, + handle: (event: V2Event) => Promise, + wait: (ms: number) => Promise = (ms) => new Promise((done) => setTimeout(done, ms)), +): Promise { + let delay = 1_000 + while (!signal.aborted) { + const opened = Date.now() + try { + for await (const event of ctx.event.subscribe({ signal })) await handle(event) + if (!signal.aborted) log.warn("event stream ended; opening it again", { waitMs: delay }) + } catch (error) { + if (signal.aborted) return + log.warn("event stream failed; opening it again", { waitMs: delay, error }) + } + if (signal.aborted) return + if (Date.now() - opened > 60_000) delay = 1_000 + await wait(delay) + delay = Math.min(delay * 2, 30_000) + } +} + +/** + * The Cockpit-wide line (`surfaces.ts`) ahead of the entry's own guidance, when this entry is the one + * in the window that says it. Said to the main agent only — a subagent answers its caller, not the + * user — and to a request whose session is unknown, as the guidance is. + */ +function withSurfaces(host: ServerHost, parts: ServerParts): ServerParts { + if (!parts.surfaces?.length) return parts + const entry = registerSurfaces(host.scope, parts.surfaces) + /** Parentage never changes: asked once per session. */ + const parented = new Map() + const subagent = async (sessionID: string): Promise => { + const known = parented.get(sessionID) + if (known !== undefined) return known + const session = await host.session.get(sessionID).catch(() => undefined) + if (!session) return false + const answer = Boolean(session.parentID) + parented.set(sessionID, answer) + return answer + } + const { system, dispose } = parts + return { + ...parts, + system: async (sessionID) => { + const own = (await system?.(sessionID)) ?? [] + const line = entry.line() + if (!line || (sessionID && (await subagent(sessionID)))) return own + return [line, ...own] + }, + dispose: async () => { + entry.release() + await dispose?.() + }, + } +} + +/** Starts a feature: what loaded and where first, so a feature that never answers still said it was loaded. */ +async function begin( + id: string, + host: ServerHost, + start: ServerStart, + options: unknown, +): Promise { + host.log.info("start", { entry: id, opencode: host.version, cockpit: cockpitVersion() }) + /** Which install this agent side loaded, for a window to compare with its own (service.ts). */ + if (host.version === 2) recordAgent(host.log) + try { + /** `cockpit_settings`, the `cockpit-setup` skill and `/cockpit-setup`: the first entry here adds them. */ + const parts = withSurfaces(host, composeParts([await start(host, options), setupServer(host, id)])) + const { toolAfter } = parts + return { + ...parts, + tools: loggedTools(parts.tools, host.log), + /** Listening to a call must never break it: a failure is logged and goes no further. */ + ...(toolAfter + ? { + toolAfter: async (call: ToolCall) => { + try { + await toolAfter(call) + } catch (error) { + host.log.warn("toolAfter failed", { tool: call.tool, error }) + } + }, + } + : {}), + } + } catch (error) { + host.log.error("start failed", { entry: id, error }) + throw error + } +} + +/** One feature as an entry both versions load. */ +export function dualServer(id: string, start: ServerStart) { + return { + id, + server: async (input: PluginInput, options?: unknown): Promise => + partsToV1Hooks(await begin(id, serverFromV1(input, createLog("server")), start, options)), + setup: async (ctx: V2ServerContext) => { + /** + * v1 1.18.29+ calls `setup` too (older releases never do), with a preview context that has + * neither tools nor a location (docs/opencode/v2.md). Registering there would be registering twice. + */ + if (!ctx.tool || !ctx.location) return + const host = serverFromV2( + ctx as V2ServerContext & { location: { directory: string } }, + createLog("server"), + ) + const parts = await begin(id, host, start, ctx.options) + const tools = Object.entries(parts.tools ?? {}) + if (tools.length > 0) { + await ctx.tool.transform((editor) => { + for (const [name, def] of tools) editor.add(toolToV2(name, def, host.directory)) + }) + } + const skills = (parts.skills ?? []).flatMap((spec) => { + const skill = readSkill(spec) + if (!skill) host.log.warn("skill not found", { dir: spec.dir }) + return skill ? [skill] : [] + }) + if (skills.length > 0) { + if (ctx.skill) { + await ctx.skill.transform((editor) => { + for (const skill of skills) if (!editor.get(skill.id)) editor.add(skill) + }) + } else + host.log.warn("this OpenCode takes no skills from plugins", { skills: skills.map((s) => s.id) }) + } + const commands = parts.commands ?? [] + if (commands.length > 0) { + if (ctx.command && ctx.session.prompt) { + const prompt = ctx.session.prompt.bind(ctx.session) + await ctx.command.transform((editor) => { + for (const command of commands) + editor.add({ + name: command.name, + description: command.description, + /** + * Queued, always: v2 hands every command `delivery: "steer"`, which cuts a reply in + * progress off to start on this. Queued, an idle session starts at once and a busy + * one shows `1 queued` and waits (measured on 2.0.18). + */ + execute: async (invocation) => { + await prompt({ + sessionID: invocation.sessionID, + text: commandText(command, invocation), + delivery: "queue", + }) + }, + }) + }) + } else host.log.warn("this OpenCode takes no commands from plugins", { commands: commands.length }) + } + if (parts.toolAfter) { + const after = parts.toolAfter + if (ctx.tool.hook) { + await ctx.tool.hook("execute.after", async (event) => { + const call = v2ToolCall(event) + if (call) await after(call) + }) + } else host.log.warn("this OpenCode has no execute.after hook; tool output is not followed") + } + if (parts.system) { + const system = parts.system + await ctx.session.hook("context", async (event) => { + for (const text of await system(event.sessionID)) event.system.push({ type: "text", text }) + }) + } + const stop = new AbortController() + if (parts.sessionDeleted || parts.event) { + const { sessionDeleted: deleted, event: each } = parts + void follow(ctx, stop.signal, host.log, async (event) => { + const sessionID = event.data?.sessionID + if (deleted && event.type === "session.deleted" && sessionID) { + await deleted(sessionID).catch((error) => + host.log.warn("session cleanup failed", { sessionID, error }), + ) + } + if (each) + await Promise.resolve(each(event)).catch((error) => host.log.warn("event failed", { error })) + }) + } + return async () => { + stop.abort() + await parts.dispose?.() + } + }, + } +} diff --git a/packages/client/src/opencode/server/parts.ts b/packages/client/src/opencode/server/parts.ts new file mode 100644 index 00000000..55fbd740 --- /dev/null +++ b/packages/client/src/opencode/server/parts.ts @@ -0,0 +1,187 @@ +/** What a feature's agent half answers with, whichever OpenCode loads it, and how several combine. */ + +import type { ToolDefinition } from "@opencode-ai/plugin" +import type { Log } from "../../log.ts" +import type { Surface } from "./surfaces.ts" + +export interface ServerHost { + readonly version: 1 | 2 + /** The project this OpenCode was opened in. */ + readonly directory: string + /** One object per OpenCode instance, shared by every Cockpit plugin in it — for `claimFeature`. */ + readonly scope: object + readonly session: { + /** `agent` is the agent running in the session (`general`, `explore`…), when the host says. */ + get(id: string): Promise<{ parentID?: string; title?: string; agent?: string } | undefined> + /** + * Whether a session is working on a turn right now. OpenCode 1 only: OpenCode 2's agent side has + * no status call, so a feature that needs it there follows the session events itself. + */ + busy?(id: string): Promise + /** A session's child sessions — its subagents. OpenCode 1 only: OpenCode 2 gives plugins no list. */ + children?(id: string): Promise< + { + id: string + title?: string + parentID?: string + agent?: string + time?: { created?: number; updated?: number } + }[] + > + /** A session's messages with their parts, as OpenCode 1 stores them. OpenCode 1 only. */ + messages?(id: string): Promise<{ info: unknown; parts: unknown[] }[]> + /** + * A session's messages as OpenCode 2 stores them (`{ type: "user" | "assistant", … }`), read + * through `session.context`: what the model is given, so a compacted session starts at its + * summary. OpenCode 2 only; OpenCode 1 has `messages`. + */ + context?(id: string): Promise + /** + * A message from the plugin rather than the person, which starts a turn: v1's synthetic prompt. + * + * `steer` is for a session that is busy: OpenCode 2 then hands the message to the running turn + * instead of queueing it after that turn ends — which, for a subagent, is after it has answered + * and nobody is listening. OpenCode 1 needs nothing: a prompt sent to a busy session is picked up + * mid-run (measured, docs/opencode/agents.md). + */ + notify(id: string, text: string, options?: { steer?: boolean }): Promise + } + /** A project file's text, or undefined when there is none — or the path leaves the project. */ + readFile(path: string): Promise + /** The shared log (`cockpit.log`), scoped `server`; a feature takes `log.child("shell")`. */ + readonly log: Log +} + +/** A tool call that finished, as `toolAfter` hears of it on either version. */ +export interface ToolCall { + sessionID: string + /** As the host names it: `bash`/`shell`, a plugin's `trail_add`, an MCP server's `_`. */ + tool: string + callID: string + /** What the model sent. */ + args: unknown + /** What the tool answered, as text — whichever field the host put it in. */ + output: string + /** The agent that made the call (`general`, `explore`…). OpenCode 2 only. */ + agent?: string +} + +/** + * A skill shipped in a package: the folder holding its `SKILL.md`, whose frontmatter names it. OpenCode 1 + * reads the folder (`skills.paths`), OpenCode 2 is handed the file's text (`ctx.skill`): either way the + * skill's files stay in the installed package and nothing is written to the user's config + * (docs/opencode/shipping-agents.md). + */ +export interface SkillSpec { + dir: string +} + +/** + * A slash command shipped from the agent side: one line of prompt. OpenCode then does what it does for + * its own commands — from home it opens a conversation, while the agent answers it queues — on both + * versions (measured on 1.18.32 and 2.0.18). Whatever is typed after the name follows the line. + */ +export interface CommandSpec { + name: string + description: string + prompt: string +} + +export interface ServerParts { + tools?: Record + skills?: SkillSpec[] + commands?: CommandSpec[] + /** Added to the system prompt of each model request. The session is unknown on some v1 requests. */ + system?: (sessionID: string | undefined) => Promise + /** + * What this feature shows the user and where (`surfaces.ts`), for the one Cockpit-wide line said + * to the main agent — once per window, whichever features are loaded. + */ + surfaces?: Surface[] + /** + * Every tool call that completed, any tool's — built-ins, MCP, other plugins'. Read-only: it hears + * what a tool answered and cannot change it. A throw is logged and never reaches the call. + */ + toolAfter?: (call: ToolCall) => Promise | void + sessionDeleted?: (sessionID: string) => Promise + /** Every event, as the host sends it: v1's `{ type, properties }`, v2's `{ type, data }`. */ + event?: (event: unknown) => Promise | void + dispose?: () => Promise | void +} + +export type ServerStart = (host: ServerHost, options: unknown) => Promise + +/** Several features as one: tools unioned (a clash is a bug, so it throws), hooks run in order. */ +export function composeParts(parts: ServerParts[]): ServerParts { + const tools: Record = {} + for (const part of parts) { + for (const [name, def] of Object.entries(part.tools ?? {})) { + if (name in tools) throw new Error(`tool "${name}" is registered by more than one cockpit feature`) + tools[name] = def + } + } + const commands: CommandSpec[] = [] + for (const command of parts.flatMap((part) => part.commands ?? [])) { + if (commands.some((each) => each.name === command.name)) + throw new Error(`command "/${command.name}" is registered by more than one cockpit feature`) + commands.push(command) + } + const surfaces = parts.flatMap((part) => part.surfaces ?? []) + const skills = [ + ...new Map(parts.flatMap((part) => part.skills ?? []).map((skill) => [skill.dir, skill])).values(), + ] + /** Only what some feature has: no features is no hooks at all, not hooks that do nothing. */ + const any = (key: keyof ServerParts) => parts.some((part) => part[key] !== undefined) + return { + ...(Object.keys(tools).length > 0 ? { tools } : {}), + ...(skills.length > 0 ? { skills } : {}), + ...(commands.length > 0 ? { commands } : {}), + ...(surfaces.length > 0 ? { surfaces } : {}), + ...(any("system") + ? { + system: async (sessionID: string | undefined) => { + const lines: string[] = [] + for (const part of parts) lines.push(...((await part.system?.(sessionID)) ?? [])) + return lines + }, + } + : {}), + ...(any("toolAfter") + ? { + /** One feature's failure does not keep the call from the next; the first is reported. */ + toolAfter: async (call: ToolCall) => { + let failed: { error: unknown } | undefined + for (const part of parts) { + try { + await part.toolAfter?.(call) + } catch (error) { + failed ??= { error } + } + } + if (failed) throw failed.error + }, + } + : {}), + ...(any("sessionDeleted") + ? { + sessionDeleted: async (sessionID: string) => { + for (const part of parts) await part.sessionDeleted?.(sessionID) + }, + } + : {}), + ...(any("event") + ? { + event: async (event: unknown) => { + for (const part of parts) await part.event?.(event) + }, + } + : {}), + ...(any("dispose") + ? { + dispose: async () => { + for (const part of parts) await part.dispose?.() + }, + } + : {}), + } +} diff --git a/packages/client/src/opencode/server/skills.ts b/packages/client/src/opencode/server/skills.ts new file mode 100644 index 00000000..9f0dea0a --- /dev/null +++ b/packages/client/src/opencode/server/skills.ts @@ -0,0 +1,39 @@ +/** A skill's folder, read the way OpenCode 2 takes it. */ + +import { readFileSync } from "node:fs" +import { join } from "node:path" +import type { SkillSpec } from "./parts.ts" +import type { V2Skill } from "./v2.ts" + +/** + * A skill's folder as v2 takes it: name and description from the frontmatter, the text without it + * (v1 strips it too). Undefined when the file is missing or names nothing — logged by the caller, never + * thrown: no skill is worth the agent side. + */ +export function readSkill(spec: SkillSpec): V2Skill | undefined { + const path = join(spec.dir, "SKILL.md") + let text: string + try { + text = readFileSync(path, "utf8") + } catch { + return undefined + } + const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text) + const field = (name: string) => + match?.[1] + ?.split(/\r?\n/) + .find((line) => line.startsWith(`${name}:`)) + ?.slice(name.length + 1) + .trim() + .replace(/^(["'])(.*)\1$/, "$2") + const name = field("name") + if (!name) return undefined + const description = field("description") + return { + id: name, + name, + ...(description ? { description } : {}), + path, + content: match ? text.slice(match[0].length) : text, + } +} diff --git a/packages/client/src/surfaces.ts b/packages/client/src/opencode/server/surfaces.ts similarity index 97% rename from packages/client/src/surfaces.ts rename to packages/client/src/opencode/server/surfaces.ts index 53ada9aa..c99a997a 100644 --- a/packages/client/src/surfaces.ts +++ b/packages/client/src/opencode/server/surfaces.ts @@ -9,8 +9,8 @@ * the first entry still loaded says the line for all of them. */ -import { DEFAULT_KEYS } from "./catalog.ts" -import type { Bay } from "./settings.ts" +import { DEFAULT_KEYS } from "../../settings/catalog.ts" +import type { Bay } from "../../settings/index.ts" /** What one bay shows the user, and where. */ export interface Surface { diff --git a/packages/client/src/opencode/server/v1.ts b/packages/client/src/opencode/server/v1.ts new file mode 100644 index 00000000..af3a47cb --- /dev/null +++ b/packages/client/src/opencode/server/v1.ts @@ -0,0 +1,158 @@ +import type { Hooks, PluginInput } from "@opencode-ai/plugin" +import { type Log, silentLog } from "../../log.ts" +import type { ServerHost, ServerParts } from "./parts.ts" + +/** v1 also has a log of its own, where people already look: warnings and errors go there too. */ +function alsoToV1(log: Log, client: PluginInput["client"]): Log { + const forward = (level: "warn" | "error", message: string) => + // Logging through the server while plugins initialise could wait on ourselves; defer it. + setTimeout(() => { + void client.app.log({ body: { service: "opencode-cockpit", level, message } }).catch(() => {}) + }, 0) + return { + ...log, + warn: (msg, fields) => { + log.warn(msg, fields) + forward("warn", msg) + }, + error: (msg, fields) => { + log.error(msg, fields) + forward("error", msg) + }, + child: (scope) => alsoToV1(log.child(scope), client), + } +} + +export function serverFromV1(input: PluginInput, log: Log = silentLog): ServerHost { + const { client, directory } = input + return { + version: 1, + directory, + scope: input, + session: { + get: async (id) => { + const result = await client.session.get({ path: { id } }).catch(() => undefined) + return result?.data as { parentID?: string; title?: string; agent?: string } | undefined + }, + busy: async (id) => { + const result = await client.session.status().catch(() => undefined) + const all = result?.data as Record | undefined + if (!all || typeof all !== "object") return undefined + // v1 lists the sessions that are doing something; one it leaves out is idle. + const type = all[id]?.type + return type === "busy" || type === "retry" + }, + notify: async (id, text) => { + await client.session.promptAsync({ + path: { id }, + body: { parts: [{ type: "text", text, synthetic: true } as never] }, + }) + }, + children: async (id) => { + const result = await client.session.children({ path: { id } }).catch(() => undefined) + const list = result?.data as { id: string; title?: string; parentID?: string }[] | undefined + return Array.isArray(list) ? list : [] + }, + messages: async (id) => { + const result = await client.session.messages({ path: { id } }).catch(() => undefined) + const list = result?.data as { info: unknown; parts: unknown[] }[] | undefined + return Array.isArray(list) ? list : [] + }, + }, + readFile: async (path) => { + const result = await client.file.read({ query: { path } }).catch(() => undefined) + const content = (result?.data as { content?: string } | undefined)?.content + return typeof content === "string" ? content : undefined + }, + log: alsoToV1(log, client), + } +} + +/** + * What a v1 tool answered, as text. A built-in or plugin tool answers `{ title, output, metadata }`; + * an MCP tool answers `{ content: [{ type: "text", text }] }` with no `output` at all, so reading + * `output` alone missed every MCP call (docs/opencode/trail-server.md). + */ +export function v1ToolText(output: unknown): string { + const answer = output as { output?: unknown; content?: unknown } | undefined + if (typeof answer?.output === "string") return answer.output + return contentText(answer?.content) +} + +/** `[{ type: "text", text }, …]` as one string; anything that is not text is left out. */ +export function contentText(content: unknown): string { + if (!Array.isArray(content)) return "" + return content + .flatMap((part) => + typeof (part as { text?: unknown })?.text === "string" ? [(part as { text: string }).text] : [], + ) + .join("\n") +} + +/** The slice of OpenCode 1's config the `config` hook edits. */ +interface V1Config { + command?: Record> + skills?: { paths?: string[] } & Record +} + +/** + * Skills and commands into OpenCode 1's config, in memory. The hook gets the config already holding + * the user's own entries: a command of the same name is merged *beneath* theirs, so what they wrote + * wins. A folder already listed is not listed twice. + */ +export function addToV1Config(config: V1Config, parts: Pick): void { + for (const command of parts.commands ?? []) { + config.command ??= {} + config.command[command.name] = { + template: command.prompt, + description: command.description, + ...config.command[command.name], + } + } + if (parts.skills?.length) { + config.skills ??= {} + const paths = config.skills.paths ?? [] + config.skills.paths = [ + ...paths, + ...parts.skills.map((skill) => skill.dir).filter((dir) => !paths.includes(dir)), + ] + } +} + +export function partsToV1Hooks(parts: ServerParts): Hooks { + return { + ...(parts.tools ? { tool: parts.tools } : {}), + ...(parts.skills || parts.commands + ? { config: async (config: V1Config) => addToV1Config(config, parts) } + : {}), + ...(parts.toolAfter + ? { + "tool.execute.after": async (input, output) => { + await parts.toolAfter?.({ + sessionID: input.sessionID, + tool: input.tool, + callID: input.callID, + args: input.args, + output: v1ToolText(output), + }) + }, + } + : {}), + ...(parts.system + ? { + "experimental.chat.system.transform": async (input, output) => { + output.system.push(...((await parts.system?.(input.sessionID)) ?? [])) + }, + } + : {}), + ...(parts.sessionDeleted || parts.event + ? { + event: async ({ event }) => { + if (event.type === "session.deleted") await parts.sessionDeleted?.(event.properties.info.id) + await parts.event?.(event) + }, + } + : {}), + ...(parts.dispose ? { dispose: async () => parts.dispose?.() } : {}), + } as Hooks +} diff --git a/packages/client/src/opencode/server/v2.ts b/packages/client/src/opencode/server/v2.ts new file mode 100644 index 00000000..7c325758 --- /dev/null +++ b/packages/client/src/opencode/server/v2.ts @@ -0,0 +1,215 @@ +import { isAbsolute, relative, resolve } from "node:path" +import { type ToolContext, type ToolDefinition, tool } from "@opencode-ai/plugin" +import { type Log, silentLog } from "../../log.ts" +import type { CommandSpec, ServerHost, ToolCall } from "./parts.ts" +import { contentText } from "./v1.ts" + +/** The parts of OpenCode 2.0.15's server plugin context used here (`@opencode/plugin`'s `Context`). */ +export interface V2ServerContext { + options?: unknown + location?: { directory: string } + tool?: { + transform(edit: (editor: V2ToolEditor) => void): Promise + /** 2.0.18: after every tool call, Code Mode's inner calls included (docs/opencode/trail-server.md). */ + hook?(name: "execute.after", run: (event: V2ToolAfter) => unknown): Promise + } + /** 2.0.15's skill registry: an added skill is offered to the model like the user's own. */ + skill?: { transform(edit: (editor: V2SkillEditor) => void): Promise } + /** 2.0.15's slash commands: code, not a template, so a shipped one prompts the session itself. */ + command?: { transform(edit: (editor: V2CommandEditor) => void): Promise } + session: { + get(input: { + sessionID: string + }): Promise<{ parentID?: string; title?: string; agent?: string } | undefined> + synthetic(input: { sessionID: string; text: string; delivery?: "steer" | "queue" }): Promise + /** A message from the person: what a command sends. */ + prompt?(input: { sessionID: string; text: string; delivery?: "steer" | "queue" }): Promise + /** A session's messages as the model is given them (2.0.15). */ + context?(input: { sessionID: string }): Promise + hook(name: "context", run: (event: { sessionID: string; system: unknown[] }) => unknown): Promise + } + event: { subscribe(options: { signal: AbortSignal }): AsyncIterable } +} + +export interface V2Event { + type: string + data?: { sessionID?: string } +} + +/** What v2's `execute.after` hands a plugin, measured on 2.0.18. */ +export interface V2ToolAfter { + tool: string + sessionID: string + agent?: string + messageID?: string + /** The call's id. A Code Mode call fires twice with the same one: the inner tool, then `execute`. */ + id: string + input?: unknown + status?: string + /** `content[].text` is the one field every tool has; `output` is a string for MCP, an object for `shell`. */ + result?: { output?: unknown; content?: unknown } + error?: unknown +} + +/** + * Code Mode's outer call. On v2 a plugin or MCP tool is called from inside `execute`, and the hook + * fires for both with the same id — the outer one carrying everything the code printed, `search(…)` + * results (the tool catalog, URLs and all) included. Only the inner call is delivered. + */ +const CODE_MODE = "execute" + +/** A v2 `execute.after` as a `ToolCall`, or undefined for Code Mode's outer call and a call that failed. */ +export function v2ToolCall(event: V2ToolAfter): ToolCall | undefined { + if (event.tool === CODE_MODE) return undefined + if (event.error !== undefined || (event.status !== undefined && event.status !== "completed")) + return undefined + const text = contentText(event.result?.content) + const output = text || (typeof event.result?.output === "string" ? event.result.output : "") + return { + sessionID: event.sessionID, + tool: event.tool, + callID: event.id, + args: event.input, + output, + ...(event.agent ? { agent: event.agent } : {}), + } +} + +/** A tool as v2's editor takes one. */ +export interface V2Tool { + name: string + description: string + input: unknown + execute: (input: unknown, context: V2ToolContext) => Promise<{ content?: string; metadata?: unknown }> +} + +interface V2ToolEditor { + add(tool: V2Tool): void +} + +/** A skill as v2's registry holds one. */ +export interface V2Skill { + id: string + name: string + description?: string + /** The `SKILL.md`: the model is told its folder, so the skill's relative paths resolve. */ + path: string + content: string +} + +interface V2SkillEditor { + get(id: string): unknown + add(skill: V2Skill): void +} + +/** What v2 hands a command when it runs, measured on 2.0.18: `delivery` is `"steer"` even when idle. */ +export interface V2CommandInvocation { + sessionID: string + prompt?: { text?: string } + delivery?: "steer" | "queue" +} + +interface V2CommandEditor { + add(command: { + name: string + description?: string + execute(input: V2CommandInvocation): Promise + }): void +} + +/** The line a v2 command sends: the command's own, then whatever was typed after its name. */ +export function commandText(command: CommandSpec, invocation: V2CommandInvocation): string { + const typed = invocation.prompt?.text?.trim() + return typed ? `${command.prompt}\n\n${typed}` : command.prompt +} + +export interface V2ToolContext { + sessionID: string + agent: string + messageID: string + signal: AbortSignal + progress: (update: Record) => Promise +} + +/** + * v1 scoped claims to the plugin input, which every plugin of an instance shares. v2 hands each plugin + * its own context, so the shared object is kept here instead, by directory — on `globalThis`, so two + * copies of this package (the bundle and a feature package) still find the same one. + */ +const SCOPES = Symbol.for("opencode-cockpit.server-scopes") +function scopeFor(directory: string): object { + const global = globalThis as { [SCOPES]?: Map } + global[SCOPES] ??= new Map() + let scope = global[SCOPES].get(directory) + if (!scope) { + scope = { directory } + global[SCOPES].set(directory, scope) + } + return scope +} + +export function serverFromV2( + ctx: V2ServerContext & { location: { directory: string } }, + log: Log = silentLog, +): ServerHost { + const directory = ctx.location.directory + return { + version: 2, + directory, + scope: scopeFor(directory), + session: { + get: (id) => ctx.session.get({ sessionID: id }).catch(() => undefined), + context: async (id) => { + const list = await ctx.session.context?.({ sessionID: id }).catch(() => undefined) + return Array.isArray(list) ? list : [] + }, + notify: async (id, text, options) => { + await ctx.session.synthetic({ + sessionID: id, + text, + ...(options?.steer ? { delivery: "steer" as const } : {}), + }) + }, + }, + /** v2 gives plugins no file API; the file system, kept inside the project, reads the same text. */ + readFile: async (path) => { + const full = resolve(directory, path) + const inside = relative(directory, full) + if (inside.startsWith("..") || isAbsolute(inside)) return undefined + const file = Bun.file(full) + return (await file.exists()) ? await file.text().catch(() => undefined) : undefined + }, + log, + } +} + +/** A v1 tool as v2 registers one: JSON Schema in, arguments parsed here, text out. */ +export function toolToV2(name: string, def: ToolDefinition, directory: string): V2Tool { + const args = tool.schema.object(def.args) + return { + name, + description: def.description, + /** As the model writes them, not as they come out: an argument with a default is optional. */ + input: tool.schema.toJSONSchema(args, { io: "input" }), + execute: async (input: unknown, context: V2ToolContext) => { + const v1: ToolContext = { + sessionID: context.sessionID, + messageID: context.messageID, + agent: context.agent, + directory, + worktree: directory, + abort: context.signal, + metadata: (update) => void context.progress(update).catch(() => {}), + /** v2 asks for a plugin tool's own permission before it runs; there is no second prompt to raise. */ + ask: async () => {}, + } + const result = await def.execute(args.parse(input ?? {}), v1) + return typeof result === "string" + ? { content: result } + : { content: result.output, ...(result.metadata ? { metadata: result.metadata } : {}) } + }, + } +} + +// --------------------------------------------------------------------------------------------------- +// both diff --git a/packages/client/src/service.ts b/packages/client/src/opencode/service.ts similarity index 97% rename from packages/client/src/service.ts rename to packages/client/src/opencode/service.ts index 7270a830..bf8d080d 100644 --- a/packages/client/src/service.ts +++ b/packages/client/src/opencode/service.ts @@ -27,9 +27,9 @@ import { homedir } from "node:os" import { dirname, join } from "node:path" import { fileURLToPath } from "node:url" import { resolvePaths } from "@opencode-cockpit/protocol" -import { claimFeature } from "./feature.ts" -import type { Host } from "./host.ts" -import type { Log } from "./log.ts" +import { claimFeature } from "../feature.ts" +import type { Log } from "../log.ts" +import type { Host } from "./host/index.ts" export const RESTART_COMMAND = "opencode service restart" @@ -48,7 +48,8 @@ export interface AgentRecord extends Install { startedAt: number } -const PACKAGE_DIR = dirname(dirname(fileURLToPath(import.meta.url))) +/** src/opencode/service.ts or dist/opencode/service.js: the package is two folders up. */ +const PACKAGE_DIR = dirname(dirname(dirname(fileURLToPath(import.meta.url)))) /** This package's install, read from disk. Undefined when it cannot be read. */ export function readInstall(dir: string = PACKAGE_DIR): Install | undefined { diff --git a/packages/client/src/sidebar.ts b/packages/client/src/opencode/sidebar.ts similarity index 55% rename from packages/client/src/sidebar.ts rename to packages/client/src/opencode/sidebar.ts index 2138511e..ee19ed16 100644 --- a/packages/client/src/sidebar.ts +++ b/packages/client/src/opencode/sidebar.ts @@ -1,4 +1,4 @@ -import type { Host } from "./host.ts" +import type { Host } from "./host/index.ts" /** * Where the bays' blocks sit in OpenCode's sidebar: one list in Cockpit's settings orders them all, @@ -39,3 +39,31 @@ export function orderedSidebar(host: Host): { host: Host; flush: () => void } { }, } } + +/** A laid-out box: its own width, and the container the host put it in. */ +interface Sized { + width?: number + parent?: unknown +} + +/** + * How wide a sidebar block really is, measured off the container the host gave it rather than the + * block: rows wider than the sidebar stretch the block with them, so its own width only ever agreed + * with the guess. `measured` is 0 before the first layout. + */ +export function measureBlock(block: Sized | undefined): { parent: number; own: number; measured: number } { + const parent = (block?.parent as { width?: number } | null | undefined)?.width ?? 0 + const own = block?.width ?? 0 + const measured = parent >= 12 ? Math.min(parent, own >= 12 ? own : parent) : own + return { parent, own, measured } +} + +/** + * The width to draw a block's rows at: measured once laid out, else a guess from the window — a + * quarter of it, between 20 and `widest`. Guessed too wide, rows run past the edge and are clipped + * ("3 done" drew as "3 d"); a bay that would rather leave its facts short of the edge guesses narrower. + */ +export function blockWidth(block: Sized | undefined, window: number, widest = 40): number { + const { measured } = measureBlock(block) + return measured >= 12 ? measured : Math.max(20, Math.min(widest, Math.floor(window / 4) - 2)) +} diff --git a/packages/client/src/server.ts b/packages/client/src/server.ts deleted file mode 100644 index 5d5536ff..00000000 --- a/packages/client/src/server.ts +++ /dev/null @@ -1,841 +0,0 @@ -/** - * What a Cockpit server half needs from OpenCode, whichever OpenCode it is — `host.ts` for the agent - * side. - * - * A feature is written once as a `ServerStart`: given a `ServerHost` it answers with its tools and the - * few hooks it uses (`ServerParts`). `dualServer` turns that into an entry both versions load: v1 calls - * `server(input)` and gets hooks back, v2 calls `setup(ctx)` and the parts are registered on its - * domains. Tools stay written with v1's `tool()`, whose arguments are zod — v2 is handed their JSON - * Schema, and the arguments are parsed here so defaults apply the same on both. - * - * Only `tool` (for its zod) is imported from OpenCode at runtime; the v2 context is described by the - * structural types below. - */ - -import { readFileSync } from "node:fs" -import { isAbsolute, join, relative, resolve } from "node:path" -import { - type Hooks, - type PluginInput, - type ToolContext, - type ToolDefinition, - tool, -} from "@opencode-ai/plugin" -import { cockpitVersion, createLog, type Log, silentLog } from "./log.ts" -import { recordAgent } from "./service.ts" -import { setupServer } from "./setup/server.ts" -import { registerSurfaces, type Surface } from "./surfaces.ts" - -export { keyText, openText, type Surface, surfacesLine } from "./surfaces.ts" - -export interface ServerHost { - readonly version: 1 | 2 - /** The project this OpenCode was opened in. */ - readonly directory: string - /** One object per OpenCode instance, shared by every Cockpit plugin in it — for `claimFeature`. */ - readonly scope: object - readonly session: { - /** `agent` is the agent running in the session (`general`, `explore`…), when the host says. */ - get(id: string): Promise<{ parentID?: string; title?: string; agent?: string } | undefined> - /** - * Whether a session is working on a turn right now. OpenCode 1 only: OpenCode 2's agent side has - * no status call, so a feature that needs it there follows the session events itself. - */ - busy?(id: string): Promise - /** A session's child sessions — its subagents. OpenCode 1 only: OpenCode 2 gives plugins no list. */ - children?(id: string): Promise< - { - id: string - title?: string - parentID?: string - agent?: string - time?: { created?: number; updated?: number } - }[] - > - /** A session's messages with their parts, as OpenCode 1 stores them. OpenCode 1 only. */ - messages?(id: string): Promise<{ info: unknown; parts: unknown[] }[]> - /** - * A session's messages as OpenCode 2 stores them (`{ type: "user" | "assistant", … }`), read - * through `session.context`: what the model is given, so a compacted session starts at its - * summary. OpenCode 2 only; OpenCode 1 has `messages`. - */ - context?(id: string): Promise - /** - * A message from the plugin rather than the person, which starts a turn: v1's synthetic prompt. - * - * `steer` is for a session that is busy: OpenCode 2 then hands the message to the running turn - * instead of queueing it after that turn ends — which, for a subagent, is after it has answered - * and nobody is listening. OpenCode 1 needs nothing: a prompt sent to a busy session is picked up - * mid-run (measured, docs/opencode/agents.md). - */ - notify(id: string, text: string, options?: { steer?: boolean }): Promise - } - /** A project file's text, or undefined when there is none — or the path leaves the project. */ - readFile(path: string): Promise - /** The shared log (`cockpit.log`), scoped `server`; a feature takes `log.child("shell")`. */ - readonly log: Log -} - -/** A tool call that finished, as `toolAfter` hears of it on either version. */ -export interface ToolCall { - sessionID: string - /** As the host names it: `bash`/`shell`, a plugin's `trail_add`, an MCP server's `_`. */ - tool: string - callID: string - /** What the model sent. */ - args: unknown - /** What the tool answered, as text — whichever field the host put it in. */ - output: string - /** The agent that made the call (`general`, `explore`…). OpenCode 2 only. */ - agent?: string -} - -/** - * A skill shipped in a package: the folder holding its `SKILL.md`, whose frontmatter names it. OpenCode 1 - * reads the folder (`skills.paths`), OpenCode 2 is handed the file's text (`ctx.skill`): either way the - * skill's files stay in the installed package and nothing is written to the user's config - * (docs/opencode/shipping-agents.md). - */ -export interface SkillSpec { - dir: string -} - -/** - * A slash command shipped from the agent side: one line of prompt. OpenCode then does what it does for - * its own commands — from home it opens a conversation, while the agent answers it queues — on both - * versions (measured on 1.18.32 and 2.0.18). Whatever is typed after the name follows the line. - */ -export interface CommandSpec { - name: string - description: string - prompt: string -} - -export interface ServerParts { - tools?: Record - skills?: SkillSpec[] - commands?: CommandSpec[] - /** Added to the system prompt of each model request. The session is unknown on some v1 requests. */ - system?: (sessionID: string | undefined) => Promise - /** - * What this feature shows the user and where (`surfaces.ts`), for the one Cockpit-wide line said - * to the main agent — once per window, whichever features are loaded. - */ - surfaces?: Surface[] - /** - * Every tool call that completed, any tool's — built-ins, MCP, other plugins'. Read-only: it hears - * what a tool answered and cannot change it. A throw is logged and never reaches the call. - */ - toolAfter?: (call: ToolCall) => Promise | void - sessionDeleted?: (sessionID: string) => Promise - /** Every event, as the host sends it: v1's `{ type, properties }`, v2's `{ type, data }`. */ - event?: (event: unknown) => Promise | void - dispose?: () => Promise | void -} - -export type ServerStart = (host: ServerHost, options: unknown) => Promise - -/** Several features as one: tools unioned (a clash is a bug, so it throws), hooks run in order. */ -export function composeParts(parts: ServerParts[]): ServerParts { - const tools: Record = {} - for (const part of parts) { - for (const [name, def] of Object.entries(part.tools ?? {})) { - if (name in tools) throw new Error(`tool "${name}" is registered by more than one cockpit feature`) - tools[name] = def - } - } - const commands: CommandSpec[] = [] - for (const command of parts.flatMap((part) => part.commands ?? [])) { - if (commands.some((each) => each.name === command.name)) - throw new Error(`command "/${command.name}" is registered by more than one cockpit feature`) - commands.push(command) - } - const surfaces = parts.flatMap((part) => part.surfaces ?? []) - const skills = [ - ...new Map(parts.flatMap((part) => part.skills ?? []).map((skill) => [skill.dir, skill])).values(), - ] - /** Only what some feature has: no features is no hooks at all, not hooks that do nothing. */ - const any = (key: keyof ServerParts) => parts.some((part) => part[key] !== undefined) - return { - ...(Object.keys(tools).length > 0 ? { tools } : {}), - ...(skills.length > 0 ? { skills } : {}), - ...(commands.length > 0 ? { commands } : {}), - ...(surfaces.length > 0 ? { surfaces } : {}), - ...(any("system") - ? { - system: async (sessionID: string | undefined) => { - const lines: string[] = [] - for (const part of parts) lines.push(...((await part.system?.(sessionID)) ?? [])) - return lines - }, - } - : {}), - ...(any("toolAfter") - ? { - /** One feature's failure does not keep the call from the next; the first is reported. */ - toolAfter: async (call: ToolCall) => { - let failed: { error: unknown } | undefined - for (const part of parts) { - try { - await part.toolAfter?.(call) - } catch (error) { - failed ??= { error } - } - } - if (failed) throw failed.error - }, - } - : {}), - ...(any("sessionDeleted") - ? { - sessionDeleted: async (sessionID: string) => { - for (const part of parts) await part.sessionDeleted?.(sessionID) - }, - } - : {}), - ...(any("event") - ? { - event: async (event: unknown) => { - for (const part of parts) await part.event?.(event) - }, - } - : {}), - ...(any("dispose") - ? { - dispose: async () => { - for (const part of parts) await part.dispose?.() - }, - } - : {}), - } -} - -// --------------------------------------------------------------------------------------------------- -// v1 - -/** v1 also has a log of its own, where people already look: warnings and errors go there too. */ -function alsoToV1(log: Log, client: PluginInput["client"]): Log { - const forward = (level: "warn" | "error", message: string) => - // Logging through the server while plugins initialise could wait on ourselves; defer it. - setTimeout(() => { - void client.app.log({ body: { service: "opencode-cockpit", level, message } }).catch(() => {}) - }, 0) - return { - ...log, - warn: (msg, fields) => { - log.warn(msg, fields) - forward("warn", msg) - }, - error: (msg, fields) => { - log.error(msg, fields) - forward("error", msg) - }, - child: (scope) => alsoToV1(log.child(scope), client), - } -} - -export function serverFromV1(input: PluginInput, log: Log = silentLog): ServerHost { - const { client, directory } = input - return { - version: 1, - directory, - scope: input, - session: { - get: async (id) => { - const result = await client.session.get({ path: { id } }).catch(() => undefined) - return result?.data as { parentID?: string; title?: string; agent?: string } | undefined - }, - busy: async (id) => { - const result = await client.session.status().catch(() => undefined) - const all = result?.data as Record | undefined - if (!all || typeof all !== "object") return undefined - // v1 lists the sessions that are doing something; one it leaves out is idle. - const type = all[id]?.type - return type === "busy" || type === "retry" - }, - notify: async (id, text) => { - await client.session.promptAsync({ - path: { id }, - body: { parts: [{ type: "text", text, synthetic: true } as never] }, - }) - }, - children: async (id) => { - const result = await client.session.children({ path: { id } }).catch(() => undefined) - const list = result?.data as { id: string; title?: string; parentID?: string }[] | undefined - return Array.isArray(list) ? list : [] - }, - messages: async (id) => { - const result = await client.session.messages({ path: { id } }).catch(() => undefined) - const list = result?.data as { info: unknown; parts: unknown[] }[] | undefined - return Array.isArray(list) ? list : [] - }, - }, - readFile: async (path) => { - const result = await client.file.read({ query: { path } }).catch(() => undefined) - const content = (result?.data as { content?: string } | undefined)?.content - return typeof content === "string" ? content : undefined - }, - log: alsoToV1(log, client), - } -} - -/** - * What a v1 tool answered, as text. A built-in or plugin tool answers `{ title, output, metadata }`; - * an MCP tool answers `{ content: [{ type: "text", text }] }` with no `output` at all, so reading - * `output` alone missed every MCP call (docs/opencode/trail-server.md). - */ -export function v1ToolText(output: unknown): string { - const answer = output as { output?: unknown; content?: unknown } | undefined - if (typeof answer?.output === "string") return answer.output - return contentText(answer?.content) -} - -/** `[{ type: "text", text }, …]` as one string; anything that is not text is left out. */ -function contentText(content: unknown): string { - if (!Array.isArray(content)) return "" - return content - .flatMap((part) => - typeof (part as { text?: unknown })?.text === "string" ? [(part as { text: string }).text] : [], - ) - .join("\n") -} - -/** The slice of OpenCode 1's config the `config` hook edits. */ -interface V1Config { - command?: Record> - skills?: { paths?: string[] } & Record -} - -/** - * Skills and commands into OpenCode 1's config, in memory. The hook gets the config already holding - * the user's own entries: a command of the same name is merged *beneath* theirs, so what they wrote - * wins. A folder already listed is not listed twice. - */ -export function addToV1Config(config: V1Config, parts: Pick): void { - for (const command of parts.commands ?? []) { - config.command ??= {} - config.command[command.name] = { - template: command.prompt, - description: command.description, - ...config.command[command.name], - } - } - if (parts.skills?.length) { - config.skills ??= {} - const paths = config.skills.paths ?? [] - config.skills.paths = [ - ...paths, - ...parts.skills.map((skill) => skill.dir).filter((dir) => !paths.includes(dir)), - ] - } -} - -export function partsToV1Hooks(parts: ServerParts): Hooks { - return { - ...(parts.tools ? { tool: parts.tools } : {}), - ...(parts.skills || parts.commands - ? { config: async (config: V1Config) => addToV1Config(config, parts) } - : {}), - ...(parts.toolAfter - ? { - "tool.execute.after": async (input, output) => { - await parts.toolAfter?.({ - sessionID: input.sessionID, - tool: input.tool, - callID: input.callID, - args: input.args, - output: v1ToolText(output), - }) - }, - } - : {}), - ...(parts.system - ? { - "experimental.chat.system.transform": async (input, output) => { - output.system.push(...((await parts.system?.(input.sessionID)) ?? [])) - }, - } - : {}), - ...(parts.sessionDeleted || parts.event - ? { - event: async ({ event }) => { - if (event.type === "session.deleted") await parts.sessionDeleted?.(event.properties.info.id) - await parts.event?.(event) - }, - } - : {}), - ...(parts.dispose ? { dispose: async () => parts.dispose?.() } : {}), - } as Hooks -} - -// --------------------------------------------------------------------------------------------------- -// v2 - -/** The parts of OpenCode 2.0.15's server plugin context used here (`@opencode/plugin`'s `Context`). */ -export interface V2ServerContext { - options?: unknown - location?: { directory: string } - tool?: { - transform(edit: (editor: V2ToolEditor) => void): Promise - /** 2.0.18: after every tool call, Code Mode's inner calls included (docs/opencode/trail-server.md). */ - hook?(name: "execute.after", run: (event: V2ToolAfter) => unknown): Promise - } - /** 2.0.15's skill registry: an added skill is offered to the model like the user's own. */ - skill?: { transform(edit: (editor: V2SkillEditor) => void): Promise } - /** 2.0.15's slash commands: code, not a template, so a shipped one prompts the session itself. */ - command?: { transform(edit: (editor: V2CommandEditor) => void): Promise } - session: { - get(input: { - sessionID: string - }): Promise<{ parentID?: string; title?: string; agent?: string } | undefined> - synthetic(input: { sessionID: string; text: string; delivery?: "steer" | "queue" }): Promise - /** A message from the person: what a command sends. */ - prompt?(input: { sessionID: string; text: string; delivery?: "steer" | "queue" }): Promise - /** A session's messages as the model is given them (2.0.15). */ - context?(input: { sessionID: string }): Promise - hook(name: "context", run: (event: { sessionID: string; system: unknown[] }) => unknown): Promise - } - event: { subscribe(options: { signal: AbortSignal }): AsyncIterable } -} - -export interface V2Event { - type: string - data?: { sessionID?: string } -} - -/** What v2's `execute.after` hands a plugin, measured on 2.0.18. */ -export interface V2ToolAfter { - tool: string - sessionID: string - agent?: string - messageID?: string - /** The call's id. A Code Mode call fires twice with the same one: the inner tool, then `execute`. */ - id: string - input?: unknown - status?: string - /** `content[].text` is the one field every tool has; `output` is a string for MCP, an object for `shell`. */ - result?: { output?: unknown; content?: unknown } - error?: unknown -} - -/** - * Code Mode's outer call. On v2 a plugin or MCP tool is called from inside `execute`, and the hook - * fires for both with the same id — the outer one carrying everything the code printed, `search(…)` - * results (the tool catalog, URLs and all) included. Only the inner call is delivered. - */ -const CODE_MODE = "execute" - -/** A v2 `execute.after` as a `ToolCall`, or undefined for Code Mode's outer call and a call that failed. */ -export function v2ToolCall(event: V2ToolAfter): ToolCall | undefined { - if (event.tool === CODE_MODE) return undefined - if (event.error !== undefined || (event.status !== undefined && event.status !== "completed")) - return undefined - const text = contentText(event.result?.content) - const output = text || (typeof event.result?.output === "string" ? event.result.output : "") - return { - sessionID: event.sessionID, - tool: event.tool, - callID: event.id, - args: event.input, - output, - ...(event.agent ? { agent: event.agent } : {}), - } -} - -/** A tool as v2's editor takes one. */ -export interface V2Tool { - name: string - description: string - input: unknown - execute: (input: unknown, context: V2ToolContext) => Promise<{ content?: string; metadata?: unknown }> -} - -interface V2ToolEditor { - add(tool: V2Tool): void -} - -/** A skill as v2's registry holds one. */ -export interface V2Skill { - id: string - name: string - description?: string - /** The `SKILL.md`: the model is told its folder, so the skill's relative paths resolve. */ - path: string - content: string -} - -interface V2SkillEditor { - get(id: string): unknown - add(skill: V2Skill): void -} - -/** What v2 hands a command when it runs, measured on 2.0.18: `delivery` is `"steer"` even when idle. */ -export interface V2CommandInvocation { - sessionID: string - prompt?: { text?: string } - delivery?: "steer" | "queue" -} - -interface V2CommandEditor { - add(command: { - name: string - description?: string - execute(input: V2CommandInvocation): Promise - }): void -} - -/** - * A skill's folder as v2 takes it: name and description from the frontmatter, the text without it - * (v1 strips it too). Undefined when the file is missing or names nothing — logged by the caller, never - * thrown: no skill is worth the agent side. - */ -export function readSkill(spec: SkillSpec): V2Skill | undefined { - const path = join(spec.dir, "SKILL.md") - let text: string - try { - text = readFileSync(path, "utf8") - } catch { - return undefined - } - const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text) - const field = (name: string) => - match?.[1] - ?.split(/\r?\n/) - .find((line) => line.startsWith(`${name}:`)) - ?.slice(name.length + 1) - .trim() - .replace(/^(["'])(.*)\1$/, "$2") - const name = field("name") - if (!name) return undefined - const description = field("description") - return { - id: name, - name, - ...(description ? { description } : {}), - path, - content: match ? text.slice(match[0].length) : text, - } -} - -/** The line a v2 command sends: the command's own, then whatever was typed after its name. */ -export function commandText(command: CommandSpec, invocation: V2CommandInvocation): string { - const typed = invocation.prompt?.text?.trim() - return typed ? `${command.prompt}\n\n${typed}` : command.prompt -} - -export interface V2ToolContext { - sessionID: string - agent: string - messageID: string - signal: AbortSignal - progress: (update: Record) => Promise -} - -/** - * v1 scoped claims to the plugin input, which every plugin of an instance shares. v2 hands each plugin - * its own context, so the shared object is kept here instead, by directory — on `globalThis`, so two - * copies of this package (the bundle and a feature package) still find the same one. - */ -const SCOPES = Symbol.for("opencode-cockpit.server-scopes") -function scopeFor(directory: string): object { - const global = globalThis as { [SCOPES]?: Map } - global[SCOPES] ??= new Map() - let scope = global[SCOPES].get(directory) - if (!scope) { - scope = { directory } - global[SCOPES].set(directory, scope) - } - return scope -} - -export function serverFromV2( - ctx: V2ServerContext & { location: { directory: string } }, - log: Log = silentLog, -): ServerHost { - const directory = ctx.location.directory - return { - version: 2, - directory, - scope: scopeFor(directory), - session: { - get: (id) => ctx.session.get({ sessionID: id }).catch(() => undefined), - context: async (id) => { - const list = await ctx.session.context?.({ sessionID: id }).catch(() => undefined) - return Array.isArray(list) ? list : [] - }, - notify: async (id, text, options) => { - await ctx.session.synthetic({ - sessionID: id, - text, - ...(options?.steer ? { delivery: "steer" as const } : {}), - }) - }, - }, - /** v2 gives plugins no file API; the file system, kept inside the project, reads the same text. */ - readFile: async (path) => { - const full = resolve(directory, path) - const inside = relative(directory, full) - if (inside.startsWith("..") || isAbsolute(inside)) return undefined - const file = Bun.file(full) - return (await file.exists()) ? await file.text().catch(() => undefined) : undefined - }, - log, - } -} - -/** A v1 tool as v2 registers one: JSON Schema in, arguments parsed here, text out. */ -export function toolToV2(name: string, def: ToolDefinition, directory: string): V2Tool { - const args = tool.schema.object(def.args) - return { - name, - description: def.description, - /** As the model writes them, not as they come out: an argument with a default is optional. */ - input: tool.schema.toJSONSchema(args, { io: "input" }), - execute: async (input: unknown, context: V2ToolContext) => { - const v1: ToolContext = { - sessionID: context.sessionID, - messageID: context.messageID, - agent: context.agent, - directory, - worktree: directory, - abort: context.signal, - metadata: (update) => void context.progress(update).catch(() => {}), - /** v2 asks for a plugin tool's own permission before it runs; there is no second prompt to raise. */ - ask: async () => {}, - } - const result = await def.execute(args.parse(input ?? {}), v1) - return typeof result === "string" - ? { content: result } - : { content: result.output, ...(result.metadata ? { metadata: result.metadata } : {}) } - }, - } -} - -// --------------------------------------------------------------------------------------------------- -// both - -/** - * Every tool call through the log: a failure with its tool and stack — tools fail in front of the - * agent, not the person, so this is the only record — and, with `COCKPIT_DEBUG`, every call and how - * long it took. - */ -function loggedTools(tools: Record | undefined, log: Log) { - if (!tools) return undefined - const out: Record = {} - for (const [name, def] of Object.entries(tools)) { - out[name] = { - ...def, - execute: async (args, context) => { - const started = performance.now() - try { - const result = await def.execute(args, context) - log.debug("tool", { tool: name, ms: Math.round(performance.now() - started) }) - return result - } catch (error) { - log.warn("tool failed", { tool: name, ms: Math.round(performance.now() - started), error }) - throw error - } - }, - } - } - return out -} - -/** - * v2's event stream, kept open. A stream that ends or throws — a reloaded location, a restarted - * service — used to stay closed, and every session deleted after it left its shells behind until - * OpenCode restarted. It is opened again, waiting longer each time it fails straight away, and a - * stream that ran a while starts the wait over. - */ -export async function follow( - ctx: Pick, - signal: AbortSignal, - log: Log, - handle: (event: V2Event) => Promise, - wait: (ms: number) => Promise = (ms) => new Promise((done) => setTimeout(done, ms)), -): Promise { - let delay = 1_000 - while (!signal.aborted) { - const opened = Date.now() - try { - for await (const event of ctx.event.subscribe({ signal })) await handle(event) - if (!signal.aborted) log.warn("event stream ended; opening it again", { waitMs: delay }) - } catch (error) { - if (signal.aborted) return - log.warn("event stream failed; opening it again", { waitMs: delay, error }) - } - if (signal.aborted) return - if (Date.now() - opened > 60_000) delay = 1_000 - await wait(delay) - delay = Math.min(delay * 2, 30_000) - } -} - -/** - * The Cockpit-wide line (`surfaces.ts`) ahead of the entry's own guidance, when this entry is the one - * in the window that says it. Said to the main agent only — a subagent answers its caller, not the - * user — and to a request whose session is unknown, as the guidance is. - */ -function withSurfaces(host: ServerHost, parts: ServerParts): ServerParts { - if (!parts.surfaces?.length) return parts - const entry = registerSurfaces(host.scope, parts.surfaces) - /** Parentage never changes: asked once per session. */ - const parented = new Map() - const subagent = async (sessionID: string): Promise => { - const known = parented.get(sessionID) - if (known !== undefined) return known - const session = await host.session.get(sessionID).catch(() => undefined) - if (!session) return false - const answer = Boolean(session.parentID) - parented.set(sessionID, answer) - return answer - } - const { system, dispose } = parts - return { - ...parts, - system: async (sessionID) => { - const own = (await system?.(sessionID)) ?? [] - const line = entry.line() - if (!line || (sessionID && (await subagent(sessionID)))) return own - return [line, ...own] - }, - dispose: async () => { - entry.release() - await dispose?.() - }, - } -} - -/** Starts a feature: what loaded and where first, so a feature that never answers still said it was loaded. */ -async function begin( - id: string, - host: ServerHost, - start: ServerStart, - options: unknown, -): Promise { - host.log.info("start", { entry: id, opencode: host.version, cockpit: cockpitVersion() }) - /** Which install this agent side loaded, for a window to compare with its own (service.ts). */ - if (host.version === 2) recordAgent(host.log) - try { - /** `cockpit_settings`, the `cockpit-setup` skill and `/cockpit-setup`: the first entry here adds them. */ - const parts = withSurfaces(host, composeParts([await start(host, options), setupServer(host, id)])) - const { toolAfter } = parts - return { - ...parts, - tools: loggedTools(parts.tools, host.log), - /** Listening to a call must never break it: a failure is logged and goes no further. */ - ...(toolAfter - ? { - toolAfter: async (call: ToolCall) => { - try { - await toolAfter(call) - } catch (error) { - host.log.warn("toolAfter failed", { tool: call.tool, error }) - } - }, - } - : {}), - } - } catch (error) { - host.log.error("start failed", { entry: id, error }) - throw error - } -} - -/** One feature as an entry both versions load. */ -export function dualServer(id: string, start: ServerStart) { - return { - id, - server: async (input: PluginInput, options?: unknown): Promise => - partsToV1Hooks(await begin(id, serverFromV1(input, createLog("server")), start, options)), - setup: async (ctx: V2ServerContext) => { - /** - * v1 1.18.29+ calls `setup` too (older releases never do), with a preview context that has - * neither tools nor a location (docs/opencode/v2.md). Registering there would be registering twice. - */ - if (!ctx.tool || !ctx.location) return - const host = serverFromV2( - ctx as V2ServerContext & { location: { directory: string } }, - createLog("server"), - ) - const parts = await begin(id, host, start, ctx.options) - const tools = Object.entries(parts.tools ?? {}) - if (tools.length > 0) { - await ctx.tool.transform((editor) => { - for (const [name, def] of tools) editor.add(toolToV2(name, def, host.directory)) - }) - } - const skills = (parts.skills ?? []).flatMap((spec) => { - const skill = readSkill(spec) - if (!skill) host.log.warn("skill not found", { dir: spec.dir }) - return skill ? [skill] : [] - }) - if (skills.length > 0) { - if (ctx.skill) { - await ctx.skill.transform((editor) => { - for (const skill of skills) if (!editor.get(skill.id)) editor.add(skill) - }) - } else - host.log.warn("this OpenCode takes no skills from plugins", { skills: skills.map((s) => s.id) }) - } - const commands = parts.commands ?? [] - if (commands.length > 0) { - if (ctx.command && ctx.session.prompt) { - const prompt = ctx.session.prompt.bind(ctx.session) - await ctx.command.transform((editor) => { - for (const command of commands) - editor.add({ - name: command.name, - description: command.description, - /** - * Queued, always: v2 hands every command `delivery: "steer"`, which cuts a reply in - * progress off to start on this. Queued, an idle session starts at once and a busy - * one shows `1 queued` and waits (measured on 2.0.18). - */ - execute: async (invocation) => { - await prompt({ - sessionID: invocation.sessionID, - text: commandText(command, invocation), - delivery: "queue", - }) - }, - }) - }) - } else host.log.warn("this OpenCode takes no commands from plugins", { commands: commands.length }) - } - if (parts.toolAfter) { - const after = parts.toolAfter - if (ctx.tool.hook) { - await ctx.tool.hook("execute.after", async (event) => { - const call = v2ToolCall(event) - if (call) await after(call) - }) - } else host.log.warn("this OpenCode has no execute.after hook; tool output is not followed") - } - if (parts.system) { - const system = parts.system - await ctx.session.hook("context", async (event) => { - for (const text of await system(event.sessionID)) event.system.push({ type: "text", text }) - }) - } - const stop = new AbortController() - if (parts.sessionDeleted || parts.event) { - const { sessionDeleted: deleted, event: each } = parts - void follow(ctx, stop.signal, host.log, async (event) => { - const sessionID = event.data?.sessionID - if (deleted && event.type === "session.deleted" && sessionID) { - await deleted(sessionID).catch((error) => - host.log.warn("session cleanup failed", { sessionID, error }), - ) - } - if (each) - await Promise.resolve(each(event)).catch((error) => host.log.warn("event failed", { error })) - }) - } - return async () => { - stop.abort() - await parts.dispose?.() - } - }, - } -} diff --git a/packages/client/src/catalog.ts b/packages/client/src/settings/catalog.ts similarity index 58% rename from packages/client/src/catalog.ts rename to packages/client/src/settings/catalog.ts index 5baec239..5d4997ae 100644 --- a/packages/client/src/catalog.ts +++ b/packages/client/src/settings/catalog.ts @@ -10,7 +10,13 @@ * writes, so neither can rot without a red test. */ -import { BAYS, type Bay, OLD_NAMES, SHARED_DEFAULTS, type SharedSettings, SIDEBAR_BAYS } from "./settings.ts" +import { BAYS, type Bay, SHARED_DEFAULTS, type SharedSettings, SIDEBAR_BAYS } from "./index.ts" +import { REVIEW_KEYS } from "./keys/review.ts" +import { SHELL_KEYS } from "./keys/shell.ts" +import { STATUS_KEYS } from "./keys/status.ts" +import { SUBAGENTS_KEYS } from "./keys/subagents.ts" +import { TRUST_KEYS } from "./keys/trust.ts" +import { UPDATER_KEYS } from "./keys/updater.ts" export interface KeyInfo { key: string @@ -139,243 +145,17 @@ export const BAY_COMMANDS: Readonly> = { } /** - * Each bay's own keys. Defaults must equal what the bay hands `baySettings` — tested in + * Each bay's own keys, a file each in `keys/`. Defaults must equal what the bay hands `baySettings` — tested in * `packages/opencode/test/catalog.test.ts` against every bay's own `DEFAULTS`. */ export const OWN_KEYS: Readonly> = { - status: [ - { - key: "surface", - type: '"sidebar" | "bottom"', - default: undefined, - defaultText: '"sidebar"', - about: 'where the line draws; `"sidebar": false` says `"bottom"` too', - }, - { - key: "preset", - type: "string", - default: undefined, - defaultText: "the surface's own: `sidebar` in the sidebar, `default` at the bottom", - about: "a whole line by name; anything written beside it wins. The `status-setup` skill has them all", - }, - { - key: "segments", - type: "list", - default: undefined, - defaultText: "the preset's", - about: - "the line's parts, built-ins or your own — the whole list, replacing the preset's. To change a row or two, `override`", - }, - { - key: "override", - type: "object", - default: undefined, - defaultText: "none", - about: - 'changes to the preset\'s segments by name, the rest kept: `false` drops one, a name swaps it, an object merges into its settings — `{ "git": { "against": "branch" } }`', - }, - { - key: "lines", - type: "list", - default: undefined, - defaultText: "one line", - about: "more than one line, each with its own `surface`, `segments`, `maxRows`…", - }, - { - key: "separator", - type: "string", - default: undefined, - defaultText: '`" │ "` across, nothing down', - about: "drawn between segments", - }, - { - key: "stack", - type: '"horizontal" | "vertical"', - default: undefined, - defaultText: "vertical in the sidebar", - about: "segments across or down", - }, - { - key: "icons", - type: "boolean", - default: true, - about: "built-in icons; off for a terminal missing the glyphs", - }, - { - key: "debug", - type: "boolean", - default: false, - about: "draw a placeholder where a segment said nothing", - }, - { - key: "paddingLeft", - type: "number", - default: undefined, - defaultText: "3 at the bottom, 0 in the sidebar", - about: "columns of space left of the line", - }, - { - key: "paddingRight", - type: "number", - default: undefined, - defaultText: "2 at the bottom, 0 in the sidebar", - about: "columns of space right of the line", - }, - { - key: "paddingTop", - type: "number", - default: undefined, - defaultText: "0", - about: "rows of space above the line", - }, - { - key: "paddingBottom", - type: "number", - default: undefined, - defaultText: "1 at the bottom, 0 in the sidebar", - about: "rows of space below the line", - }, - { - key: "commands", - type: "object", - default: undefined, - defaultText: "none", - about: "shell commands usable as segments — a Claude Code statusline script works unchanged", - }, - { - key: "modules", - type: "string[]", - default: undefined, - defaultText: "none", - about: "your own segments in TypeScript; a project's add to the global ones", - }, - ], - subagents: [ - { - key: "hideFinishedAfterMinutes", - type: "number", - default: undefined, - defaultText: "unset: kept for the conversation", - about: "minutes a finished subagent stays in the sidebar (still reachable from `/subagents`)", - }, - { - key: "hideNestedAfterSeconds", - type: "number", - default: 30, - about: "seconds a finished nested subagent stays in the sidebar; negative keeps them", - }, - { - key: "guidance", - type: "boolean", - default: true, - about: "tell the agent how to follow, wait on and read its subagents (system prompt)", - }, - ], - shell: [ - { - key: "hideFinishedAfterMinutes", - type: "number", - default: 30, - about: "minutes a finished shell stays in the folded views", - }, - { key: "dockHeight", type: "number", default: 14, about: "rows of the shells panel under the chat" }, - { - key: "dockOpen", - type: "boolean", - default: undefined, - defaultText: "as you last left it", - about: "the panel starts open", - }, - { - key: "defaultView", - type: '"screen" | "log"', - default: "screen", - about: "what the console opens on: the live screen or the clean log", - }, - { key: "colors", type: "boolean", default: true, about: "paint the colours programs print" }, - { - key: "guidance", - type: "boolean", - default: true, - about: "tell the agent how to use shells (system prompt, ~120 tokens)", - }, - { - key: "listRunningShells", - type: "number", - default: 15, - about: "running shells named in the system prompt each turn; `0` off", - }, - { - key: "lifecycle", - type: "object", - default: {}, - defaultText: '`onExit: "stopMine"`, `orphanAfterMinutes: 60`, `removeFinishedAfterMinutes: 30`', - about: - 'when shells end: `onExit` (`"stopMine"` or `"keep"`, which survives a restart) and the two timers', - }, - { - key: "defaults", - type: "object", - default: {}, - defaultText: "none", - about: - "applied to every shell the agent starts: `watch`, `logFile`, `idleTimeoutSeconds`, `timeoutSeconds`, `notifyOnExit`", - }, - { - key: "notify", - type: "object", - default: {}, - defaultText: "`exit: true`, `watch: true`, `tailLines: 15`", - about: "what may interrupt the agent", - }, - { - key: "watch", - type: "object", - default: {}, - defaultText: "`auto: false`", - about: "health watching: `presets` (your own rules), `auto` (attach one to every shell)", - }, - { - key: "kinds", - type: "object", - default: {}, - defaultText: "none", - about: "your own shell categories, name → regular expression on the command", - }, - ], + status: STATUS_KEYS, + subagents: SUBAGENTS_KEYS, + shell: SHELL_KEYS, trail: [], - trust: [ - { - key: "threshold", - type: "number", - default: 3, - about: "approvals in a row, by you, before Trust answers", - }, - { key: "dangerExtra", type: "number", default: 5, about: "what a dangerous command costs on top" }, - { - key: "expireDays", - type: "number", - default: 30, - about: "days unused before trust has to be earned again; `0` never", - }, - ], - review: [ - { key: "variant", type: '"right" | "full"', default: "right", about: "where the pane opens" }, - { - key: "source", - type: '"worktree" | "branch"', - default: "worktree", - about: "what it reviews on open: uncommitted work, or the whole branch", - }, - ], - updater: [ - { - key: "updateCheck", - type: "boolean", - default: true, - about: "check for plugin updates once a day and say so", - }, - ], + trust: TRUST_KEYS, + review: REVIEW_KEYS, + updater: UPDATER_KEYS, } /** Status's block holds up to 14 rows with its `sidebar` preset; the loader's 8 is any other column's. */ @@ -425,7 +205,7 @@ export function settingsReference(): string { ), ] return [ - "", + "", "", "# Cockpit settings reference", "", @@ -454,19 +234,5 @@ export function settingsReference(): string { "- `hideWhenEmpty: true` keeps the block but draws nothing while there is nothing to list.", "", ...BAYS.flatMap((bay) => [`## \`${bay}\` — ${BAY_ABOUT[bay]}`, "", ...table(bay), ""]), - "## Names from before 0.9", - "", - "Not read at all. Each one found is a notice in `cockpit_settings` and a `!` row in its bay. In the", - "same file, move the value to the new name and remove the old one:", - "", - "| Old | New |", - "| --- | --- |", - ...OLD_NAMES.map((name) => - name.new === "sidebar" - ? `| \`${name.old}\` | the top-level \`sidebar\` list (remove it) |` - : `| \`${name.old}\` | \`${name.new}\` |`, - ), - "| Status keys at the file's root (`preset`, `segments`, `enabled`…) | the same keys under `status` |", - "", ].join("\n") } diff --git a/packages/client/src/checks.ts b/packages/client/src/settings/checks.ts similarity index 95% rename from packages/client/src/checks.ts rename to packages/client/src/settings/checks.ts index cf7913c4..e4292aa2 100644 --- a/packages/client/src/checks.ts +++ b/packages/client/src/settings/checks.ts @@ -2,7 +2,7 @@ * Every notice the bays draw, for what reports on the settings without being a bay: `cockpit_settings` * and doctor. "Notices: none" there has to mean no `!` row in any block after a restart. * - * The loader knows the files, the old names and the shared keys; it does not know a bay's own words. + * The loader knows the files and the shared keys; it does not know a bay's own words. * So each bay's notices come from the bay: its keys' kinds through the loader with its defaults (the * catalog's, which a test holds equal to every bay's own), and — where a bay checks more than kinds, * as Status does its presets, surfaces and overrides — its own check, offered here the way a bay @@ -12,7 +12,7 @@ */ import { bayKeys } from "./catalog.ts" -import { BAYS, type Bay, baySettings, type Settings, type SettingsNotice } from "./settings.ts" +import { BAYS, type Bay, baySettings, type Settings, type SettingsNotice } from "./index.ts" /** A bay's own check: every notice it draws for these settings and its plugin options. Pure. */ export type SettingsCheck = (input: { settings: Settings; options?: unknown }) => SettingsNotice[] diff --git a/packages/client/src/settings.ts b/packages/client/src/settings/index.ts similarity index 93% rename from packages/client/src/settings.ts rename to packages/client/src/settings/index.ts index 21d0dc18..8d8beaf0 100644 --- a/packages/client/src/settings.ts +++ b/packages/client/src/settings/index.ts @@ -19,9 +19,9 @@ * (draw the block, a boolean), `sidebarRows`, `hideWhenEmpty`. Time keys carry their unit * (`hideFinishedAfterMinutes`, `hideNestedAfterSeconds`). * - **One order**: the top-level `sidebar` list, and nothing else. - * - **Old names are not read.** They are recognised, so each one is a notice — the bay draws it as a - * `!` row, doctor prints it, `cockpit_settings` lists it for the `cockpit-setup` skill to fix — and - * its value is ignored. + * - **Names from before 0.9 are not read**, nor recognised since 0.10: each is an unknown name like a + * typo — doctor prints a top-level one, `cockpit_settings` lists every one for the `cockpit-setup` + * skill to fix — and its value is ignored. * - **It never throws.** An unreadable file, a wrong type, an unknown name: a notice, and the * defaults. A typo in a config should never cost you the interface. * @@ -32,9 +32,6 @@ import { readFileSync } from "node:fs" import { homedir } from "node:os" import { join } from "node:path" import { parseJsonc } from "./jsonc.ts" -import { oldAtTop, oldInSection } from "./settings/old-names.ts" - -export { OLD_NAMES } from "./settings/old-names.ts" // ── Names ────────────────────────────────────────────────────────────────────────────────────── @@ -57,9 +54,6 @@ export const PROJECT_FILE = ".cockpit.json" /** Where a notice from plugin-entry options says it came from. */ export const OPTIONS_SOURCE = "plugin options" -/** What every notice about an old name tells you to do. */ -export const SETUP_COMMAND = "/cockpit-setup" - // ── The shape ────────────────────────────────────────────────────────────────────────────────── /** Spelled the same in every bay. */ @@ -117,8 +111,6 @@ export interface CockpitSettings { // ── Notices ──────────────────────────────────────────────────────────────────────────────────── export type NoticeKind = - /** A name from before 0.9. Not read; `new` says what to write instead. */ - | "old" /** Not read: an unknown name, or a key in the wrong place. */ | "unread" /** The right name with the wrong kind of value; the default is used. */ @@ -128,8 +120,8 @@ export type NoticeKind = /** * Something about the settings worth fixing. `old` is the name as written, `new` the one to write - * instead (when there is one). `text` says it in a sentence — `"statusline" is no longer read — run - * /cockpit-setup` — for a bay's `!` row (`noticeText`) and doctor's fix line (`${file}: ${text}`). + * instead (when there is one). `text` says it in a sentence — `"stauts" is not a setting: did you mean + * "status"?` — for a bay's `!` row (`noticeText`) and doctor's fix line (`${file}: ${text}`). */ export interface SettingsNotice { /** The bay whose block should say it. `cockpit` belongs to no one bay: a file, the top level. */ @@ -145,10 +137,6 @@ export interface SettingsNotice { /** The row a bay draws for a notice, after a `!` in the warning tone. */ export const noticeText = (notice: SettingsNotice): string => `settings: ${notice.text}` -// ── Old names ────────────────────────────────────────────────────────────────────────────────── -// -// Detection only, and removed in 0.10: `settings/old-names.ts`. - // ── Small helpers ────────────────────────────────────────────────────────────────────────────── type Section = Record @@ -225,7 +213,7 @@ export interface SettingsFile { error?: string } -/** One file that was read: its sections, old names already taken out. */ +/** One file that was read: its sections, stray keys already taken out. */ export interface SettingsLayer { path: string scope: "global" | "project" @@ -269,10 +257,10 @@ function readText(path: string): string | undefined { } /** - * One bay's section with its old keys taken out, each one noted. `prefix` is how a key is named in - * a notice: `shell.` in a file, nothing in plugin options (where the keys are the bay's own). + * One bay's section with a `sidebar` list taken out, noted: the order is the top-level list. `prefix` + * is how a key is named in a notice: `shell.` in a file, nothing in plugin options. */ -function withoutOld( +function withoutOrder( bay: Bay, section: Section, file: string, @@ -281,7 +269,6 @@ function withoutOld( ): Section { const out: Section = {} for (const [key, value] of Object.entries(section)) { - if (oldInSection(bay, key, value, file, prefix, notes)) continue if (key === "sidebar" && Array.isArray(value)) { notes.push({ bay, @@ -400,7 +387,7 @@ function readFeatures(value: unknown, file: string, notes: SettingsNotice[]): Pa return out } -/** One parsed file into its sections; every old name and stray key noted, and left out. */ +/** One parsed file into its sections; every stray key noted, and left out. */ function readLayer(raw: Section, file: string, scope: SettingsLayer["scope"], notes: SettingsNotice[]) { const note = (notice: Omit) => notes.push({ file, ...notice }) const sections: Partial> = {} @@ -409,10 +396,10 @@ function readLayer(raw: Section, file: string, scope: SettingsLayer["scope"], no if (key === "sidebar" || key === "features" || key === "$schema") continue if (isBay(key)) { if (isObject(value)) { - const section = withoutOld(key, value, file, `${key}.`, notes) + const section = withoutOrder(key, value, file, `${key}.`, notes) sections[key] = checkShared(key, section, file, `${key}.`, notes) } else note({ bay: key, kind: "invalid", old: key, text: `"${key}" should be an object of settings` }) - } else if (!oldAtTop(key, value, note)) { + } else { const meant = closestName(key, TOP) note({ bay: meant && isBay(meant) ? meant : "cockpit", @@ -571,7 +558,7 @@ export function baySettings>( })) const options = optionsSection(bay, input.options) if (options) { - const section = withoutOld(bay, options, OPTIONS_SOURCE, "", notices) + const section = withoutOrder(bay, options, OPTIONS_SOURCE, "", notices) sources.push({ file: OPTIONS_SOURCE, prefix: "", diff --git a/packages/client/src/jsonc.ts b/packages/client/src/settings/jsonc.ts similarity index 100% rename from packages/client/src/jsonc.ts rename to packages/client/src/settings/jsonc.ts diff --git a/packages/client/src/settings/keys/review.ts b/packages/client/src/settings/keys/review.ts new file mode 100644 index 00000000..3bb3e58b --- /dev/null +++ b/packages/client/src/settings/keys/review.ts @@ -0,0 +1,13 @@ +/** Review's own keys. Defaults equal what the bay hands `baySettings` (`packages/opencode/test/catalog.test.ts`). */ + +import type { KeyInfo } from "../catalog.ts" + +export const REVIEW_KEYS: readonly KeyInfo[] = [ + { key: "variant", type: '"right" | "full"', default: "right", about: "where the pane opens" }, + { + key: "source", + type: '"worktree" | "branch"', + default: "worktree", + about: "what it reviews on open: uncommitted work, or the whole branch", + }, +] diff --git a/packages/client/src/settings/keys/shell.ts b/packages/client/src/settings/keys/shell.ts new file mode 100644 index 00000000..0f837760 --- /dev/null +++ b/packages/client/src/settings/keys/shell.ts @@ -0,0 +1,76 @@ +/** Shell's own keys. Defaults equal what the bay hands `baySettings` (`packages/opencode/test/catalog.test.ts`). */ + +import type { KeyInfo } from "../catalog.ts" + +export const SHELL_KEYS: readonly KeyInfo[] = [ + { + key: "hideFinishedAfterMinutes", + type: "number", + default: 30, + about: "minutes a finished shell stays in the folded views", + }, + { key: "dockHeight", type: "number", default: 14, about: "rows of the shells panel under the chat" }, + { + key: "dockOpen", + type: "boolean", + default: undefined, + defaultText: "as you last left it", + about: "the panel starts open", + }, + { + key: "defaultView", + type: '"screen" | "log"', + default: "screen", + about: "what the console opens on: the live screen or the clean log", + }, + { key: "colors", type: "boolean", default: true, about: "paint the colours programs print" }, + { + key: "guidance", + type: "boolean", + default: true, + about: "tell the agent how to use shells (system prompt, ~120 tokens)", + }, + { + key: "listRunningShells", + type: "number", + default: 15, + about: "running shells named in the system prompt each turn; `0` off", + }, + { + key: "lifecycle", + type: "object", + default: {}, + defaultText: '`onExit: "stopMine"`, `orphanAfterMinutes: 60`, `removeFinishedAfterMinutes: 30`', + about: + 'when shells end: `onExit` (`"stopMine"` or `"keep"`, which survives a restart) and the two timers', + }, + { + key: "defaults", + type: "object", + default: {}, + defaultText: "none", + about: + "applied to every shell the agent starts: `watch`, `logFile`, `idleTimeoutSeconds`, `timeoutSeconds`, `notifyOnExit`", + }, + { + key: "notify", + type: "object", + default: {}, + defaultText: "`exit: true`, `watch: true`, `tailLines: 15`", + about: "what may interrupt the agent", + }, + { + key: "watch", + type: "object", + default: {}, + defaultText: "`auto: false`", + about: "health watching: `presets` (your own rules), `auto` (attach one to every shell)", + }, + { + key: "kinds", + type: "object", + default: {}, + defaultText: "none", + about: "your own shell categories, name → regular expression on the command", + }, +] diff --git a/packages/client/src/settings/keys/status.ts b/packages/client/src/settings/keys/status.ts new file mode 100644 index 00000000..428bb755 --- /dev/null +++ b/packages/client/src/settings/keys/status.ts @@ -0,0 +1,111 @@ +/** Status's own keys. Defaults equal what the bay hands `baySettings` (`packages/opencode/test/catalog.test.ts`). */ + +import type { KeyInfo } from "../catalog.ts" + +export const STATUS_KEYS: readonly KeyInfo[] = [ + { + key: "surface", + type: '"sidebar" | "bottom"', + default: undefined, + defaultText: '"sidebar"', + about: 'where the line draws; `"sidebar": false` says `"bottom"` too', + }, + { + key: "preset", + type: "string", + default: undefined, + defaultText: "the surface's own: `sidebar` in the sidebar, `default` at the bottom", + about: "a whole line by name; anything written beside it wins. The `status-setup` skill has them all", + }, + { + key: "segments", + type: "list", + default: undefined, + defaultText: "the preset's", + about: + "the line's parts, built-ins or your own — the whole list, replacing the preset's. To change a row or two, `override`", + }, + { + key: "override", + type: "object", + default: undefined, + defaultText: "none", + about: + 'changes to the preset\'s segments by name, the rest kept: `false` drops one, a name swaps it, an object merges into its settings — `{ "git": { "against": "branch" } }`', + }, + { + key: "lines", + type: "list", + default: undefined, + defaultText: "one line", + about: "more than one line, each with its own `surface`, `segments`, `maxRows`…", + }, + { + key: "separator", + type: "string", + default: undefined, + defaultText: '`" │ "` across, nothing down', + about: "drawn between segments", + }, + { + key: "stack", + type: '"horizontal" | "vertical"', + default: undefined, + defaultText: "vertical in the sidebar", + about: "segments across or down", + }, + { + key: "icons", + type: "boolean", + default: true, + about: "built-in icons; off for a terminal missing the glyphs", + }, + { + key: "debug", + type: "boolean", + default: false, + about: "draw a placeholder where a segment said nothing", + }, + { + key: "paddingLeft", + type: "number", + default: undefined, + defaultText: "3 at the bottom, 0 in the sidebar", + about: "columns of space left of the line", + }, + { + key: "paddingRight", + type: "number", + default: undefined, + defaultText: "2 at the bottom, 0 in the sidebar", + about: "columns of space right of the line", + }, + { + key: "paddingTop", + type: "number", + default: undefined, + defaultText: "0", + about: "rows of space above the line", + }, + { + key: "paddingBottom", + type: "number", + default: undefined, + defaultText: "1 at the bottom, 0 in the sidebar", + about: "rows of space below the line", + }, + { + key: "commands", + type: "object", + default: undefined, + defaultText: "none", + about: "shell commands usable as segments — a Claude Code statusline script works unchanged", + }, + { + key: "modules", + type: "string[]", + default: undefined, + defaultText: "none", + about: "your own segments in TypeScript; a project's add to the global ones", + }, +] diff --git a/packages/client/src/settings/keys/subagents.ts b/packages/client/src/settings/keys/subagents.ts new file mode 100644 index 00000000..c96b8aef --- /dev/null +++ b/packages/client/src/settings/keys/subagents.ts @@ -0,0 +1,25 @@ +/** Subagents's own keys. Defaults equal what the bay hands `baySettings` (`packages/opencode/test/catalog.test.ts`). */ + +import type { KeyInfo } from "../catalog.ts" + +export const SUBAGENTS_KEYS: readonly KeyInfo[] = [ + { + key: "hideFinishedAfterMinutes", + type: "number", + default: undefined, + defaultText: "unset: kept for the conversation", + about: "minutes a finished subagent stays in the sidebar (still reachable from `/subagents`)", + }, + { + key: "hideNestedAfterSeconds", + type: "number", + default: 30, + about: "seconds a finished nested subagent stays in the sidebar; negative keeps them", + }, + { + key: "guidance", + type: "boolean", + default: true, + about: "tell the agent how to follow, wait on and read its subagents (system prompt)", + }, +] diff --git a/packages/client/src/settings/keys/trust.ts b/packages/client/src/settings/keys/trust.ts new file mode 100644 index 00000000..48f54ba4 --- /dev/null +++ b/packages/client/src/settings/keys/trust.ts @@ -0,0 +1,19 @@ +/** Trust's own keys. Defaults equal what the bay hands `baySettings` (`packages/opencode/test/catalog.test.ts`). */ + +import type { KeyInfo } from "../catalog.ts" + +export const TRUST_KEYS: readonly KeyInfo[] = [ + { + key: "threshold", + type: "number", + default: 3, + about: "approvals in a row, by you, before Trust answers", + }, + { key: "dangerExtra", type: "number", default: 5, about: "what a dangerous command costs on top" }, + { + key: "expireDays", + type: "number", + default: 30, + about: "days unused before trust has to be earned again; `0` never", + }, +] diff --git a/packages/client/src/settings/keys/updater.ts b/packages/client/src/settings/keys/updater.ts new file mode 100644 index 00000000..a1434f2b --- /dev/null +++ b/packages/client/src/settings/keys/updater.ts @@ -0,0 +1,12 @@ +/** Updater's own keys. Defaults equal what the bay hands `baySettings` (`packages/opencode/test/catalog.test.ts`). */ + +import type { KeyInfo } from "../catalog.ts" + +export const UPDATER_KEYS: readonly KeyInfo[] = [ + { + key: "updateCheck", + type: "boolean", + default: true, + about: "check for plugin updates once a day and say so", + }, +] diff --git a/packages/client/src/settings/old-names.ts b/packages/client/src/settings/old-names.ts deleted file mode 100644 index 72664356..00000000 --- a/packages/client/src/settings/old-names.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * Old names: detection only, and removed in 0.10 — by deleting this file and its two calls in - * `settings.ts` (`oldInSection`, `oldAtTop`). - * - * Before 0.9 each bay had its own spellings; they are no longer read, only recognised, so a config - * written for 0.8 says what changed instead of silently doing nothing. Values under these keys are - * ignored. - */ - -import { type Bay, SETUP_COMMAND, type SettingsNotice } from "../settings.ts" - -/** Shell's keys that sat at the file's root before it had a section. */ -const ROOT_SHELL = ["watch", "kinds", "defaults", "lifecycle", "notify", "guidance", "listRunningShells"] - -/** - * Root keys Status used to read as its own when the file had no section — so a root `enabled: false` - * meant for something else turned the statusline off. A file's root is no bay's settings now. - */ -const ROOT_STATUS = [ - "enabled", - "debug", - "preset", - "surface", - "segments", - "separator", - "stack", - "icons", - "maxRows", - "lines", - "commands", - "modules", - "paddingLeft", - "paddingRight", - "paddingTop", - "paddingBottom", -] - -/** Old keys inside a bay's section (and its plugin options), and what replaced them. */ -const OLD_IN_SECTION: Readonly>>>> = { - status: { maxRows: "sidebarRows" }, - shell: { historyMinutes: "hideFinishedAfterMinutes" }, - subagents: { hideFinishedAfter: "hideFinishedAfterMinutes", hideNestedAfter: "hideNestedAfterSeconds" }, -} - -/** Shell's old `ui` group: where each key went. Anything not listed went to `shell.`. */ -const OLD_UI: Readonly> = { - historyMinutes: "shell.hideFinishedAfterMinutes", - updateCheck: "updater.updateCheck", - sidebarOrder: "sidebar", -} - -/** Every old name the loader recognises, and what to write instead — for the `cockpit-setup` skill's reference. */ -export const OLD_NAMES: readonly { old: string; new: string }[] = [ - { old: "statusline", new: "status" }, - { old: "status.maxRows", new: "status.sidebarRows" }, - ...ROOT_SHELL.map((key) => ({ old: key, new: `shell.${key}` })), - { old: "ui.", new: "shell." }, - { old: "ui.historyMinutes", new: "shell.hideFinishedAfterMinutes" }, - { old: "ui.updateCheck", new: "updater.updateCheck" }, - { old: "ui.sidebarOrder", new: "sidebar" }, - { old: ".sidebarOrder", new: "sidebar" }, - { old: "subagents.hideFinishedAfter", new: "subagents.hideFinishedAfterMinutes" }, - { old: "subagents.hideNestedAfter", new: "subagents.hideNestedAfterSeconds" }, -] - -const oldText = (old: string) => `"${old}" is no longer read — run ${SETUP_COMMAND}` - -const isObject = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value) - -/** - * Whether a key in one bay's section is an old name; when it is, it is noted. `prefix` is how a key is - * named in a notice: `shell.` in a file, nothing in plugin options (where the keys are the bay's own). - */ -export function oldInSection( - bay: Bay, - key: string, - value: unknown, - file: string, - prefix: string, - notes: SettingsNotice[], -): boolean { - const old = (name: string, now: string) => - notes.push({ bay, file, kind: "old", old: name, new: now, text: oldText(name) }) - const now = OLD_IN_SECTION[bay]?.[key] - if (key === "sidebarOrder") old(`${prefix}${key}`, "sidebar") - else if (now) old(`${prefix}${key}`, `${bay}.${now}`) - else if (bay === "shell" && key === "ui" && isObject(value)) { - for (const inner of Object.keys(value)) old(`${prefix}ui.${inner}`, OLD_UI[inner] ?? `shell.${inner}`) - } else return false - return true -} - -/** Whether a key at a file's top level is an old name; when it is, it is noted. */ -export function oldAtTop( - key: string, - value: unknown, - note: (notice: Omit) => void, -): boolean { - if (key === "statusline") { - note({ bay: "status", kind: "old", old: key, new: "status", text: oldText(key) }) - } else if (ROOT_SHELL.includes(key)) { - note({ bay: "shell", kind: "old", old: key, new: `shell.${key}`, text: oldText(key) }) - } else if (key === "ui" && isObject(value)) { - for (const inner of Object.keys(value)) { - const now = OLD_UI[inner] ?? `shell.${inner}` - const bay = now.startsWith("updater.") ? "updater" : "shell" - note({ bay, kind: "old", old: `ui.${inner}`, new: now, text: oldText(`ui.${inner}`) }) - } - } else if (ROOT_STATUS.includes(key)) { - note({ - bay: "status", - kind: "unread", - old: key, - new: `status.${key}`, - text: `"${key}" at the top level is not read: it belongs in "status"`, - }) - } else return false - return true -} diff --git a/packages/client/src/plugin-entries.ts b/packages/client/src/settings/plugin-entries.ts similarity index 97% rename from packages/client/src/plugin-entries.ts rename to packages/client/src/settings/plugin-entries.ts index 0e72e9ef..3941ee9b 100644 --- a/packages/client/src/plugin-entries.ts +++ b/packages/client/src/settings/plugin-entries.ts @@ -6,7 +6,7 @@ */ import { basename } from "node:path" -import { BAYS, type Bay, isBay } from "./settings.ts" +import { BAYS, type Bay, isBay } from "./index.ts" /** The bundle's package, and the prefix every single bay's package carries. */ export const BUNDLE = "opencode-cockpit" diff --git a/packages/client/src/brief.ts b/packages/client/src/setup/brief.ts similarity index 98% rename from packages/client/src/brief.ts rename to packages/client/src/setup/brief.ts index 8efc9dd1..b344525e 100644 --- a/packages/client/src/brief.ts +++ b/packages/client/src/setup/brief.ts @@ -1,6 +1,6 @@ /** Sending a line to the agent from the interface, on either OpenCode. */ -import type { Host } from "./host.ts" +import type { Host } from "../opencode/host/index.ts" /** The session on screen, if there is one. */ function sessionOnScreen(host: Host): string | undefined { diff --git a/packages/client/src/setup/conventions/instructions.ts b/packages/client/src/setup/conventions/instructions.ts new file mode 100644 index 00000000..1c9e1f0a --- /dev/null +++ b/packages/client/src/setup/conventions/instructions.ts @@ -0,0 +1,66 @@ +/** Where the section goes: the instruction files OpenCode reads, project and global, as they are now. */ + +import { join } from "node:path" +import { findSections } from "./section.ts" + +export type InstructionScope = "project" | "global" + +export interface InstructionFile { + scope: InstructionScope + path: string + exists: boolean + /** The section's body when there is one; several are reported as a count. */ + sections: number + body?: string + /** The line of a start marker with no end. */ + unclosed?: number + /** + * OpenCode 1 reads the first file it finds of a list and stops there: `AGENTS.md` before the + * project's `CLAUDE.md` (and `CONTEXT.md`), the global `AGENTS.md` before `~/.claude/CLAUDE.md`. + * Read off 1.18.32's instruction loader. So creating this file stops OpenCode 1 reading that one. + */ + shadows?: string +} + +export function instructionPaths( + directory: string, + env: Readonly>, + home: string, +): Record { + return { + project: join(directory, "AGENTS.md"), + global: join(env.XDG_CONFIG_HOME || join(home, ".config"), "opencode", "AGENTS.md"), + } +} + +export function readInstructions( + opencode: 1 | 2, + directory: string, + env: Readonly>, + home: string, + read: (path: string) => string | undefined, +): InstructionFile[] { + const paths = instructionPaths(directory, env, home) + const fallbacks: Record = { + project: [join(directory, "CLAUDE.md"), join(directory, "CONTEXT.md")], + global: [join(home, ".claude", "CLAUDE.md")], + } + return (["project", "global"] as const).map((scope) => { + const path = paths[scope] + const text = read(path) + const found = findSections(text ?? "") + const shadows = + opencode === 1 && text === undefined + ? fallbacks[scope].find((file) => read(file) !== undefined) + : undefined + return { + scope, + path, + exists: text !== undefined, + sections: found.ok ? found.sections.length : 0, + ...(found.ok && found.sections[0] ? { body: found.sections[0].body } : {}), + ...(found.ok ? {} : { unclosed: found.line }), + ...(shadows ? { shadows } : {}), + } + }) +} diff --git a/packages/client/src/setup/conventions/project.ts b/packages/client/src/setup/conventions/project.ts new file mode 100644 index 00000000..6ab086d2 --- /dev/null +++ b/packages/client/src/setup/conventions/project.ts @@ -0,0 +1,220 @@ +/** + * What a project runs and uses, read fresh for the section: commands that never end, ticket keys in + * its history, where its pull requests go. + */ + +import { execFileSync } from "node:child_process" +import { join } from "node:path" + +/** A command that does not end on its own: a dev server, a watcher, `docker compose up`. */ +export interface LongRunning { + command: string + /** Where it was found, as the person would recognise it: `package.json "dev": "vite"`. */ + from: string + /** A short name for the shell: the script's or the target's. */ + name: string +} + +export interface ProjectFacts { + /** `bun`, `pnpm`, `yarn` or `npm`, from the lockfile; undefined with no package.json. */ + packageManager?: string + longRunning: LongRunning[] + /** The package.json scripts not counted as long-running. */ + otherScripts: string[] + /** Ticket prefixes seen in branch names and recent commit subjects, most used first. */ + tickets: { prefix: string; count: number; example: string }[] + /** Where pull requests go: each remote as `host/owner/repo`, credentials removed. */ + remotes: { name: string; repo: string }[] +} + +const LONG_NAME = + /^(dev|start|serve|server|watch|preview|storybook)$|[:_-](dev|watch|serve|server)$|^(dev|watch|serve)[:_-]/ +const LONG_COMMAND = + /--watch\b|(^|\s)watch(\s|$)|\bnodemon\b|\bvite\b(?!\s+build)|\bnext (dev|start)\b|\bnuxt dev\b|\bastro dev\b|\bstorybook dev\b|\bwebpack serve\b|\bdocker[- ]compose up\b|\bwrangler dev\b|\bng serve\b|\bexpo start\b|\btsx watch\b|\bbun --hot\b|\bbun --watch\b/ + +const isLong = (name: string, command: string) => LONG_NAME.test(name) || LONG_COMMAND.test(command) + +const LOCKFILES: [string, string][] = [ + ["bun.lock", "bun"], + ["bun.lockb", "bun"], + ["pnpm-lock.yaml", "pnpm"], + ["yarn.lock", "yarn"], + ["package-lock.json", "npm"], +] + +const runScript = (manager: string, script: string) => + manager === "yarn" ? `yarn ${script}` : `${manager} run ${script}` + +function packageScripts( + directory: string, + read: (path: string) => string | undefined, +): Pick { + const text = read(join(directory, "package.json")) + if (text === undefined) return { longRunning: [], otherScripts: [] } + let scripts: Record = {} + let declared: string | undefined + try { + const parsed = JSON.parse(text) as { scripts?: Record; packageManager?: unknown } + scripts = parsed.scripts ?? {} + if (typeof parsed.packageManager === "string") declared = parsed.packageManager.split("@")[0] + } catch { + return { longRunning: [], otherScripts: [] } + } + const manager = + LOCKFILES.find(([file]) => read(join(directory, file)) !== undefined)?.[1] ?? declared ?? "npm" + const longRunning: LongRunning[] = [] + const otherScripts: string[] = [] + for (const [name, command] of Object.entries(scripts)) { + if (typeof command !== "string") continue + if (isLong(name, command)) + longRunning.push({ + command: runScript(manager, name), + from: `package.json "${name}": "${command}"`, + name, + }) + else otherScripts.push(name) + } + return { packageManager: manager, longRunning, otherScripts } +} + +/** `make dev`, `make watch`…: targets named for a server or a watcher, or whose recipe is one. */ +function makeTargets(directory: string, read: (path: string) => string | undefined): LongRunning[] { + const text = read(join(directory, "Makefile")) ?? read(join(directory, "makefile")) + if (text === undefined) return [] + const out: LongRunning[] = [] + const lines = text.split("\n") + lines.forEach((line, at) => { + const target = /^([A-Za-z][\w.-]*)\s*:(?!=)/.exec(line)?.[1] + if (!target || target.startsWith(".")) return + const recipe: string[] = [] + for (const next of lines.slice(at + 1)) { + if (!next.startsWith("\t")) break + recipe.push(next.trim()) + } + if (isLong(target, recipe.join(" "))) + out.push({ command: `make ${target}`, from: `Makefile target "${target}"`, name: target }) + }) + return out +} + +const COMPOSE_FILES = ["compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"] + +function composeUp(directory: string, read: (path: string) => string | undefined): LongRunning[] { + for (const file of COMPOSE_FILES) { + const text = read(join(directory, file)) + if (text === undefined) continue + const services: string[] = [] + let inServices = false + for (const line of text.split("\n")) { + if (/^\S/.test(line)) inServices = /^services:\s*$/.test(line) + else if (inServices) { + const name = /^ {2}([\w.-]+):\s*$/.exec(line)?.[1] + if (name) services.push(name) + } + } + const named = services.length > 0 ? ` (services: ${services.join(", ")})` : "" + return [{ command: "docker compose up", from: `${file}${named}`, name: "compose" }] + } + return [] +} + +function procfile(directory: string, read: (path: string) => string | undefined): LongRunning[] { + return ["Procfile.dev", "Procfile"].flatMap((file) => + (read(join(directory, file)) ?? "").split("\n").flatMap((line) => { + const match = /^([\w-]+):\s*(.+)$/.exec(line.trim()) + return match + ? [{ command: (match[2] as string).trim(), from: `${file} "${match[1]}"`, name: match[1] as string }] + : [] + }), + ) +} + +/** Looks like a ticket key but is a standard or an encoding. */ +const NOT_TICKETS = new Set([ + "UTF", + "ISO", + "SHA", + "RFC", + "HTTP", + "TLS", + "CVE", + "ES", + "IE", + "MD", + "AES", + "RSA", + "WCAG", + "PEP", +]) + +export function ticketPrefixes(lines: readonly string[]): ProjectFacts["tickets"] { + const seen = new Map() + for (const line of lines) { + for (const match of line.matchAll(/\b([A-Z][A-Z0-9]{1,9})-(\d{1,6})\b/g)) { + const prefix = match[1] as string + if (NOT_TICKETS.has(prefix)) continue + const entry = seen.get(prefix) ?? { count: 0, example: match[0] } + entry.count++ + seen.set(prefix, entry) + } + } + return [...seen.entries()] + .map(([prefix, { count, example }]) => ({ prefix, count, example })) + .filter((ticket) => ticket.count >= 2) + .sort((a, b) => b.count - a.count) + .slice(0, 5) +} + +/** `git@github.com:acme/app.git`, `https://user:tok@github.com/acme/app` → `github.com/acme/app`. */ +export function repoOf(url: string): string { + const scp = /^[\w.-]+@([^:/]+):(.+)$/.exec(url) + const path = scp ? `${scp[1]}/${scp[2]}` : url.replace(/^[a-z+]+:\/\//i, "").replace(/^[^@/]*@/, "") + return path.replace(/\.git$/, "").replace(/\/+$/, "") +} + +/** Runs git in the project, or says nothing. */ +export type GitRun = (args: string[]) => string | undefined + +export function gitIn(directory: string): GitRun { + return (args) => { + try { + return execFileSync("git", args, { + cwd: directory, + encoding: "utf8", + timeout: 3000, + stdio: ["ignore", "pipe", "ignore"], + }) + } catch { + return undefined + } + } +} + +export function projectFacts( + directory: string, + read: (path: string) => string | undefined, + git: GitRun = gitIn(directory), +): ProjectFacts { + const scripts = packageScripts(directory, read) + const history = [ + ...(git(["branch", "--all", "--format=%(refname:short)"]) ?? "").split("\n"), + ...(git(["log", "-200", "--format=%s"]) ?? "").split("\n"), + ] + const remotes = new Map() + for (const line of (git(["remote", "-v"]) ?? "").split("\n")) { + const [name, url] = line.split(/\s+/) + if (name && url && !remotes.has(name)) remotes.set(name, repoOf(url)) + } + return { + ...(scripts.packageManager ? { packageManager: scripts.packageManager } : {}), + longRunning: [ + ...scripts.longRunning, + ...makeTargets(directory, read), + ...composeUp(directory, read), + ...procfile(directory, read), + ], + otherScripts: scripts.otherScripts, + tickets: ticketPrefixes(history), + remotes: [...remotes.entries()].map(([name, repo]) => ({ name, repo })), + } +} diff --git a/packages/client/src/setup/conventions/section.ts b/packages/client/src/setup/conventions/section.ts new file mode 100644 index 00000000..683134d8 --- /dev/null +++ b/packages/client/src/setup/conventions/section.ts @@ -0,0 +1,122 @@ +/** + * The second half of `/cockpit-setup`: making Cockpit fit how a person works. What the agent needs + * for it, read fresh — what this project runs that never ends, which ticket keys its history uses, + * where its pull requests go, what the instruction files say now — and the one write it makes: a + * marked `## Cockpit conventions` section in an `AGENTS.md`. + * + * Conventions only. How to use Cockpit is already in every request's system prompt (each bay's + * guidance); what a project's own instructions add is the part no bay can know — "the dev server is + * `bun dev`", "tickets are COM-…". + * + * The section is written here rather than by the agent's edit tool because it has to be the same + * section every time: a rerun replaces it in place, everything around it is kept byte for byte, and a + * model asked to "update the section" in someone's instructions file is one rewrite away from + * tidying the rest. + * + * This file is the marked section itself, found and written in place; `instructions.ts` is where it + * goes, `project.ts` what it says. + */ + +export const SECTION_START = + "" +export const SECTION_END = "" +export const SECTION_HEADING = "## Cockpit conventions" + +/** Matches a start marker however its tail was edited: the prefix is what marks it. */ +const START = //g +const END = //g + +export interface Section { + /** Offsets of the start marker and just past the end marker. */ + start: number + end: number + /** What is between the heading and the end marker, trimmed. */ + body: string +} + +export type Sections = { ok: true; sections: Section[] } | { ok: false; line: number } + +const lineAt = (text: string, offset: number) => text.slice(0, offset).split("\n").length + +/** Every marked section in a file, in order. A start with no end is an error naming its line. */ +export function findSections(text: string): Sections { + const sections: Section[] = [] + const ends = [...text.matchAll(END)] + for (const start of text.matchAll(START)) { + const from = start.index ?? 0 + if (sections.some((section) => from < section.end)) continue + const end = ends.find((each) => (each.index ?? 0) > from) + if (!end) return { ok: false, line: lineAt(text, from) } + const stop = (end.index ?? 0) + end[0].length + const inner = text.slice(from + start[0].length, end.index).trim() + const body = inner.startsWith(SECTION_HEADING) ? inner.slice(SECTION_HEADING.length).trim() : inner + sections.push({ start: from, end: stop, body }) + } + return { ok: true, sections } +} + +/** The body as the agent hands it, without a heading of its own (the section brings one). */ +export function cleanBody(body: string): string { + const text = body.replaceAll("\r\n", "\n").trim() + return text.startsWith(SECTION_HEADING) ? text.slice(SECTION_HEADING.length).trim() : text +} + +export function sectionText(body: string, eol = "\n"): string { + return [SECTION_START, SECTION_HEADING, "", ...cleanBody(body).split("\n"), SECTION_END].join(eol) +} + +export type WriteAction = "created" | "added" | "updated" | "unchanged" | "removed" | "absent" + +export type Written = + | { ok: true; action: WriteAction; text: string | undefined; merged: number } + | { ok: false; error: string } + +/** Cuts `[start, end)` and the blank line that set it apart, so removing a section undoes adding it. */ +function cut(text: string, start: number, end: number): string { + let from = start + while (from > 0 && (text[from - 1] === "\n" || text[from - 1] === "\r")) from-- + const before = text.slice(0, from) + let after = text.slice(end) + if (from === 0) after = after.replace(/^\r?\n/, "") + return before.length > 0 && after.length === 0 ? `${before}\n` : before + after +} + +/** + * The file with its section set to `body`: replaced where it is, added at the end where there is + * none, removed when `body` is empty. Everything outside the section is kept as it was. Several + * sections (two runs that raced, a paste) become one, where the first was. `text` undefined is a file + * that does not exist; `text: undefined` back means delete it — it held nothing but the section. + */ +export function writeSection(text: string | undefined, body: string): Written { + const content = cleanBody(body) + const found = findSections(text ?? "") + if (!found.ok) + return { + ok: false, + error: `the Cockpit section that starts at line ${found.line} has no end marker. Add \`${SECTION_END}\` on its own line where the section ends (or remove the start marker), then call this again.`, + } + /** CRLF only for a file written that way throughout; a stray `\r\n` in a LF file is not a style. */ + const eol = text?.includes("\r\n") && !/(^|[^\r])\n/.test(text) ? "\r\n" : "\n" + const [first, ...extra] = found.sections + if (!first) { + if (!content) return { ok: true, action: "absent", text, merged: 0 } + if (text === undefined || text.trim() === "") + return { + ok: true, + action: text === undefined ? "created" : "added", + text: `${sectionText(content, eol)}${eol}`, + merged: 0, + } + const sep = text.endsWith("\n") ? eol : `${eol}${eol}` + return { ok: true, action: "added", text: `${text}${sep}${sectionText(content, eol)}${eol}`, merged: 0 } + } + let out = text as string + for (const section of [...extra].reverse()) out = cut(out, section.start, section.end) + if (!content) { + out = cut(out, first.start, first.end) + return { ok: true, action: "removed", text: out.trim() === "" ? undefined : out, merged: extra.length } + } + out = out.slice(0, first.start) + sectionText(content, eol) + out.slice(first.end) + const action = extra.length === 0 && first.body === content && out === text ? "unchanged" : "updated" + return { ok: true, action, text: out, merged: extra.length } +} diff --git a/packages/client/src/setup/host-blocks.ts b/packages/client/src/setup/host-blocks.ts index 7d719ac2..31499be9 100644 --- a/packages/client/src/setup/host-blocks.ts +++ b/packages/client/src/setup/host-blocks.ts @@ -9,8 +9,8 @@ import { homedir } from "node:os" import { join } from "node:path" -import { parseJsonc } from "../jsonc.ts" -import { pluginEntries } from "../plugin-entries.ts" +import { parseJsonc } from "../settings/jsonc.ts" +import { pluginEntries } from "../settings/plugin-entries.ts" import { isObject, opencodeDirs } from "./installs.ts" /** OpenCode's own sidebar blocks this talks about, and their plugin ids on each version. */ diff --git a/packages/client/src/setup.ts b/packages/client/src/setup/index.ts similarity index 82% rename from packages/client/src/setup.ts rename to packages/client/src/setup/index.ts index 9eeaf848..f9e5e996 100644 --- a/packages/client/src/setup.ts +++ b/packages/client/src/setup/index.ts @@ -24,10 +24,10 @@ * loads the other's code. */ +export { baysOfEntry } from "../settings/plugin-entries.ts" export { briefAgent } from "./brief.ts" -export { baysOfEntry } from "./plugin-entries.ts" -export { HOST_BLOCKS, type HostFile, hostFilePaths, readHostFile } from "./setup/host-blocks.ts" -export { type Install, opencodeConfigPaths, readInstalls } from "./setup/installs.ts" +export { HOST_BLOCKS, type HostFile, hostFilePaths, readHostFile } from "./host-blocks.ts" +export { type Install, opencodeConfigPaths, readInstalls } from "./installs.ts" export { CONVENTIONS_TOOL, SETTINGS_TOOL, @@ -35,9 +35,9 @@ export { SETUP_SKILL, SETUP_SKILL_DIR, SETUP_SLASH, -} from "./setup/names.ts" -export { registerSetup } from "./setup/palette.ts" -export { offerPreview, previewCommands } from "./setup/previews.ts" +} from "./names.ts" +export { registerSetup } from "./palette.ts" +export { offerPreview, previewCommands } from "./previews.ts" export { type BayState, type ReportInput, @@ -45,7 +45,7 @@ export { type SettingsReport, type Source, settingsReport, -} from "./setup/report.ts" -export { setupServer } from "./setup/server.ts" -export { blockState, noticeLine, settingsText } from "./setup/text.ts" -export { bayCommands, conventionsReply, type TuneFacts, tuneFacts, tuneText } from "./setup/tune.ts" +} from "./report.ts" +export { setupServer } from "./server.ts" +export { blockState, noticeLine, settingsText } from "./text.ts" +export { bayCommands, conventionsReply, type TuneFacts, tuneFacts, tuneText } from "./tune.ts" diff --git a/packages/client/src/setup/installs.ts b/packages/client/src/setup/installs.ts index 0709feca..29f896c5 100644 --- a/packages/client/src/setup/installs.ts +++ b/packages/client/src/setup/installs.ts @@ -8,9 +8,9 @@ import { homedir } from "node:os" import { join } from "node:path" -import { parseJsonc } from "../jsonc.ts" -import { baysOfEntry, pluginEntries } from "../plugin-entries.ts" -import type { Bay } from "../settings.ts" +import type { Bay } from "../settings/index.ts" +import { parseJsonc } from "../settings/jsonc.ts" +import { baysOfEntry, pluginEntries } from "../settings/plugin-entries.ts" export const isObject = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value) diff --git a/packages/client/src/setup/palette.ts b/packages/client/src/setup/palette.ts index 8ae99dcb..c48d7c09 100644 --- a/packages/client/src/setup/palette.ts +++ b/packages/client/src/setup/palette.ts @@ -1,8 +1,8 @@ /** The interface: a palette entry that asks the agent to set Cockpit up. */ -import { briefAgent } from "../brief.ts" import { claimFeature } from "../feature.ts" -import type { Host } from "../host.ts" +import type { Host } from "../opencode/host/index.ts" +import { briefAgent } from "./brief.ts" import { SETUP_PROMPT } from "./names.ts" /** diff --git a/packages/client/src/setup/report.ts b/packages/client/src/setup/report.ts index 2d1ee73d..5833c12e 100644 --- a/packages/client/src/setup/report.ts +++ b/packages/client/src/setup/report.ts @@ -5,9 +5,8 @@ import { readFileSync } from "node:fs" import { homedir } from "node:os" -import { bayKeys, type KeyInfo } from "../catalog.ts" -import { bayNotices, uniqueNotices } from "../checks.ts" -import { baysOfEntry } from "../plugin-entries.ts" +import { bayKeys, type KeyInfo } from "../settings/catalog.ts" +import { bayNotices, uniqueNotices } from "../settings/checks.ts" import { BAYS, type Bay, @@ -18,7 +17,8 @@ import { type Settings, type SettingsNotice, type SettingsWhere, -} from "../settings.ts" +} from "../settings/index.ts" +import { baysOfEntry } from "../settings/plugin-entries.ts" import { type HostFile, hostFilePaths, readHostFile } from "./host-blocks.ts" import { entryOptions, type Install, isObject, opencodeConfigPaths, readInstalls } from "./installs.ts" diff --git a/packages/client/src/setup/server.ts b/packages/client/src/setup/server.ts index f54e3d36..d734ab9c 100644 --- a/packages/client/src/setup/server.ts +++ b/packages/client/src/setup/server.ts @@ -4,9 +4,10 @@ import { mkdirSync, rmSync, writeFileSync } from "node:fs" import { homedir } from "node:os" import { dirname } from "node:path" import { tool } from "@opencode-ai/plugin" -import { instructionPaths, writeSection } from "../conventions.ts" import { claimedFeatures, claimFeature } from "../feature.ts" -import type { ServerHost, ServerParts } from "../server.ts" +import type { ServerHost, ServerParts } from "../opencode/server/index.ts" +import { instructionPaths } from "./conventions/instructions.ts" +import { writeSection } from "./conventions/section.ts" import { CONVENTIONS_TOOL, SETTINGS_TOOL, SETUP_PROMPT, SETUP_SKILL_DIR, SETUP_SLASH } from "./names.ts" import { previewCommands } from "./previews.ts" import { type ReportInput, readText, settingsReport } from "./report.ts" diff --git a/packages/client/src/setup/text.ts b/packages/client/src/setup/text.ts index 0344c2e9..4ab9433d 100644 --- a/packages/client/src/setup/text.ts +++ b/packages/client/src/setup/text.ts @@ -1,13 +1,13 @@ /** The tool's answer: the report as text the agent acts on. */ -import { BAY_ABOUT, shownDefault } from "../catalog.ts" +import { BAY_ABOUT, shownDefault } from "../settings/catalog.ts" import { isSidebarBay, OPTIONS_SOURCE, type Settings, type SettingsNotice, type SidebarBay, -} from "../settings.ts" +} from "../settings/index.ts" import { HOST_BLOCKS } from "./host-blocks.ts" import type { BayState, ResolvedKey, SettingsReport } from "./report.ts" @@ -31,18 +31,10 @@ export function blockState(state: BayState): string { return value("hideWhenEmpty") === true ? "shown, hidden while empty" : "shown, `none yet` while empty" } -/** - * One notice as a line to act on. An old name says what to write instead; the order's old numbers - * have no new name to copy to, because the order is the list now. - */ +/** One notice as a line to act on. */ export function noticeLine(notice: SettingsNotice): string { const where = notice.file === OPTIONS_SOURCE ? "plugin options" : notice.file - if (notice.kind === "old" && notice.old && notice.new) { - if (notice.new === "sidebar") - return `- ${where}: "${notice.old}" is no longer read. Remove it; the order is the top-level "sidebar" list.` - return `- ${where}: "${notice.old}" is no longer read. Move its value to "${notice.new}" and remove "${notice.old}".` - } - const text = notice.text.replace(/ — run \/cockpit-setup$/, "") + const { text } = notice /** A bay's own words may end in a question (`did you mean "git"?`): no full stop after it. */ return `- ${where}: ${text}${/[.?!]$/.test(text) ? "" : "."}` } diff --git a/packages/client/src/setup/tune.ts b/packages/client/src/setup/tune.ts index f27837fd..b271da32 100644 --- a/packages/client/src/setup/tune.ts +++ b/packages/client/src/setup/tune.ts @@ -8,18 +8,10 @@ */ import { homedir } from "node:os" -import { BAY_ABOUT, BAY_COMMANDS, DEFAULT_KEYS } from "../catalog.ts" -import { - findSections, - type GitRun, - type InstructionFile, - type ProjectFacts, - projectFacts, - readInstructions, - sectionText, - type WriteAction, - type Written, -} from "../conventions.ts" +import { BAY_ABOUT, BAY_COMMANDS, DEFAULT_KEYS } from "../settings/catalog.ts" +import { type InstructionFile, readInstructions } from "./conventions/instructions.ts" +import { type GitRun, type ProjectFacts, projectFacts } from "./conventions/project.ts" +import { findSections, sectionText, type WriteAction, type Written } from "./conventions/section.ts" import { isObject } from "./installs.ts" import { CONVENTIONS_TOOL } from "./names.ts" import { type BayState, type ReportInput, readText, type SettingsReport } from "./report.ts" diff --git a/packages/client/test/catalog.test.ts b/packages/client/test/catalog.test.ts index 10298379..efd0b047 100644 --- a/packages/client/test/catalog.test.ts +++ b/packages/client/test/catalog.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "bun:test" import { readFileSync } from "node:fs" import { join } from "node:path" -import { BAY_ABOUT, bayKeys, settingsReference } from "../src/catalog.ts" -import { readSkill } from "../src/server.ts" -import { BAYS, SHARED_DEFAULTS } from "../src/settings.ts" +import { readSkill } from "../src/opencode/server/index.ts" +import { BAY_ABOUT, bayKeys, settingsReference } from "../src/settings/catalog.ts" +import { BAYS, SHARED_DEFAULTS } from "../src/settings/index.ts" import { CONVENTIONS_TOOL, HOST_BLOCKS, @@ -11,7 +11,7 @@ import { SETUP_SKILL, SETUP_SKILL_DIR, settingsReport, -} from "../src/setup.ts" +} from "../src/setup/index.ts" /** * The `cockpit-setup` skill is static text shipped beside code that changes. These are what keep it diff --git a/packages/client/test/checks.test.ts b/packages/client/test/checks.test.ts index 8dd7fb28..ff8cd92c 100644 --- a/packages/client/test/checks.test.ts +++ b/packages/client/test/checks.test.ts @@ -5,9 +5,9 @@ import { offerSettingsCheck, type SettingsCheck, uniqueNotices, -} from "../src/checks.ts" -import { loadSettings, type SettingsNotice } from "../src/settings.ts" -import { settingsReport, settingsText } from "../src/setup.ts" +} from "../src/settings/checks.ts" +import { loadSettings, type SettingsNotice } from "../src/settings/index.ts" +import { settingsReport, settingsText } from "../src/setup/index.ts" /** * "Notices: none" from `cockpit_settings` has to mean no `!` row in any block. The audit's case: the @@ -81,9 +81,9 @@ describe("cockpit_settings lists what the bays draw", () => { describe("bayNotices", () => { test("each notice once: the loader's are in the bay's list too", () => { - const settings = loadSettings(where({ status: { maxRows: 3 } })) + const settings = loadSettings(where({ status: { sidebar: ["status"] } })) const notices = bayNotices("status", settings, undefined, ({ settings }) => settings.notices) - expect(notices.filter((notice) => notice.text.includes("status.maxRows"))).toHaveLength(1) + expect(notices.filter((notice) => notice.text.includes("status.sidebar"))).toHaveLength(1) }) test("plugin options are checked as the bay reads them", () => { diff --git a/packages/client/test/conventions.test.ts b/packages/client/test/conventions.test.ts index 9780782e..e826b7b4 100644 --- a/packages/client/test/conventions.test.ts +++ b/packages/client/test/conventions.test.ts @@ -1,16 +1,14 @@ import { describe, expect, test } from "bun:test" +import { readInstructions } from "../src/setup/conventions/instructions.ts" +import { projectFacts, repoOf, ticketPrefixes } from "../src/setup/conventions/project.ts" import { findSections, - projectFacts, - readInstructions, - repoOf, SECTION_END, SECTION_HEADING, SECTION_START, sectionText, - ticketPrefixes, writeSection, -} from "../src/conventions.ts" +} from "../src/setup/conventions/section.ts" /** * The second phase of /cockpit-setup writes into someone's own instructions file. What is tested is diff --git a/packages/client/test/design.test.ts b/packages/client/test/design.test.ts index 640c8947..b743c5ba 100644 --- a/packages/client/test/design.test.ts +++ b/packages/client/test/design.test.ts @@ -192,12 +192,12 @@ describe("an empty sidebar block", () => { expect(rows.at(-1)?.[0]).toMatchObject({ text: "none yet", tone: "muted" }) }) - test("a warning wraps at spaces and keeps its fix", () => { - const rows = warnRows('settings: "statusline" is no longer read — run /cockpit-setup', 30) + test("a warning wraps at spaces", () => { + const rows = warnRows('settings: "stauts" is not a setting: did you mean "status"?', 30) expect(lines(rows)).toEqual([ - '! settings: "statusline" is no', - " longer read — run".padEnd(30), - " /cockpit-setup".padEnd(30), + '! settings: "stauts" is not a ', + " setting: did you mean".padEnd(30), + ' "status"?'.padEnd(30), ]) expect(rows[0]?.[0]).toMatchObject({ text: "! ", tone: "warning" }) expect(lines(warnRows("one two three four five six", 9, 2))).toEqual(["! one two", " three…".padEnd(9)]) diff --git a/packages/client/test/host.test.ts b/packages/client/test/host.test.ts index 164cabfe..4fb68964 100644 --- a/packages/client/test/host.test.ts +++ b/packages/client/test/host.test.ts @@ -1,5 +1,13 @@ import { describe, expect, test } from "bun:test" -import { dualTui, fromV1, fromV2, layerToV2, onPaste, themeFromV2, type V2Context } from "../src/host.ts" +import { + dualTui, + fromV1, + fromV2, + layerToV2, + onPaste, + themeFromV2, + type V2Context, +} from "../src/opencode/host/index.ts" /** * The v2 half of the host, against a fake context shaped like OpenCode 2.0.15's (docs/opencode/v2.md). diff --git a/packages/client/test/jsonc.test.ts b/packages/client/test/jsonc.test.ts index 6fbe6f8b..e600ad60 100644 --- a/packages/client/test/jsonc.test.ts +++ b/packages/client/test/jsonc.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { parseJsonc } from "../src/jsonc.ts" +import { parseJsonc } from "../src/settings/jsonc.ts" describe("parseJsonc", () => { test("drops comments and trailing commas, and leaves strings alone", () => { diff --git a/packages/client/test/plugin-entries.test.ts b/packages/client/test/plugin-entries.test.ts index 513ca94d..cd6499eb 100644 --- a/packages/client/test/plugin-entries.test.ts +++ b/packages/client/test/plugin-entries.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" -import { bayOf, baysOfEntry, pluginEntries } from "../src/plugin-entries.ts" -import { BAYS } from "../src/settings.ts" +import { BAYS } from "../src/settings/index.ts" +import { bayOf, baysOfEntry, pluginEntries } from "../src/settings/plugin-entries.ts" describe("Cockpit's plugin entries (one reader for /cockpit-setup and doctor)", () => { test("every spelling of an entry, v1's and v2's", () => { diff --git a/packages/client/test/server.test.ts b/packages/client/test/server.test.ts index d7a650e6..5d40a17b 100644 --- a/packages/client/test/server.test.ts +++ b/packages/client/test/server.test.ts @@ -19,7 +19,7 @@ import { toolToV2, v1ToolText, v2ToolCall, -} from "../src/server.ts" +} from "../src/opencode/server/index.ts" /** * The server half of running on both: one feature's tools and hooks, handed to v1 as hooks and to diff --git a/packages/client/test/service.test.ts b/packages/client/test/service.test.ts index 54ac90b5..b8ddd184 100644 --- a/packages/client/test/service.test.ts +++ b/packages/client/test/service.test.ts @@ -2,8 +2,8 @@ import { afterEach, describe, expect, test } from "bun:test" import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { join } from "node:path" import { claimFeature } from "../src/feature.ts" -import type { Host } from "../src/host.ts" import { silentLog } from "../src/log.ts" +import type { Host } from "../src/opencode/host/index.ts" import { type AgentRecord, agentsDir, @@ -21,7 +21,7 @@ import { serviceFile, servicePid, verdict, -} from "../src/service.ts" +} from "../src/opencode/service.ts" /** * A window compares the install it loaded with the one OpenCode 2's background service loaded, and diff --git a/packages/client/test/settings.test.ts b/packages/client/test/settings.test.ts index 82eae1be..5246a940 100644 --- a/packages/client/test/settings.test.ts +++ b/packages/client/test/settings.test.ts @@ -1,16 +1,9 @@ import { describe, expect, test } from "bun:test" -import { - baySettings, - closestName, - loadSettings, - OLD_NAMES, - orderOf, - type SettingsWhere, -} from "../src/settings.ts" +import { baySettings, closestName, loadSettings, orderOf, type SettingsWhere } from "../src/settings/index.ts" /** * One loader for every bay: the files mean the same thing to all of them, a comment never drops a - * file, an old name says it is not read, and nothing a user writes can throw. + * file, an unknown name says it is not read, and nothing a user writes can throw. */ const GLOBAL = "/home/me/.config/opencode-cockpit/config.json" @@ -125,74 +118,33 @@ describe("reading the files", () => { ) expect(loaded.config.enabled).toBe(true) expect(loaded.config.debug).toBe(false) - expect(loaded.notices.map((notice) => notice.text)).toEqual([ - '"enabled" at the top level is not read: it belongs in "status"', - '"debug" at the top level is not read: it belongs in "status"', + const notices = loadSettings(where({ [GLOBAL]: { enabled: false, debug: true } })).notices + expect(notices.map((notice) => [notice.bay, notice.text])).toEqual([ + ["cockpit", '"enabled" is not a setting'], + ["cockpit", '"debug" is not a setting'], ]) }) }) -describe("old names", () => { - /** Detection only: the value under an old name is ignored, the default applies, and it says so. */ - test("each one is a notice, its value ignored and the default used", () => { - const loaded = (bay: Parameters[0], file: object, defaults = {}) => - baySettings(bay, defaults, { where: where({ [GLOBAL]: file }) }) - - const status = loaded("status", { statusline: { maxRows: 3, debug: true } }, { debug: false }) +describe("names from before 0.9", () => { + /** No longer recognised: one is an unknown name like any other, its value ignored. */ + test("a top-level one is not a setting, and the nearest name is offered", () => { + const status = baySettings( + "status", + { debug: false }, + { where: where({ [GLOBAL]: { statusline: { debug: true } } }) }, + ) expect(status.config.debug).toBe(false) expect(status.notices).toEqual([ { bay: "status", file: GLOBAL, - kind: "old", + kind: "unread", old: "statusline", new: "status", - text: '"statusline" is no longer read — run /cockpit-setup', + text: '"statusline" is not a setting: did you mean "status"?', }, ]) - - const shell = loaded( - "shell", - { watch: { auto: true }, ui: { dockHeight: 30, sidebarOrder: 1 } }, - { dockHeight: 14 }, - ) - expect(shell.config.dockHeight).toBe(14) - expect(shell.config).not.toHaveProperty("watch") - expect(shell.notices.map((notice) => [notice.old, notice.new])).toEqual([ - ["watch", "shell.watch"], - ["ui.dockHeight", "shell.dockHeight"], - ["ui.sidebarOrder", "sidebar"], - ]) - - const subagents = loaded("subagents", { - subagents: { hideFinishedAfter: 5, hideNestedAfter: 10, sidebarOrder: 3 }, - }) - expect(subagents.config).not.toHaveProperty("hideFinishedAfterMinutes") - expect(subagents.notices.map((notice) => notice.new)).toEqual([ - "subagents.hideFinishedAfterMinutes", - "subagents.hideNestedAfterSeconds", - "sidebar", - ]) - - const maxRows = loaded("status", { status: { maxRows: 3 } }) - expect(maxRows.config.sidebarRows).toBe(8) - expect(maxRows.notices[0]?.new).toBe("status.sidebarRows") - - const updater = loaded("updater", { ui: { updateCheck: false } }) - expect(updater.notices[0]).toMatchObject({ old: "ui.updateCheck", new: "updater.updateCheck" }) - }) - - test("old names in plugin options too", () => { - const loaded = baySettings("subagents", {}, { where: where({}), options: { hideFinishedAfter: 5 } }) - expect(loaded.notices).toEqual([ - expect.objectContaining({ file: "plugin options", old: "hideFinishedAfter", kind: "old" }), - ]) - expect(loaded.written).toEqual({}) - }) - - test("the list is there for /cockpit-setup's brief", () => { - expect(OLD_NAMES).toContainEqual({ old: "statusline", new: "status" }) - expect(OLD_NAMES).toContainEqual({ old: ".sidebarOrder", new: "sidebar" }) }) }) @@ -212,7 +164,6 @@ describe("the sidebar order", () => { test("a bay's old sidebarOrder no longer moves it", () => { const loaded = baySettings("trust", {}, { where: where({ [GLOBAL]: { trust: { sidebarOrder: 1 } } }) }) expect(loaded.order).toBe(150) - expect(loaded.notices[0]).toMatchObject({ old: "trust.sidebarOrder", new: "sidebar", kind: "old" }) }) test("a project's list replaces the global one", () => { diff --git a/packages/client/test/setup.test.ts b/packages/client/test/setup.test.ts index 0db9b7bb..8089f035 100644 --- a/packages/client/test/setup.test.ts +++ b/packages/client/test/setup.test.ts @@ -1,11 +1,11 @@ import { describe, expect, test } from "bun:test" import { existsSync } from "node:fs" import { join } from "node:path" -import { sectionText, writeSection } from "../src/conventions.ts" import { claimFeature } from "../src/feature.ts" -import type { Host } from "../src/host.ts" import { silentLog } from "../src/log.ts" -import type { ServerHost } from "../src/server.ts" +import type { Host } from "../src/opencode/host/index.ts" +import type { ServerHost } from "../src/opencode/server/index.ts" +import { sectionText, writeSection } from "../src/setup/conventions/section.ts" import { baysOfEntry, briefAgent, @@ -26,7 +26,7 @@ import { setupServer, tuneFacts, tuneText, -} from "../src/setup.ts" +} from "../src/setup/index.ts" /** * `cockpit_settings` is what the agent reads before and after it edits. What is tested is the facts @@ -103,19 +103,10 @@ describe("previews", () => { }) describe("what to fix", () => { - test("an old name says where its value goes", () => { - const { text } = report({ [GLOBAL]: { statusline: {}, ui: { sidebarRows: 3 } } }) - expect(text).toContain( - `- ${GLOBAL}: "statusline" is no longer read. Move its value to "status" and remove "statusline".`, - ) - expect(text).toContain(`"ui.sidebarRows" is no longer read. Move its value to "shell.sidebarRows"`) - }) - - test("an old place number points at the list, not at a name to copy it to", () => { - const { text } = report({ [PROJECT]: { review: { sidebarOrder: 3 } } }) - expect(text).toContain( - `"review.sidebarOrder" is no longer read. Remove it; the order is the top-level "sidebar" list.`, - ) + test("a name from before 0.9 is not a setting: the nearest one is offered", () => { + const { text } = report({ [GLOBAL]: { statusline: {}, review: { sidebarOrder: 3 } } }) + expect(text).toContain(`- ${GLOBAL}: "statusline" is not a setting: did you mean "status"?`) + expect(text).toContain(`- ${GLOBAL}: "review.sidebarOrder" is not a setting of review.`) }) test("a key no bay reads is a notice too, with the one it most likely meant", () => { diff --git a/packages/client/test/sidebar.test.ts b/packages/client/test/sidebar.test.ts index 50a349d2..a6923502 100644 --- a/packages/client/test/sidebar.test.ts +++ b/packages/client/test/sidebar.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" -import type { Host } from "../src/host.ts" -import { orderedSidebar } from "../src/sidebar.ts" +import type { Host } from "../src/opencode/host/index.ts" +import { blockWidth, measureBlock, orderedSidebar } from "../src/opencode/sidebar.ts" describe("orderedSidebar", () => { test("sidebar blocks register in their order, everything else at once", () => { @@ -26,3 +26,17 @@ describe("orderedSidebar", () => { ]) }) }) + +describe("a block's width", () => { + test("the container's, not the rows': a block stretched by a long row still draws at the sidebar's", () => { + expect(blockWidth({ width: 60, parent: { width: 36 } }, 200)).toBe(36) + expect(blockWidth({ width: 30, parent: { width: 36 } }, 200)).toBe(30) + }) + + test("before the first layout, a quarter of the window, between 20 and the widest guess", () => { + expect(blockWidth(undefined, 200)).toBe(40) + expect(blockWidth(undefined, 200, 30)).toBe(30) + expect(blockWidth({ width: 0, parent: { width: 0 } }, 60)).toBe(20) + expect(measureBlock(undefined)).toEqual({ parent: 0, own: 0, measured: 0 }) + }) +}) diff --git a/packages/client/test/surfaces.test.ts b/packages/client/test/surfaces.test.ts index 5eb6e17e..50bc0da4 100644 --- a/packages/client/test/surfaces.test.ts +++ b/packages/client/test/surfaces.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" -import { DEFAULT_KEYS } from "../src/catalog.ts" -import { composeParts, dualServer, type ServerParts } from "../src/server.ts" -import { keyText, openText, registerSurfaces, surfacesLine } from "../src/surfaces.ts" +import { composeParts, dualServer, type ServerParts } from "../src/opencode/server/index.ts" +import { keyText, openText, registerSurfaces, surfacesLine } from "../src/opencode/server/surfaces.ts" +import { DEFAULT_KEYS } from "../src/settings/catalog.ts" /** * The one Cockpit-wide line: where the user sees what the agent made, written from the bays that are diff --git a/packages/opencode/README.md b/packages/opencode/README.md index f926b93b..31d4f83c 100644 --- a/packages/opencode/README.md +++ b/packages/opencode/README.md @@ -104,7 +104,7 @@ keeping line numbers and highlighting matches — and output keeps the colours t | `ctrl+x p` · `/trust` | What Trust answered for you, and what it has learned | | `/plugins-update` | Every plugin you have installed: what runs, what is published, and an update checked against disk | | `/cockpit-setup` | The agent sets Cockpit up with you, with the `cockpit-setup` skill: which bays show, where, in what order — then, if you want, tunes it to how your project works | -| `/status-setup` | The agent designs the Status line with you, with the `status-setup` skill (`/statusline` until 0.9) | +| `/status-setup` | The agent designs the Status line with you, with the `status-setup` skill | 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. @@ -185,10 +185,9 @@ skills; a window says so in a toast, and `npx opencode-cockpit@latest doctor` do One section per bay — `status`, `subagents`, `shell`, `trail`, `trust`, `review`, `updater` — with the same shared keys in each (`enabled`, `sidebar`, `sidebarRows`, `hideWhenEmpty`, `keybinds`). A -project's file wins key by key; comments and trailing commas are fine. Names from before 0.9 -(`statusline`, Shell's keys at the root, `ui.*`, `sidebarOrder`) are no longer read: each is a `!` -row in its bay's block, and `/cockpit-setup` fixes it. Every key, its default and the old names: -[Configuration](https://github.com/Codestz/opencode-cockpit#configuration). +project's file wins key by key; comments and trailing commas are fine. A name Cockpit does not read +— a typo, or one from before 0.9 like `statusline` — is a notice in doctor and `/cockpit-setup`, +which fixes it. Every key and its default: [Configuration](https://github.com/Codestz/opencode-cockpit#configuration). ## Troubleshooting diff --git a/packages/opencode/test/catalog.test.ts b/packages/opencode/test/catalog.test.ts index 25ec5af0..9a5d4ebe 100644 --- a/packages/opencode/test/catalog.test.ts +++ b/packages/opencode/test/catalog.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path" import { bayKeys, DEFAULT_KEYS, OWN_KEYS } from "@opencode-cockpit/client/catalog" import { DEFAULTS as REVIEW } from "../../review/src/core/config.ts" import { DEFAULTS as SHELL } from "../../shell/src/core/config.ts" -import { KINDS as STATUS } from "../../status/src/core/config.ts" +import { KINDS as STATUS } from "../../status/src/core/config/index.ts" import { DEFAULTS as SUBAGENTS } from "../../subagents/src/core/config.ts" import { DEFAULTS as TRUST } from "../../trust/src/core/config.ts" diff --git a/packages/opencode/test/duplicate.test.ts b/packages/opencode/test/duplicate.test.ts index dc3bea21..9e2b28d2 100644 --- a/packages/opencode/test/duplicate.test.ts +++ b/packages/opencode/test/duplicate.test.ts @@ -55,7 +55,7 @@ describe("configured twice in one OpenCode instance", () => { const config = {} as { command?: Record; skills?: { paths: string[] } } await (standalone as { config?: (c: unknown) => Promise }).config?.(config) await (fromBundle as { config?: (c: unknown) => Promise }).config?.(config) - expect(Object.keys(config.command ?? {}).sort()).toEqual(["cockpit-setup", "status-setup", "statusline"]) + expect(Object.keys(config.command ?? {}).sort()).toEqual(["cockpit-setup", "status-setup"]) expect(config.skills?.paths.map((path) => path.split("/").at(-1)).sort()).toEqual([ "cockpit-setup", "status-setup", diff --git a/packages/review/measure/agent.ts b/packages/review/measure/agent.ts index 00105292..2a7f789a 100644 --- a/packages/review/measure/agent.ts +++ b/packages/review/measure/agent.ts @@ -19,7 +19,7 @@ import { mkdtempSync, realpathSync, rmSync } from "node:fs" import { join, resolve } from "node:path" import { brief, flag, measure, openCode, turn, world } from "../../../scripts/measure-agent.ts" import { reviewPaths } from "../src/core/store/paths.ts" -import { createPersistence } from "../src/core/store/persist.ts" +import { createPersistence } from "../src/io/persist.ts" const oc = openCode() const plugin = resolve(flag("--plugin") ?? join(import.meta.dir, "..")) diff --git a/packages/review/package.json b/packages/review/package.json index d6460429..e79fb7b8 100644 --- a/packages/review/package.json +++ b/packages/review/package.json @@ -31,10 +31,6 @@ "./tui": { "types": "./types/tui/index.d.ts", "default": "./dist/tui/index.js" - }, - "./frame": { - "types": "./types/core/frame.d.ts", - "default": "./dist/core/frame.js" } }, "engines": { diff --git a/packages/review/src/agent/plugin.ts b/packages/review/src/agent/plugin.ts index 94c7f198..5f369e06 100644 --- a/packages/review/src/agent/plugin.ts +++ b/packages/review/src/agent/plugin.ts @@ -9,7 +9,7 @@ import { import { loadReview } from "../core/config.ts" import { waitingOn } from "../core/model/thread.ts" import { reviewPaths } from "../core/store/paths.ts" -import { createPersistence, type Persistence } from "../core/store/persist.ts" +import { createPersistence, type Persistence } from "../io/persist.ts" import { createTools } from "./tools/index.ts" import type { FileContents } from "./tools/shared.ts" diff --git a/packages/review/src/agent/tools/shared.ts b/packages/review/src/agent/tools/shared.ts index 0a8348d4..a5a99a1d 100644 --- a/packages/review/src/agent/tools/shared.ts +++ b/packages/review/src/agent/tools/shared.ts @@ -7,7 +7,7 @@ */ import type { Thread } from "../../core/model/thread.ts" -import type { Persistence } from "../../core/store/persist.ts" +import type { Persistence } from "../../io/persist.ts" /** Where `describe` lives now: submit sends the same prose when the agent has no tools. */ export { describeThread as describe } from "../../core/model/thread.ts" diff --git a/packages/review/src/cli/preview.ts b/packages/review/src/cli/preview.ts index 9f2e2698..633d99a7 100755 --- a/packages/review/src/cli/preview.ts +++ b/packages/review/src/cli/preview.ts @@ -137,7 +137,7 @@ const rows = layout( ...(args.includes("--settings") ? { settings: [ - 'settings: "review.sidebarOrder" is no longer read — run /cockpit-setup', + 'settings: "review.keybinds" should be an object; the default is used', 'settings: "review.source" should be a string; the default is used', ].slice(0, Number(flag("settings")) || 1), } diff --git a/packages/review/src/core/config.ts b/packages/review/src/core/config.ts index 41e03afa..175f4a26 100644 --- a/packages/review/src/core/config.ts +++ b/packages/review/src/core/config.ts @@ -33,7 +33,7 @@ export const DEFAULTS = { variant: "right" as Variant, source: "worktree" as Sou export interface LoadedReview { config: ReviewConfig - /** Settings to fix: an old name, a wrong kind, a value Review does not know. */ + /** Settings to fix: a wrong kind, a value Review does not know. */ notices: SettingsNotice[] } diff --git a/packages/review/src/core/git/changes.ts b/packages/review/src/core/git/changes.ts new file mode 100644 index 00000000..9c85c04e --- /dev/null +++ b/packages/review/src/core/git/changes.ts @@ -0,0 +1,202 @@ +/** + * Changes git knows about, decided: which files changed, how, and what a reviewer should be shown — + * as plain functions of what git said and of the bytes read, so a test can drive them with no + * repository at all. Asking git and reading the files is `io/git.ts`. + */ + +import { countChanges, diffLines } from "../diff/hunks.ts" +import { looksBinary, sniff } from "../image/sniff.ts" +import type { BinarySide, FileChange } from "../model/review.ts" + +export interface GitResult { + files: FileChange[] + /** What could not be read, phrased for a person: shown rather than swallowed. */ + errors: string[] + /** For branch mode: what it compared against. */ + base?: string +} + +/** + * More than this and the pane is not the right tool — a generated folder, a `node_modules` git was + * never told to ignore — and drawing them all would stall the TUI. + * + * Measured on this repository (2026-10-04): a paint takes 4 ms at 200 files, 9 ms at 1,000 and 117 ms + * at 2,000; reading 1,000 files, eight at a time, about two seconds. At 200, a refactor that moved a + * package's files was already cut short, so the cap sits where a paint still fits in a frame. + */ +export const MAX_FILES = 1000 +/** A text file bigger than this is almost certainly not being read line by line. */ +export const MAX_BYTES = 400_000 +/** + * A binary is read whole up to this — its own cap, far above the text one. + * + * Every real screenshot is over 400 KB, and under the text cap each one was skipped with "too large + * to review here": the file most worth a look silently was not in the review. Past this the header + * is still read, so the file is still named, sized and described. + */ +export const MAX_BINARY_BYTES = 32 * 1024 * 1024 + +/** Some or all of a file's bytes, and how many there are in all. */ +export interface Read { + bytes: Uint8Array + size: number + /** False when only the first bytes were read, the file being over the cap. */ + whole: boolean +} + +/** UTF-8, with a byte-order mark kept on both sides alike, so a BOM is never a change of its own. */ +const decoder = new TextDecoder("utf-8", { ignoreBOM: true }) + +const sameBytes = (a: Read | undefined, b: Read | undefined): boolean => { + if (!a || !b) return a === b + if (!a.whole || !b.whole || a.size !== b.size) return false + return Buffer.from(a.bytes.buffer, a.bytes.byteOffset, a.bytes.length).equals(b.bytes) +} + +/** One side of a binary: its size, and what its header says if it is an image. */ +const sideOf = (read: Read | undefined): BinarySide | undefined => { + if (!read) return undefined + const image = sniff(read.bytes) + return { size: read.size, ...(image ? { image } : {}) } +} + +/** + * Both sides read, one file decided: text, binary, too large, or unchanged. + * + * Binary is decided once, for the pair — a file that became binary, or stopped being, is not a text + * diff on either side. + */ +export function decide( + path: string, + before: Read | undefined, + after: Read | undefined, + change: FileChange["change"], + from: string | undefined, + revision: string, +): { file?: FileChange; error?: string } { + if (sameBytes(before, after) && change !== "renamed") return {} + if ((before && looksBinary(before.bytes)) || (after && looksBinary(after.bytes))) { + const sides = { before: sideOf(before), after: sideOf(after) } + return { + file: { + ...fileOf(path, "", "", change, from), + binary: { + ...(sides.before ? { before: sides.before } : {}), + ...(sides.after ? { after: sides.after } : {}), + ...(before ? { revision } : {}), + }, + }, + } + } + if ((after && !after.whole) || (after && after.size > MAX_BYTES) || (before && !before.whole)) + return { error: `${path}: too large to review here` } + return { + file: fileOf( + path, + before ? decoder.decode(before.bytes) : "", + after ? decoder.decode(after.bytes) : "", + change, + from, + ), + } +} + +/** What a two-letter `git status` code says happened to the file itself. */ +export function statusChange(code: string, from: string | undefined): FileChange["change"] { + if (/[RC]/.test(code)) return from && code.includes("R") ? "renamed" : "added" + if (code.includes("?") || code.includes("A")) return "added" + if (code.includes("D")) return "deleted" + return undefined +} + +/** One file, with its change and where it came from only when there is something to say. */ +const fileOf = ( + path: string, + before: string, + after: string, + change: FileChange["change"], + from: string | undefined, +): FileChange => ({ + path, + before, + after, + additions: 0, + deletions: 0, + ...(change ? { change } : {}), + ...(change === "renamed" && from ? { from } : {}), +}) + +/** + * `git diff --name-status -z` as path → what happened to it. + * + * Each record is a status, then one path — or two, old then new, for a rename or a copy. A rename + * matters most: without it the old path read as deleted and the new one as created, and the + * reviewer read the whole file twice to find the line that changed. + */ +export function nameStatus(out: string): Map { + const found = new Map() + const fields = out.split("\0") + let index = 0 + while (index < fields.length) { + const code = fields[index++] ?? "" + if (!code) continue + if (code.startsWith("R") || code.startsWith("C")) { + const from = fields[index++] ?? "" + const path = fields[index++] ?? "" + if (path) found.set(path, code.startsWith("R") ? { change: "renamed", from } : { change: "added" }) + continue + } + const path = fields[index++] ?? "" + if (!path) continue + found.set(path, { change: code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : undefined }) + } + return found +} + +/** A branch this one could be compared against, and how far apart the two are. */ +export interface BaseCandidate { + ref: string + /** Commits on HEAD that `ref` lacks — what a pull request into `ref` would carry. */ + own: number + /** Commits on `ref` that HEAD lacks — how far `ref` has moved on since the fork. */ + other: number +} + +/** The names a repository's main line usually goes by: what a branch is compared with when nothing nearer is found. */ +export const USUAL_BASES = ["main", "master", "origin/main", "origin/master"] + +/** + * The branch this one most likely targets: the nearest parent. + * + * On `main ← feature ← X`, comparing X with `main` also shows every commit of `feature` — the + * "everything mixed together" a stacked branch got before. The nearest parent is the one X has the + * fewest commits beyond, which is `feature`, and a PR from X into `feature` shows exactly that. + * The same rule drops a stale local `main` for a fresher `origin/main` without special-casing it. + */ +export function pickBase(candidates: readonly BaseCandidate[], preferred?: string): string | undefined { + const usual = (ref: string) => { + if (preferred && (ref === preferred || ref.endsWith(`/${preferred}`))) return 0 + return USUAL_BASES.includes(ref) ? 1 : 2 + } + const ranked = [...candidates].sort( + (a, b) => + a.own - b.own || + // Two bases at the same fork point give the same diff; the one a PR would target reads best. + usual(a.ref) - usual(b.ref) || + // Then local over remote, so the label says `feature` rather than `origin/feature`. + Number(a.ref.includes("/")) - Number(b.ref.includes("/")) || + a.other - b.other, + ) + return ranked[0]?.ref +} + +/** + * Fill in each file's counts from the same diff the pane will draw. Git could report them, but two + * sources for one number is how a list ends up saying +12 above a hunk showing eleven lines. + */ +export function withCounts(files: readonly FileChange[]): FileChange[] { + /** A binary has no lines to count; its card says what changed instead. */ + return files.map((file) => + file.binary ? file : { ...file, ...countChanges(diffLines(file.before, file.after)) }, + ) +} diff --git a/packages/review/src/core/view/chrome.ts b/packages/review/src/core/view/chrome.ts index 516f0b27..93544456 100644 --- a/packages/review/src/core/view/chrome.ts +++ b/packages/review/src/core/view/chrome.ts @@ -3,7 +3,8 @@ * * Fixed height, always: the body is measured from them, so a header or footer that grew by a row * would shove the diff about every time something happened. Everything either of them has to say — - * trouble, the performance numbers, a selection — *replaces* what is there rather than adding to it. + * trouble, the performance numbers, a selection — *replaces* what is there rather than adding to it + * (trouble the footer's rule, so its keys stay). */ import { closeHint, fitHints, GLYPH, HINT_GAP, type Hint, warnRows } from "@opencode-cockpit/client/design" @@ -162,33 +163,40 @@ export function footerRows( const rule: Row = { runs: [{ text: "─".repeat(width), tone: "border" }] } /** - * One line, and a queue for it: trouble, then numbers, then the keys. + * Two rows, exactly, however much there is to say: the body's height is measured from them, so a + * footer that grew would push the diff about every time something went wrong. * - * The footer stays exactly two rows however much it has to say, because the body's height is measured - * from it — a footer that grew would push the diff about every time something went wrong. + * Trouble takes the rule's row, and the keys keep theirs. It used to take the keys' row — fine for a + * message that passes, but "more than N files changed" stays for the whole review, and with it every + * key, the way out included, was gone. */ - if (state.notice) { - const trouble: Run[] = [ - { text: ` ${GLYPH.warn} `, tone: "error", bold: true }, - { text: state.notice, tone: "error" }, - ] - return [rule, { runs: clipRuns(trouble, width, "none") }] - } - if (state.stats) return [rule, { runs: clipRuns([...state.stats], width, "none") }] + const top: Row = state.notice + ? { + runs: clipRuns( + [ + { text: ` ${GLYPH.warn} `, tone: "error", bold: true }, + { text: state.notice, tone: "error" }, + ], + width, + "none", + ), + } + : rule + if (state.stats) return [top, { runs: clipRuns([...state.stats], width, "none") }] /** On the keys screen the only key worth a row is the way back to the review. */ - if (state.keys) return [rule, { runs: keyRow([], [closeHint("Hide Keys")], width) }] + if (state.keys) return [top, { runs: keyRow([], [closeHint("Hide Keys")], width) }] /** * Nothing to review: only the keys that act. Moving, noting and marking have nothing to land on, * and a row that offers them anyway teaches that its keys do not always mean anything. */ - if (empty) return [rule, { runs: keyRow([], [...sources, KEYS, close], width) }] + if (empty) return [top, { runs: keyRow([], [...sources, KEYS, close], width) }] /** A selection says how many lines it holds before the keys that act on them. */ const lead: Run[] = selecting ? [{ text: `${lines} line${lines === 1 ? "" : "s"}`, tone: "accent", bold: true }] : [] return [ - rule, + top, { runs: keyRow( lead, diff --git a/packages/review/src/core/view/state.ts b/packages/review/src/core/view/state.ts index c03b954f..523d1ad0 100644 --- a/packages/review/src/core/view/state.ts +++ b/packages/review/src/core/view/state.ts @@ -84,7 +84,7 @@ export interface ViewState { */ notice?: string /** - * Settings Review does not read — an old name, a value of the wrong kind — each a sentence + * Settings Review does not read — a value of the wrong kind — each a sentence * (`noticeText`). Review has no sidebar block to say them in, so the pane does: one `!` row in * place of the header's rule, for as long as the file is wrong, not a toast gone in ten seconds. */ diff --git a/packages/review/src/core/git/sources.ts b/packages/review/src/io/git.ts similarity index 56% rename from packages/review/src/core/git/sources.ts rename to packages/review/src/io/git.ts index c634bea0..57e9bbbb 100644 --- a/packages/review/src/core/git/sources.ts +++ b/packages/review/src/io/git.ts @@ -6,35 +6,27 @@ * that already exists, and the pane has nothing to show. These are the other two questions a * reviewer asks: what have I not committed, and what does this branch change. * - * Everything here is a plain function of `git` output so it can be driven from a test with a real - * repository, rather than mocked into agreeing with itself. + * This half asks git and reads the files; what the answers mean is `core/git/changes.ts`. Every + * function takes its git runner, so a test drives it with a real repository rather than mocking git + * into agreeing with itself. */ import { join } from "node:path" -import { countChanges, diffLines } from "../diff/hunks.ts" -import { HEADER_BYTES, looksBinary, sniff } from "../image/sniff.ts" -import type { BinarySide, FileChange } from "../model/review.ts" - -export interface GitResult { - files: FileChange[] - /** What could not be read, phrased for a person: shown rather than swallowed. */ - errors: string[] - /** For branch mode: what it compared against. */ - base?: string -} - -/** More than this and the pane is not the right tool — and reading them all would stall the TUI. */ -const MAX_FILES = 200 -/** A text file bigger than this is almost certainly not being read line by line. */ -const MAX_BYTES = 400_000 -/** - * A binary is read whole up to this — its own cap, far above the text one. - * - * Every real screenshot is over 400 KB, and under the text cap each one was skipped with "too large - * to review here": the file most worth a look silently was not in the review. Past this the header - * is still read, so the file is still named, sized and described. - */ -export const MAX_BINARY_BYTES = 32 * 1024 * 1024 +import { + type BaseCandidate, + decide, + type GitResult, + MAX_BINARY_BYTES, + MAX_BYTES, + MAX_FILES, + nameStatus, + pickBase, + type Read, + statusChange, + USUAL_BASES, +} from "../core/git/changes.ts" +import { HEADER_BYTES, looksBinary } from "../core/image/sniff.ts" +import type { FileChange } from "../core/model/review.ts" export type RunGit = (args: string[], cwd: string) => Promise<{ ok: boolean; out: string }> @@ -63,14 +55,6 @@ export async function headOf(cwd: string, git: RunGit = runGit): Promise 0 ? head : undefined } -/** Some or all of a file's bytes, and how many there are in all. */ -export interface Read { - bytes: Uint8Array - size: number - /** False when only the first bytes were read, the file being over the cap. */ - whole: boolean -} - const joined = (chunks: readonly Uint8Array[], total: number): Uint8Array => { if (chunks.length === 1) return chunks[0] as Uint8Array const out = new Uint8Array(total) @@ -152,63 +136,6 @@ export async function readWorking(cwd: string, path: string): Promise { - if (!a || !b) return a === b - if (!a.whole || !b.whole || a.size !== b.size) return false - return Buffer.from(a.bytes.buffer, a.bytes.byteOffset, a.bytes.length).equals(b.bytes) -} - -/** One side of a binary: its size, and what its header says if it is an image. */ -const sideOf = (read: Read | undefined): BinarySide | undefined => { - if (!read) return undefined - const image = sniff(read.bytes) - return { size: read.size, ...(image ? { image } : {}) } -} - -/** - * Both sides read, one file decided: text, binary, too large, or unchanged. - * - * Binary is decided once, for the pair — a file that became binary, or stopped being, is not a text - * diff on either side. - */ -export function decide( - path: string, - before: Read | undefined, - after: Read | undefined, - change: FileChange["change"], - from: string | undefined, - revision: string, -): { file?: FileChange; error?: string } { - if (sameBytes(before, after) && change !== "renamed") return {} - if ((before && looksBinary(before.bytes)) || (after && looksBinary(after.bytes))) { - const sides = { before: sideOf(before), after: sideOf(after) } - return { - file: { - ...fileOf(path, "", "", change, from), - binary: { - ...(sides.before ? { before: sides.before } : {}), - ...(sides.after ? { after: sides.after } : {}), - ...(before ? { revision } : {}), - }, - }, - } - } - if ((after && !after.whole) || (after && after.size > MAX_BYTES) || (before && !before.whole)) - return { error: `${path}: too large to review here` } - return { - file: fileOf( - path, - before ? decoder.decode(before.bytes) : "", - after ? decoder.decode(after.bytes) : "", - change, - from, - ), - } -} - /** * Uncommitted work: everything `git status` reports, including files git has never seen. * @@ -219,98 +146,55 @@ export async function worktreeChanges(cwd: string, git: RunGit = runGit): Promis const status = await git(["status", "--porcelain=v1", "-z", "--untracked-files=all"], cwd) if (!status.ok) return { files: [], errors: ["not a git repository"] } - const files: FileChange[] = [] - const errors: string[] = [] + const listed: { code: string; path: string; from: string | undefined }[] = [] // NUL-separated so paths with spaces or quotes need no unescaping. const entries = status.out.split("\0") for (let index = 0; index < entries.length; index++) { const entry = entries[index] as string if (entry.length < 4) continue const code = entry.slice(0, 2) - const path = entry.slice(3) /** * A rename's "-z" form puts the old path in the next entry, with no code of its own. Read as an * entry, it became a file called whatever followed its first three characters. */ const from = /[RC]/.test(code) ? entries[++index] : undefined - if (files.length >= MAX_FILES) { - errors.push(`more than ${MAX_FILES} files changed; showing the first ${MAX_FILES}`) - break - } - const change = statusChange(code, from) + listed.push({ code, path: entry.slice(3), from }) + } + return decideAll(listed, async ({ code, path, from }) => { const before = code.includes("?") || code.includes("C") ? undefined : await readBlob(cwd, "HEAD", from ?? path) - const { file, error } = decide(path, before, await readWorking(cwd, path), change, from, "HEAD") - if (error) errors.push(error) - if (file) files.push(file) - } - return { files, errors } -} - -/** What a two-letter `git status` code says happened to the file itself. */ -function statusChange(code: string, from: string | undefined): FileChange["change"] { - if (/[RC]/.test(code)) return from && code.includes("R") ? "renamed" : "added" - if (code.includes("?") || code.includes("A")) return "added" - if (code.includes("D")) return "deleted" - return undefined + return decide(path, before, await readWorking(cwd, path), statusChange(code, from), from, "HEAD") + }) } -/** One file, with its change and where it came from only when there is something to say. */ -const fileOf = ( - path: string, - before: string, - after: string, - change: FileChange["change"], - from: string | undefined, -): FileChange => ({ - path, - before, - after, - additions: 0, - deletions: 0, - ...(change ? { change } : {}), - ...(change === "renamed" && from ? { from } : {}), -}) +/** Files read at once. One at a time, a two-hundred-file branch took 1.3 s to open; git is happy with more. */ +const AT_ONCE = 8 /** - * `git diff --name-status -z` as path → what happened to it. - * - * Each record is a status, then one path — or two, old then new, for a rename or a copy. A rename - * matters most: without it the old path read as deleted and the new one as created, and the - * reviewer read the whole file twice to find the line that changed. + * Every file decided, in the order given, `AT_ONCE` at a time, until `MAX_FILES` of them are files to + * show — what was left out is said, in `errors`, like anything else that could not be shown. */ -function nameStatus(out: string): Map { - const found = new Map() - const fields = out.split("\0") - let index = 0 - while (index < fields.length) { - const code = fields[index++] ?? "" - if (!code) continue - if (code.startsWith("R") || code.startsWith("C")) { - const from = fields[index++] ?? "" - const path = fields[index++] ?? "" - if (path) found.set(path, code.startsWith("R") ? { change: "renamed", from } : { change: "added" }) - continue +async function decideAll( + items: readonly T[], + one: (item: T) => Promise<{ file?: FileChange; error?: string }>, +): Promise<{ files: FileChange[]; errors: string[] }> { + const files: FileChange[] = [] + const errors: string[] = [] + for (let at = 0; at < items.length; at += AT_ONCE) { + for (const { file, error } of await Promise.all(items.slice(at, at + AT_ONCE).map(one))) { + if (files.length >= MAX_FILES) { + errors.push(`more than ${MAX_FILES} files changed; showing the first ${MAX_FILES}`) + return { files, errors } + } + if (error) errors.push(error) + if (file) files.push(file) } - const path = fields[index++] ?? "" - if (!path) continue - found.set(path, { change: code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : undefined }) } - return found -} - -/** A branch this one could be compared against, and how far apart the two are. */ -export interface BaseCandidate { - ref: string - /** Commits on HEAD that `ref` lacks — what a pull request into `ref` would carry. */ - own: number - /** Commits on `ref` that HEAD lacks — how far `ref` has moved on since the fork. */ - other: number + return { files, errors } } /** Enough to cover every branch anyone is stacking on, few enough to stay one quick burst of git. */ const MAX_CANDIDATES = 60 -const USUAL_BASES = ["main", "master", "origin/main", "origin/master"] /** * Every branch HEAD could have come from, measured. @@ -358,31 +242,6 @@ export async function baseCandidates(cwd: string, git: RunGit = runGit): Promise return measured.filter((each): each is BaseCandidate => each !== undefined) } -/** - * The branch this one most likely targets: the nearest parent. - * - * On `main ← feature ← X`, comparing X with `main` also shows every commit of `feature` — the - * "everything mixed together" a stacked branch got before. The nearest parent is the one X has the - * fewest commits beyond, which is `feature`, and a PR from X into `feature` shows exactly that. - * The same rule drops a stale local `main` for a fresher `origin/main` without special-casing it. - */ -export function pickBase(candidates: readonly BaseCandidate[], preferred?: string): string | undefined { - const usual = (ref: string) => { - if (preferred && (ref === preferred || ref.endsWith(`/${preferred}`))) return 0 - return USUAL_BASES.includes(ref) ? 1 : 2 - } - const ranked = [...candidates].sort( - (a, b) => - a.own - b.own || - // Two bases at the same fork point give the same diff; the one a PR would target reads best. - usual(a.ref) - usual(b.ref) || - // Then local over remote, so the label says `feature` rather than `origin/feature`. - Number(a.ref.includes("/")) - Number(b.ref.includes("/")) || - a.other - b.other, - ) - return ranked[0]?.ref -} - /** * Whether HEAD is the branch everything else targets. * @@ -444,30 +303,10 @@ export async function branchChanges( for (const path of fresh) if (!changed.has(path)) changed.set(path, { change: "added" }) const paths = [...changed.keys()] - const files: FileChange[] = [] - const errors: string[] = [] - for (const path of paths) { - if (!path) continue - if (files.length >= MAX_FILES) { - errors.push(`more than ${MAX_FILES} files changed; showing the first ${MAX_FILES}`) - break - } + const { files, errors } = await decideAll(paths.filter(Boolean), async (path) => { const { change, from } = changed.get(path) ?? { change: undefined } const before = change === "added" ? undefined : await readBlob(cwd, fork, from ?? path) - const { file, error } = decide(path, before, await readWorking(cwd, path), change, from, fork) - if (error) errors.push(error) - if (file) files.push(file) - } + return decide(path, before, await readWorking(cwd, path), change, from, fork) + }) return { files, errors, base: against } } - -/** - * Fill in each file's counts from the same diff the pane will draw. Git could report them, but two - * sources for one number is how a list ends up saying +12 above a hunk showing eleven lines. - */ -export function withCounts(files: readonly FileChange[]): FileChange[] { - /** A binary has no lines to count; its card says what changed instead. */ - return files.map((file) => - file.binary ? file : { ...file, ...countChanges(diffLines(file.before, file.after)) }, - ) -} diff --git a/packages/review/src/core/store/persist.ts b/packages/review/src/io/persist.ts similarity index 95% rename from packages/review/src/core/store/persist.ts rename to packages/review/src/io/persist.ts index f9f2ef47..5950dc6b 100644 --- a/packages/review/src/core/store/persist.ts +++ b/packages/review/src/io/persist.ts @@ -13,9 +13,9 @@ import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises" import { join } from "node:path" -import type { Thread } from "../model/thread.ts" -import { decode, encode } from "./format.ts" -import type { ReviewPaths } from "./paths.ts" +import type { Thread } from "../core/model/thread.ts" +import { decode, encode } from "../core/store/format.ts" +import type { ReviewPaths } from "../core/store/paths.ts" export interface Persistence { /** Every thread on disk, oldest first. Missing directory means an empty review, not an error. */ diff --git a/packages/review/src/core/viewer.ts b/packages/review/src/io/viewer.ts similarity index 99% rename from packages/review/src/core/viewer.ts rename to packages/review/src/io/viewer.ts index 75552f81..7326c9a2 100644 --- a/packages/review/src/core/viewer.ts +++ b/packages/review/src/io/viewer.ts @@ -16,7 +16,7 @@ import { mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { basename, extname, join } from "node:path" import { isRunnable, type OpenerWhere, openerFor, openerName } from "@opencode-cockpit/client/opener" -import type { FileChange } from "./model/review.ts" +import type { FileChange } from "../core/model/review.ts" /** A launched process, as far as this file cares: it can fail to start, and it is not waited on. */ export interface Launched { diff --git a/packages/review/src/tui/data/changes.ts b/packages/review/src/tui/data/changes.ts index 0c9486fa..8c068a59 100644 --- a/packages/review/src/tui/data/changes.ts +++ b/packages/review/src/tui/data/changes.ts @@ -15,8 +15,9 @@ */ import type { Host } from "@opencode-cockpit/client/host" -import { branchChanges, withCounts, worktreeChanges } from "../../core/git/sources.ts" +import { withCounts } from "../../core/git/changes.ts" import type { ChangeSet, Source } from "../../core/model/review.ts" +import { branchChanges, worktreeChanges } from "../../io/git.ts" export interface Loaded { changes: ChangeSet diff --git a/packages/review/src/tui/index.tsx b/packages/review/src/tui/index.tsx index 2671a849..6bb2bdbc 100644 --- a/packages/review/src/tui/index.tsx +++ b/packages/review/src/tui/index.tsx @@ -6,16 +6,16 @@ import { bindingLookup, dualTui, type Host } from "@opencode-cockpit/client/host import { noticeText } from "@opencode-cockpit/client/settings" import type { BoxRenderable } from "@opentui/core" import { loadReview, type ReviewConfig } from "../core/config.ts" -import { headOf, readBlob, readWorking } from "../core/git/sources.ts" import { createLooks } from "../core/image/looks.ts" import type { Source } from "../core/model/review.ts" import { matchesBinding, paletteBindings } from "../core/palette.ts" import { metrics } from "../core/perf.ts" import { reviewPaths } from "../core/store/paths.ts" -import { createPersistence } from "../core/store/persist.ts" import { frameBounds, VARIANTS } from "../core/view/frame.ts" import { FOOTER_ROWS, HEADER_ROWS } from "../core/view/geometry.ts" -import { createViewer, systemExists, systemSpawn, systemWhich } from "../core/viewer.ts" +import { headOf, readBlob, readWorking } from "../io/git.ts" +import { createPersistence } from "../io/persist.ts" +import { createViewer, systemExists, systemSpawn, systemWhich } from "../io/viewer.ts" import { createStore } from "./data/changes.ts" import { createActions } from "./panel/actions.ts" import { paneLayer } from "./panel/keys.ts" diff --git a/packages/review/src/tui/panel/actions.ts b/packages/review/src/tui/panel/actions.ts index 3f098044..f467621a 100644 --- a/packages/review/src/tui/panel/actions.ts +++ b/packages/review/src/tui/panel/actions.ts @@ -8,43 +8,25 @@ * * Nothing here knows which key it is bound to. That table is `keys.ts`, and keeping it separate is what * lets a key change without touching what it does. + * + * Moving about is `navigation.ts`, what acts on threads and files `threads.ts`; this file adds the + * rest — sources, the base, stats, the viewer, the keys screen — and is the one table of verbs. */ import type { Host } from "@opencode-cockpit/client/host" -import { baseCandidates } from "../../core/git/sources.ts" import type { Guard } from "../../core/guard.ts" -import { - drop, - filesElsewhere, - isRead, - open as openThread, - type Source, - say, - threadOn, - threadsFor, - threadsOnLine, - toggleRead, -} from "../../core/model/review.ts" -import { submission, toolsConfigured, waitingOnAgent } from "../../core/model/submit.ts" -import type { Persistence } from "../../core/store/persist.ts" -import { mostShift } from "../../core/view/diff.ts" -import { streamWidth } from "../../core/view/layout.ts" -import { keepCursorVisible, navigableRows } from "../../core/view/list.ts" +import type { Source } from "../../core/model/review.ts" +import { navigableRows } from "../../core/view/list.ts" import type { Viewport } from "../../core/view/state.ts" -import { - cursorRow, - segmentAt, - stopsOf, - streamOf, - streamOrder, - streamScroll, - streamWindow, -} from "../../core/view/stream.ts" -import type { Viewer } from "../../core/viewer.ts" +import { baseCandidates } from "../../io/git.ts" +import type { Persistence } from "../../io/persist.ts" +import type { Viewer } from "../../io/viewer.ts" import type { Store } from "../data/changes.ts" -import { askForBase, askForNote, noteFields, replyFields, submitFields } from "../view/dialogs.tsx" +import { askForBase } from "../view/dialogs.tsx" +import { createNavigation } from "./navigation.ts" import type { Queries } from "./queries.ts" import type { Surface } from "./surface.ts" +import { createThreads } from "./threads.ts" export interface ActionDeps { api: Host @@ -118,194 +100,25 @@ export interface Actions { } export function createActions(deps: ActionDeps): Actions { - const { api, surface, store, guard, queries, draw, keep, listHeight } = deps - const persistence = deps.persistence + const { api, surface, store, guard, queries, draw } = deps const takeKeys = deps.takeKeys const dropKeys = deps.dropKeys const close = deps.close - const REVIEW_PACKAGE = deps.reviewPackage const SOURCES = deps.sources - - /** - * Movement follows the rows on screen, not the change set's own file order. - * - * Those are two different orders — the screen shows a grouped tree, the change set is however git - * listed things — and driving the cursor from the second while looking at the first is why it - * appeared to jump at random. - */ - /** The view the list is actually drawn from, including files that have comments but no diff. */ - const listState = () => ({ - ...surface.view, - elsewhere: filesElsewhere(surface.review, store.current().changes), - }) - - const moveFiles = (delta: number) => { - const rows = navigableRows(store.current().changes, listState()) - if (rows.length === 0) return - const at = surface.view.cursor ? rows.findIndex((row) => row.path === surface.view.cursor) : 0 - const next = rows[Math.max(0, Math.min(rows.length - 1, (at < 0 ? 0 : at) + delta))] - if (!next) return - surface.view = { ...surface.view, cursor: next.path } - if (next.kind === "file") - surface.view = { - ...surface.view, - file: next.path, - scroll: undefined, - line: undefined, - anchor: undefined, - } - surface.view = { - ...surface.view, - listOffset: keepCursorVisible(store.current().changes, listState(), listHeight()), - } - draw() - } - - /** The stream as the paint will draw it, for the keys to reason about. */ - const width = () => streamWidth(deps.viewport(), store.current().changes) - const streamNow = () => streamOf(store.current().changes, surface.review, listState(), width()) - - /** - * Scrolls just enough to keep the cursor in view, and keeps the file list pointing at its file. - * - * One row of margin at the top, because the pinned heading covers the first row of the pane: a - * cursor on that row would be hidden under the name of its own file. - */ - const follow = () => { - const stream = streamNow() - const height = listHeight() - const scroll = streamScroll(stream, surface.view, height) - const row = cursorRow(stream, surface.review, surface.view, width()) - let next = scroll - if (row !== undefined) { - const top = surface.view.line === undefined ? row : row - 1 - if (top < scroll) next = top - else if (row > scroll + height - 3) next = row - height + 3 - } - surface.view = { ...surface.view, scroll: Math.max(0, next), cursor: surface.view.file } - surface.view = { - ...surface.view, - listOffset: keepCursorVisible(store.current().changes, listState(), listHeight()), - } - } - - /** - * In the diff, the cursor walks the stream: a file's heading, then its lines, then the next file's - * heading. A folded file is one stop — its heading — so viewed files are stepped over in one press. - */ - const moveLines = (delta: number) => { - const { segments } = streamNow() - if (segments.length === 0) return - let index = Math.max( - 0, - segments.findIndex((segment) => segment.path === surface.view.file), - ) - let segment = segments[index] - if (!segment) return - let stops = stopsOf(segment, surface.review, surface.view, width()) - let at = Math.max(0, stops.indexOf(surface.view.line)) - const step = Math.sign(delta) - for (let left = Math.abs(delta); left > 0; left--) { - if (at + step >= 0 && at + step < stops.length) { - at += step - continue - } - const neighbour = segments[index + step] - if (!neighbour) break - index += step - segment = neighbour - stops = stopsOf(segment, surface.review, surface.view, width()) - at = step > 0 ? 0 : stops.length - 1 - } - const moved = segment.path !== surface.view.file - surface.view = { - ...surface.view, - file: segment.path, - line: stops[at], - thread: undefined, - /** A selection is inside one file: leaving the file lets go of it. */ - ...(moved ? { anchor: undefined } : {}), - } - follow() - draw() - } - - const move = (delta: number) => (surface.view.pane === "diff" ? moveLines(delta) : moveFiles(delta)) - - /** - * Scrolling moves the view, and the cursor only if the view leaves it behind — then it lands on the - * first thing in sight, so the next `j` carries on from what you are looking at rather than jumping - * back to where you were. - */ - /** - * Sideways through the code of the file under the cursor, as far as its longest line. A line that - * ran past the pane used to end in "…" with no way to read the rest. - */ - const pan = (delta: number) => { - const file = store.current().changes.files.find((change) => change.path === surface.view.file) - const most = mostShift(file, width()) - const shift = Math.max(0, Math.min(most, (surface.view.shift ?? 0) + delta)) - if (shift === (surface.view.shift ?? 0)) return - surface.view = { ...surface.view, shift, pane: "diff" } - draw() - } - - const scroll = (delta: number) => { - const stream = streamNow() - const height = listHeight() - const next = Math.max(0, streamScroll(stream, surface.view, height) + delta) - surface.view = { - ...surface.view, - scroll: streamScroll(stream, { ...surface.view, scroll: next }, height), - } - const at = surface.view.scroll ?? 0 - const row = cursorRow(stream, surface.review, surface.view, width()) - if (row === undefined || row < at || row >= at + height) { - const shown = streamWindow(store.current().changes, surface.review, listState(), width(), height) - /** Past the pinned heading, onto the first line of code in sight, if there is one. */ - const landing = shown.slice(1).find((each) => each.line !== undefined || each.header) ?? shown[0] - const file = landing?.file ?? segmentAt(stream, at)?.path - if (file) { - surface.view = { - ...surface.view, - file, - cursor: file, - line: landing?.header ? undefined : landing?.line, - anchor: undefined, - } - } - } - /** The file list follows the diff: the file you are reading stays in sight on the left too. */ - surface.view = { - ...surface.view, - cursor: surface.view.file, - listOffset: keepCursorVisible( - store.current().changes, - { ...listState(), cursor: surface.view.file }, - height, - ), - } - draw() - } - - /** - * Out of the diff and back to the list. - * - * `enter` takes you into a file, so something has to take you out, and `tab` alone is a thing you - * have to be told. `h` and `left` are where a hand already is after `j`/`k`. - */ - const toFiles = () => { - surface.view = { ...surface.view, pane: "files", anchor: undefined } - draw() - } - - /** Two panes, one keyboard: `tab` says which one `j` is talking to. */ - const swap = () => { - surface.view = { ...surface.view, pane: surface.view.pane === "diff" ? "files" : "diff" } - if (surface.view.pane === "diff" && surface.view.file === undefined) - surface.view = { ...surface.view, file: streamNow().segments[0]?.path, scroll: undefined } - draw() - } + const navigation = createNavigation(deps) + const { listState, move, scroll, pan, toFiles, swap } = navigation + const threads = createThreads(deps, navigation) + const { + comment, + selectRange, + replyHere, + submit, + uncomment, + markRead, + toggleViewed, + toggleFold, + commentFile, + } = threads /** Enter on a folder folds it; on a file it opens it and moves you into the diff. */ const enter = () => { @@ -324,324 +137,6 @@ export function createActions(deps: ActionDeps): Actions { draw() } - /** - * One key, and it comments on whatever the cursor is on: the selected lines, the line under the - * cursor, or — in the file list — the file as a whole. Two keys for the same intention is two - * keys to remember for no reason. - */ - const comment = (whole = false) => { - const file = surface.view.file - if (!file) return - /** On a file's heading there is no line: a note there is about the file. */ - const onFile = whole || surface.view.pane === "files" || surface.view.line === undefined - const from = onFile - ? undefined - : Math.min(surface.view.anchor ?? surface.view.line ?? 0, surface.view.line ?? 0) - const to = onFile - ? undefined - : Math.max(surface.view.anchor ?? surface.view.line ?? 0, surface.view.line ?? 0) - /** - * The thread already here: written on exactly these lines, or — for one line — drawn on it now, - * its code having moved since. Missing the second made a reply a new thread above the old one. - */ - const existing = - threadOn(surface.review, file, from, to) ?? - (!onFile && from === to && to !== undefined - ? threadsOnLine(surface.review, file, to, queries.contents().get(file))[0] - : undefined) - - /** - * The review's keys are a *global* layer, so they are still live while a dialog is open — which - * means typing a note would trigger them and `escape` would close the review out from under the - * prompt. They go away for as long as the dialog is up. - */ - dropKeys() - - askForNote( - api, - noteFields(file, { - ...(from === undefined ? {} : { from }), - ...(to === undefined ? {} : { to }), - ...(existing ? { existing } : {}), - ...(queries.quoteOf(file, from, to) ? { quoted: queries.quoteOf(file, from, to) } : {}), - }), - (body) => { - const at = Date.now() - surface.review = existing - ? say(surface.review, existing.id, { author: "you", body, at }) - : openThread( - surface.review, - { - file, - ...(from === undefined ? {} : { line: from }), - ...(to !== undefined && from !== undefined && to > from ? { through: to } : {}), - ...(queries.quoteOf(file, from, to) ? { quoted: queries.quoteOf(file, from, to) } : {}), - /** What the file was at when this was written. Provenance, never the anchor. */ - ...(deps.head() ? { commit: deps.head() } : {}), - }, - body, - "you", - at, - ) - /** Written as it is said. A note you have to remember to save is a note you will lose. */ - keep(existing?.id ?? threadOn(surface.review, file, from, to)?.id) - surface.view = { ...surface.view, anchor: undefined } - draw() - }, - () => { - /** However it closed, the keys come back and the pane is drawn again. */ - if (surface.open) takeKeys() - draw() - }, - ) - } - - /** Starts a selection, or throws one away. The moving end is the cursor. */ - const selectRange = () => { - surface.view = - surface.view.anchor === undefined - ? { ...surface.view, anchor: surface.view.line } - : { ...surface.view, anchor: undefined } - draw() - } - - /** - * The thread under the cursor, which is what `r`, `o` and `x` act on. - * - * Derived rather than navigated: a thread lives on a line, so standing on the line is standing - * on the thread. A second cursor for threads would be a second thing to move and to explain. - */ - const hereThread = () => { - if (!surface.view.file) return undefined - if (surface.view.pane === "files") - return threadsFor(surface.review, surface.view.file).find((each) => each.line === undefined) - /** Placed by the file as it reads now, as the diff draws it (see `hereThreadId`). */ - return surface.view.line === undefined - ? threadsFor(surface.review, surface.view.file).find((each) => each.line === undefined) - : threadsOnLine( - surface.review, - surface.view.file, - surface.view.line, - queries.contents().get(surface.view.file), - )[0] - } - - /** - * Answering back. - * - * Saying something on a resolved thread reopens it, which is the honest meaning of a reply: you - * have read the answer and it is not finished. Accepting needs no key — a thread you say nothing - * more about is one you accepted. - */ - const replyHere = () => { - const thread = hereThread() - if (!thread) return comment() - dropKeys() - askForNote( - api, - replyFields(thread), - (body) => { - surface.review = say(surface.review, thread.id, { author: "you", body, at: Date.now() }) - keep(thread.id) - draw() - }, - () => { - if (surface.open) takeKeys() - draw() - }, - ) - } - - /** - * Whether the agent has the review tools. - * - * Read from the user's own plugin list rather than guessed: only this half can see it, and telling - * an agent to "run review_list" when it has no such tool is worse than sending it too much. When - * the server half is not installed the whole review travels as prose instead. - */ - const hasTools = (): boolean => - /** On OpenCode 2 the tools arrive with this package's own server half, which v2 always loads. */ - api.v1 ? toolsConfigured(api.v1.state.config.plugin, REVIEW_PACKAGE) : true - - /** The conversation this review would be handed to. */ - const sessionID = (): string | undefined => { - const route = api.route.current - return route.name === "session" ? (route.params as { sessionID?: string }).sessionID : undefined - } - - /** - * Handing the review over. - * - * The end of reading, so it closes the pane: what happens next happens in the conversation, and - * leaving a review open over the answer to it is leaving the screen on the wrong thing. - */ - const submit = () => { - /** - * The files go in from the first call, so what the dialog counts is what will be sent. - * - * They were left out here once, which meant the count offered and the count handed over could - * differ by however many comments had gone outdated since they were written. - */ - const ready = submission(surface.review, { - label: queries.label(), - tools: hasTools(), - files: queries.contents(), - }) - if (!ready) { - /** - * Three ways to have nothing to submit, and they need three different sentences. - * - * "Every comment has been answered" was said in all three, including to someone who had not - * written a comment yet — which reads as the feature being broken rather than unused. - */ - const waiting = waitingOnAgent(surface.review) - const message = - surface.review.threads.length === 0 - ? "Nothing to submit yet — press c on a line to leave a comment." - : waiting.length === 0 - ? "Nothing to hand over — every comment has been answered." - : waiting.length === 1 - ? "Nothing to hand over — the code the last comment is about is gone." - : `Nothing to hand over — the code all ${waiting.length} comments are about is gone.` - api.ui.toast({ variant: "info", title: "Review", message }) - return - } - const id = sessionID() - if (!id) { - api.ui.toast({ - variant: "error", - title: "Review", - message: "Open a conversation to submit a review to.", - }) - return - } - - dropKeys() - askForNote( - api, - submitFields(queries.label(), ready.threads.length), - (summary) => { - const said = submission(surface.review, { - label: queries.label(), - tools: hasTools(), - files: queries.contents(), - summary, - }) - if (!said) return - guard.task("submit", async () => { - await api.promptSession(id, said.text) - }) - api.ui.toast({ - variant: "success", - title: "Review", - /** The held-back ones are said out loud: a comment that quietly did not go is a bug report. */ - message: - said.outdated.length > 0 - ? `Handed over ${said.threads.length} comment${said.threads.length === 1 ? "" : "s"}. ${said.outdated.length} left out — their code is gone.` - : `Handed over ${said.threads.length} comment${said.threads.length === 1 ? "" : "s"}.`, - }) - close() - }, - () => { - if (surface.open) takeKeys() - draw() - }, - ) - } - - /** Throws a thread away. Resolving is what the agent does; this is what you do to a mistake. */ - const uncomment = () => { - const thread = hereThread() - if (!thread) return - surface.review = drop(surface.review, thread.id) - void persistence() - .remove(thread.id) - .catch(() => {}) - draw() - } - - /** - * A global layer, registered only while the review is open. A layer with a `target` matches the - * *keymap host's* focused target, which stays the prompt whatever `focus()` sets on our own box — - * so scoping by target silently never fires. Global means these letters are really taken, which - * is why the layer is disposed the moment the review closes. - */ - - /** - * Marking a file read, and going to the next one that is not. - * - * Reading a review is a sweep, not a browse: the useful thing after finishing a file is the next - * file, not the file list. Unmarking does not move you, because unmarking is a correction. - */ - const markRead = () => { - const file = surface.view.file - if (!file) return - viewed(file) - if (isRead(surface.review, file)) { - /** - * The next unread file after this one, in the order you are reading — or, with none left below, - * the nearest one above. Wrapping round to the top sent you back to the first file from - * wherever you were, which reads as the review losing your place. - */ - const order = streamOrder(store.current().changes, listState()) - const from = order.indexOf(file) - const unread = (path: string) => !isRead(surface.review, path) - const next = - order.slice(from + 1).find(unread) ?? order.slice(0, Math.max(0, from)).reverse().find(unread) - if (next) { - surface.view = { ...surface.view, file: next, cursor: next, scroll: undefined, line: undefined } - surface.view = { ...surface.view, scroll: streamScroll(streamNow(), surface.view, listHeight()) } - } else follow() - } - draw() - } - - /** - * Viewed folds a file out of the way and unviewed brings it back — so a hand-set fold is let go of - * either way, and the default (open until viewed) takes over again. - */ - const viewed = (path: string) => { - surface.review = toggleRead(surface.review, path) - const folded = new Set(surface.view.folded ?? []) - const opened = new Set(surface.view.opened ?? []) - folded.delete(path) - opened.delete(path) - surface.view = { ...surface.view, folded, opened } - /** On a line of a file that just folded, the cursor goes up to its heading. */ - if (surface.view.file === path && isRead(surface.review, path)) - surface.view = { ...surface.view, line: undefined } - } - - const toggleViewed = (path = surface.view.file) => { - if (!path) return - viewed(path) - follow() - draw() - } - - const toggleFold = (path = surface.view.file) => { - if (!path) return - const segment = streamNow().segments.find((each) => each.path === path) - if (!segment) return - const folded = new Set(surface.view.folded ?? []) - const opened = new Set(surface.view.opened ?? []) - if (segment.open) { - folded.add(path) - opened.delete(path) - } else { - opened.add(path) - folded.delete(path) - } - surface.view = { ...surface.view, folded, opened, file: path, line: undefined, anchor: undefined } - follow() - draw() - } - - const commentFile = (path: string) => { - surface.view = { ...surface.view, file: path, line: undefined, anchor: undefined } - comment(true) - } - /** Uncommitted, then the branch, round and round. */ const nextSource = () => { store.setSource(SOURCES[(SOURCES.indexOf(store.source()) + 1) % SOURCES.length] ?? "branch") diff --git a/packages/review/src/tui/panel/navigation.ts b/packages/review/src/tui/panel/navigation.ts new file mode 100644 index 00000000..b11f2322 --- /dev/null +++ b/packages/review/src/tui/panel/navigation.ts @@ -0,0 +1,219 @@ +/** + * Moving through the panel: the cursor in the file list and in the diff, scrolling, panning sideways, + * and which pane `j` is talking to. The verbs that act on a thread lean on the same view of the rows + * (`streamNow`, `listState`) and on `follow`, so they come from here too. + */ + +import { filesElsewhere } from "../../core/model/review.ts" +import { mostShift } from "../../core/view/diff.ts" +import { streamWidth } from "../../core/view/layout.ts" +import { keepCursorVisible, navigableRows } from "../../core/view/list.ts" +import { + cursorRow, + segmentAt, + stopsOf, + streamOf, + streamScroll, + streamWindow, +} from "../../core/view/stream.ts" +import type { ActionDeps } from "./actions.ts" + +export interface Navigation { + /** The view the list is drawn from. */ + listState: () => Parameters[1] + width: () => number + streamNow: () => ReturnType + follow: () => void + move: (delta: number) => void + scroll: (delta: number) => void + pan: (delta: number) => void + toFiles: () => void + swap: () => void +} + +export function createNavigation(deps: ActionDeps): Navigation { + const { surface, store, draw, listHeight } = deps + + /** + * Movement follows the rows on screen, not the change set's own file order. + * + * Those are two different orders — the screen shows a grouped tree, the change set is however git + * listed things — and driving the cursor from the second while looking at the first is why it + * appeared to jump at random. + */ + /** The view the list is actually drawn from, including files that have comments but no diff. */ + const listState = () => ({ + ...surface.view, + elsewhere: filesElsewhere(surface.review, store.current().changes), + }) + + const moveFiles = (delta: number) => { + const rows = navigableRows(store.current().changes, listState()) + if (rows.length === 0) return + const at = surface.view.cursor ? rows.findIndex((row) => row.path === surface.view.cursor) : 0 + const next = rows[Math.max(0, Math.min(rows.length - 1, (at < 0 ? 0 : at) + delta))] + if (!next) return + surface.view = { ...surface.view, cursor: next.path } + if (next.kind === "file") + surface.view = { + ...surface.view, + file: next.path, + scroll: undefined, + line: undefined, + anchor: undefined, + } + surface.view = { + ...surface.view, + listOffset: keepCursorVisible(store.current().changes, listState(), listHeight()), + } + draw() + } + + /** The stream as the paint will draw it, for the keys to reason about. */ + const width = () => streamWidth(deps.viewport(), store.current().changes) + const streamNow = () => streamOf(store.current().changes, surface.review, listState(), width()) + + /** + * Scrolls just enough to keep the cursor in view, and keeps the file list pointing at its file. + * + * One row of margin at the top, because the pinned heading covers the first row of the pane: a + * cursor on that row would be hidden under the name of its own file. + */ + const follow = () => { + const stream = streamNow() + const height = listHeight() + const scroll = streamScroll(stream, surface.view, height) + const row = cursorRow(stream, surface.review, surface.view, width()) + let next = scroll + if (row !== undefined) { + const top = surface.view.line === undefined ? row : row - 1 + if (top < scroll) next = top + else if (row > scroll + height - 3) next = row - height + 3 + } + surface.view = { ...surface.view, scroll: Math.max(0, next), cursor: surface.view.file } + surface.view = { + ...surface.view, + listOffset: keepCursorVisible(store.current().changes, listState(), listHeight()), + } + } + + /** + * In the diff, the cursor walks the stream: a file's heading, then its lines, then the next file's + * heading. A folded file is one stop — its heading — so viewed files are stepped over in one press. + */ + const moveLines = (delta: number) => { + const { segments } = streamNow() + if (segments.length === 0) return + let index = Math.max( + 0, + segments.findIndex((segment) => segment.path === surface.view.file), + ) + let segment = segments[index] + if (!segment) return + let stops = stopsOf(segment, surface.review, surface.view, width()) + let at = Math.max(0, stops.indexOf(surface.view.line)) + const step = Math.sign(delta) + for (let left = Math.abs(delta); left > 0; left--) { + if (at + step >= 0 && at + step < stops.length) { + at += step + continue + } + const neighbour = segments[index + step] + if (!neighbour) break + index += step + segment = neighbour + stops = stopsOf(segment, surface.review, surface.view, width()) + at = step > 0 ? 0 : stops.length - 1 + } + const moved = segment.path !== surface.view.file + surface.view = { + ...surface.view, + file: segment.path, + line: stops[at], + thread: undefined, + /** A selection is inside one file: leaving the file lets go of it. */ + ...(moved ? { anchor: undefined } : {}), + } + follow() + draw() + } + + const move = (delta: number) => (surface.view.pane === "diff" ? moveLines(delta) : moveFiles(delta)) + + /** + * Scrolling moves the view, and the cursor only if the view leaves it behind — then it lands on the + * first thing in sight, so the next `j` carries on from what you are looking at rather than jumping + * back to where you were. + */ + /** + * Sideways through the code of the file under the cursor, as far as its longest line. A line that + * ran past the pane used to end in "…" with no way to read the rest. + */ + const pan = (delta: number) => { + const file = store.current().changes.files.find((change) => change.path === surface.view.file) + const most = mostShift(file, width()) + const shift = Math.max(0, Math.min(most, (surface.view.shift ?? 0) + delta)) + if (shift === (surface.view.shift ?? 0)) return + surface.view = { ...surface.view, shift, pane: "diff" } + draw() + } + + const scroll = (delta: number) => { + const stream = streamNow() + const height = listHeight() + const next = Math.max(0, streamScroll(stream, surface.view, height) + delta) + surface.view = { + ...surface.view, + scroll: streamScroll(stream, { ...surface.view, scroll: next }, height), + } + const at = surface.view.scroll ?? 0 + const row = cursorRow(stream, surface.review, surface.view, width()) + if (row === undefined || row < at || row >= at + height) { + const shown = streamWindow(store.current().changes, surface.review, listState(), width(), height) + /** Past the pinned heading, onto the first line of code in sight, if there is one. */ + const landing = shown.slice(1).find((each) => each.line !== undefined || each.header) ?? shown[0] + const file = landing?.file ?? segmentAt(stream, at)?.path + if (file) { + surface.view = { + ...surface.view, + file, + cursor: file, + line: landing?.header ? undefined : landing?.line, + anchor: undefined, + } + } + } + /** The file list follows the diff: the file you are reading stays in sight on the left too. */ + surface.view = { + ...surface.view, + cursor: surface.view.file, + listOffset: keepCursorVisible( + store.current().changes, + { ...listState(), cursor: surface.view.file }, + height, + ), + } + draw() + } + + /** + * Out of the diff and back to the list. + * + * `enter` takes you into a file, so something has to take you out, and `tab` alone is a thing you + * have to be told. `h` and `left` are where a hand already is after `j`/`k`. + */ + const toFiles = () => { + surface.view = { ...surface.view, pane: "files", anchor: undefined } + draw() + } + + /** Two panes, one keyboard: `tab` says which one `j` is talking to. */ + const swap = () => { + surface.view = { ...surface.view, pane: surface.view.pane === "diff" ? "files" : "diff" } + if (surface.view.pane === "diff" && surface.view.file === undefined) + surface.view = { ...surface.view, file: streamNow().segments[0]?.path, scroll: undefined } + draw() + } + + return { listState, width, streamNow, follow, move, scroll, pan, toFiles, swap } +} diff --git a/packages/review/src/tui/panel/threads.ts b/packages/review/src/tui/panel/threads.ts new file mode 100644 index 00000000..cc5ea074 --- /dev/null +++ b/packages/review/src/tui/panel/threads.ts @@ -0,0 +1,373 @@ +/** + * What the panel does to threads and files: a comment on lines, a file or a range, a reply, a thread + * taken back, read or answered, a file viewed or folded — and submit, which hands the comments to the + * agent. + */ + +import { + drop, + isRead, + open as openThread, + say, + threadOn, + threadsFor, + threadsOnLine, + toggleRead, +} from "../../core/model/review.ts" +import { submission, toolsConfigured, waitingOnAgent } from "../../core/model/submit.ts" +import { streamOrder, streamScroll } from "../../core/view/stream.ts" +import { askForNote, noteFields, replyFields, submitFields } from "../view/dialogs.tsx" +import type { ActionDeps } from "./actions.ts" +import type { Navigation } from "./navigation.ts" + +export interface Threads { + comment: (whole?: boolean) => void + selectRange: () => void + replyHere: () => void + submit: () => void + uncomment: () => void + markRead: () => void + toggleViewed: (path?: string) => void + toggleFold: (path?: string) => void + commentFile: (path: string) => void +} + +export function createThreads(deps: ActionDeps, navigation: Navigation): Threads { + const { api, surface, store, guard, queries, draw, keep, listHeight } = deps + const persistence = deps.persistence + const takeKeys = deps.takeKeys + const dropKeys = deps.dropKeys + const close = deps.close + const REVIEW_PACKAGE = deps.reviewPackage + const { listState, streamNow, follow } = navigation + + /** + * One key, and it comments on whatever the cursor is on: the selected lines, the line under the + * cursor, or — in the file list — the file as a whole. Two keys for the same intention is two + * keys to remember for no reason. + */ + const comment = (whole = false) => { + const file = surface.view.file + if (!file) return + /** On a file's heading there is no line: a note there is about the file. */ + const onFile = whole || surface.view.pane === "files" || surface.view.line === undefined + const from = onFile + ? undefined + : Math.min(surface.view.anchor ?? surface.view.line ?? 0, surface.view.line ?? 0) + const to = onFile + ? undefined + : Math.max(surface.view.anchor ?? surface.view.line ?? 0, surface.view.line ?? 0) + /** + * The thread already here: written on exactly these lines, or — for one line — drawn on it now, + * its code having moved since. Missing the second made a reply a new thread above the old one. + */ + const existing = + threadOn(surface.review, file, from, to) ?? + (!onFile && from === to && to !== undefined + ? threadsOnLine(surface.review, file, to, queries.contents().get(file))[0] + : undefined) + + /** + * The review's keys are a *global* layer, so they are still live while a dialog is open — which + * means typing a note would trigger them and `escape` would close the review out from under the + * prompt. They go away for as long as the dialog is up. + */ + dropKeys() + + askForNote( + api, + noteFields(file, { + ...(from === undefined ? {} : { from }), + ...(to === undefined ? {} : { to }), + ...(existing ? { existing } : {}), + ...(queries.quoteOf(file, from, to) ? { quoted: queries.quoteOf(file, from, to) } : {}), + }), + (body) => { + const at = Date.now() + surface.review = existing + ? say(surface.review, existing.id, { author: "you", body, at }) + : openThread( + surface.review, + { + file, + ...(from === undefined ? {} : { line: from }), + ...(to !== undefined && from !== undefined && to > from ? { through: to } : {}), + ...(queries.quoteOf(file, from, to) ? { quoted: queries.quoteOf(file, from, to) } : {}), + /** What the file was at when this was written. Provenance, never the anchor. */ + ...(deps.head() ? { commit: deps.head() } : {}), + }, + body, + "you", + at, + ) + /** Written as it is said. A note you have to remember to save is a note you will lose. */ + keep(existing?.id ?? threadOn(surface.review, file, from, to)?.id) + surface.view = { ...surface.view, anchor: undefined } + draw() + }, + () => { + /** However it closed, the keys come back and the pane is drawn again. */ + if (surface.open) takeKeys() + draw() + }, + ) + } + + /** Starts a selection, or throws one away. The moving end is the cursor. */ + const selectRange = () => { + surface.view = + surface.view.anchor === undefined + ? { ...surface.view, anchor: surface.view.line } + : { ...surface.view, anchor: undefined } + draw() + } + + /** + * The thread under the cursor, which is what `r`, `o` and `x` act on. + * + * Derived rather than navigated: a thread lives on a line, so standing on the line is standing + * on the thread. A second cursor for threads would be a second thing to move and to explain. + */ + const hereThread = () => { + if (!surface.view.file) return undefined + if (surface.view.pane === "files") + return threadsFor(surface.review, surface.view.file).find((each) => each.line === undefined) + /** Placed by the file as it reads now, as the diff draws it (see `hereThreadId`). */ + return surface.view.line === undefined + ? threadsFor(surface.review, surface.view.file).find((each) => each.line === undefined) + : threadsOnLine( + surface.review, + surface.view.file, + surface.view.line, + queries.contents().get(surface.view.file), + )[0] + } + + /** + * Answering back. + * + * Saying something on a resolved thread reopens it, which is the honest meaning of a reply: you + * have read the answer and it is not finished. Accepting needs no key — a thread you say nothing + * more about is one you accepted. + */ + const replyHere = () => { + const thread = hereThread() + if (!thread) return comment() + dropKeys() + askForNote( + api, + replyFields(thread), + (body) => { + surface.review = say(surface.review, thread.id, { author: "you", body, at: Date.now() }) + keep(thread.id) + draw() + }, + () => { + if (surface.open) takeKeys() + draw() + }, + ) + } + + /** + * Whether the agent has the review tools. + * + * Read from the user's own plugin list rather than guessed: only this half can see it, and telling + * an agent to "run review_list" when it has no such tool is worse than sending it too much. When + * the server half is not installed the whole review travels as prose instead. + */ + const hasTools = (): boolean => + /** On OpenCode 2 the tools arrive with this package's own server half, which v2 always loads. */ + api.v1 ? toolsConfigured(api.v1.state.config.plugin, REVIEW_PACKAGE) : true + + /** The conversation this review would be handed to. */ + const sessionID = (): string | undefined => { + const route = api.route.current + return route.name === "session" ? (route.params as { sessionID?: string }).sessionID : undefined + } + + /** + * Handing the review over. + * + * The end of reading, so it closes the pane: what happens next happens in the conversation, and + * leaving a review open over the answer to it is leaving the screen on the wrong thing. + */ + const submit = () => { + /** + * The files go in from the first call, so what the dialog counts is what will be sent. + * + * They were left out here once, which meant the count offered and the count handed over could + * differ by however many comments had gone outdated since they were written. + */ + const ready = submission(surface.review, { + label: queries.label(), + tools: hasTools(), + files: queries.contents(), + }) + if (!ready) { + /** + * Three ways to have nothing to submit, and they need three different sentences. + * + * "Every comment has been answered" was said in all three, including to someone who had not + * written a comment yet — which reads as the feature being broken rather than unused. + */ + const waiting = waitingOnAgent(surface.review) + const message = + surface.review.threads.length === 0 + ? "Nothing to submit yet — press c on a line to leave a comment." + : waiting.length === 0 + ? "Nothing to hand over — every comment has been answered." + : waiting.length === 1 + ? "Nothing to hand over — the code the last comment is about is gone." + : `Nothing to hand over — the code all ${waiting.length} comments are about is gone.` + api.ui.toast({ variant: "info", title: "Review", message }) + return + } + const id = sessionID() + if (!id) { + api.ui.toast({ + variant: "error", + title: "Review", + message: "Open a conversation to submit a review to.", + }) + return + } + + dropKeys() + askForNote( + api, + submitFields(queries.label(), ready.threads.length), + (summary) => { + const said = submission(surface.review, { + label: queries.label(), + tools: hasTools(), + files: queries.contents(), + summary, + }) + if (!said) return + guard.task("submit", async () => { + await api.promptSession(id, said.text) + }) + api.ui.toast({ + variant: "success", + title: "Review", + /** The held-back ones are said out loud: a comment that quietly did not go is a bug report. */ + message: + said.outdated.length > 0 + ? `Handed over ${said.threads.length} comment${said.threads.length === 1 ? "" : "s"}. ${said.outdated.length} left out — their code is gone.` + : `Handed over ${said.threads.length} comment${said.threads.length === 1 ? "" : "s"}.`, + }) + close() + }, + () => { + if (surface.open) takeKeys() + draw() + }, + ) + } + + /** Throws a thread away. Resolving is what the agent does; this is what you do to a mistake. */ + const uncomment = () => { + const thread = hereThread() + if (!thread) return + surface.review = drop(surface.review, thread.id) + void persistence() + .remove(thread.id) + .catch(() => {}) + draw() + } + + /** + * A global layer, registered only while the review is open. A layer with a `target` matches the + * *keymap host's* focused target, which stays the prompt whatever `focus()` sets on our own box — + * so scoping by target silently never fires. Global means these letters are really taken, which + * is why the layer is disposed the moment the review closes. + */ + + /** + * Marking a file read, and going to the next one that is not. + * + * Reading a review is a sweep, not a browse: the useful thing after finishing a file is the next + * file, not the file list. Unmarking does not move you, because unmarking is a correction. + */ + const markRead = () => { + const file = surface.view.file + if (!file) return + viewed(file) + if (isRead(surface.review, file)) { + /** + * The next unread file after this one, in the order you are reading — or, with none left below, + * the nearest one above. Wrapping round to the top sent you back to the first file from + * wherever you were, which reads as the review losing your place. + */ + const order = streamOrder(store.current().changes, listState()) + const from = order.indexOf(file) + const unread = (path: string) => !isRead(surface.review, path) + const next = + order.slice(from + 1).find(unread) ?? order.slice(0, Math.max(0, from)).reverse().find(unread) + if (next) { + surface.view = { ...surface.view, file: next, cursor: next, scroll: undefined, line: undefined } + surface.view = { ...surface.view, scroll: streamScroll(streamNow(), surface.view, listHeight()) } + } else follow() + } + draw() + } + + /** + * Viewed folds a file out of the way and unviewed brings it back — so a hand-set fold is let go of + * either way, and the default (open until viewed) takes over again. + */ + const viewed = (path: string) => { + surface.review = toggleRead(surface.review, path) + const folded = new Set(surface.view.folded ?? []) + const opened = new Set(surface.view.opened ?? []) + folded.delete(path) + opened.delete(path) + surface.view = { ...surface.view, folded, opened } + /** On a line of a file that just folded, the cursor goes up to its heading. */ + if (surface.view.file === path && isRead(surface.review, path)) + surface.view = { ...surface.view, line: undefined } + } + + const toggleViewed = (path = surface.view.file) => { + if (!path) return + viewed(path) + follow() + draw() + } + + const toggleFold = (path = surface.view.file) => { + if (!path) return + const segment = streamNow().segments.find((each) => each.path === path) + if (!segment) return + const folded = new Set(surface.view.folded ?? []) + const opened = new Set(surface.view.opened ?? []) + if (segment.open) { + folded.add(path) + opened.delete(path) + } else { + opened.add(path) + folded.delete(path) + } + surface.view = { ...surface.view, folded, opened, file: path, line: undefined, anchor: undefined } + follow() + draw() + } + + const commentFile = (path: string) => { + surface.view = { ...surface.view, file: path, line: undefined, anchor: undefined } + comment(true) + } + + return { + comment, + selectRange, + replyHere, + submit, + uncomment, + markRead, + toggleViewed, + toggleFold, + commentFile, + } +} diff --git a/packages/review/src/tui/view/dialogs.tsx b/packages/review/src/tui/view/dialogs.tsx index c5e95557..6628e62d 100644 --- a/packages/review/src/tui/view/dialogs.tsx +++ b/packages/review/src/tui/view/dialogs.tsx @@ -1,6 +1,6 @@ /** @jsxImportSource @opentui/solid */ import type { Host } from "@opencode-cockpit/client/host" -import type { BaseCandidate } from "../../core/git/sources.ts" +import type { BaseCandidate } from "../../core/git/changes.ts" import { type Thread, threadWhere } from "../../core/model/thread.ts" import { cardRows } from "../../core/view/card.ts" import type { Fill, Row, Tone } from "../../core/view/rows.ts" diff --git a/packages/review/test/agent/plugin.test.ts b/packages/review/test/agent/plugin.test.ts index 3a500525..b4391e59 100644 --- a/packages/review/test/agent/plugin.test.ts +++ b/packages/review/test/agent/plugin.test.ts @@ -7,7 +7,7 @@ import { partsToV1Hooks, serverFromV1 } from "@opencode-cockpit/client/server" import { createReviewServer, reviewGuidance } from "../../src/agent/plugin.ts" import type { Thread } from "../../src/core/model/thread.ts" import { reviewPaths } from "../../src/core/store/paths.ts" -import { createPersistence } from "../../src/core/store/persist.ts" +import { createPersistence } from "../../src/io/persist.ts" /** * The server half's own behaviour: what it tells the agent before the agent asks anything. diff --git a/packages/review/test/agent/tools.test.ts b/packages/review/test/agent/tools.test.ts index 4e0563f1..34f56e75 100644 --- a/packages/review/test/agent/tools.test.ts +++ b/packages/review/test/agent/tools.test.ts @@ -6,7 +6,7 @@ import type { ToolDefinition } from "@opencode-ai/plugin" import { createTools } from "../../src/agent/tools/index.ts" import type { Thread } from "../../src/core/model/thread.ts" import { reviewPaths } from "../../src/core/store/paths.ts" -import { createPersistence } from "../../src/core/store/persist.ts" +import { createPersistence } from "../../src/io/persist.ts" /** * The half the person never sees. diff --git a/packages/review/test/git/binary.test.ts b/packages/review/test/git/binary.test.ts index 1bb32556..e5ef14fb 100644 --- a/packages/review/test/git/binary.test.ts +++ b/packages/review/test/git/binary.test.ts @@ -2,7 +2,8 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test" import { copyFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" -import { branchChanges, readBlob, runGit, withCounts, worktreeChanges } from "../../src/core/git/sources.ts" +import { withCounts } from "../../src/core/git/changes.ts" +import { branchChanges, readBlob, runGit, worktreeChanges } from "../../src/io/git.ts" /** * Binaries through a real repository: what git hands over, read as bytes. diff --git a/packages/review/test/git/kinds.test.ts b/packages/review/test/git/kinds.test.ts index cebe68ba..45e4c9c4 100644 --- a/packages/review/test/git/kinds.test.ts +++ b/packages/review/test/git/kinds.test.ts @@ -3,9 +3,10 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" import { toHunks } from "../../src/core/diff/hunks.ts" -import { branchChanges, runGit, withCounts, worktreeChanges } from "../../src/core/git/sources.ts" +import { withCounts } from "../../src/core/git/changes.ts" import type { FileChange } from "../../src/core/model/review.ts" import { hunkWhitespace } from "../../src/core/view/whitespace.ts" +import { branchChanges, runGit, worktreeChanges } from "../../src/io/git.ts" /** * What happened to each file itself — created, deleted, moved — as git reports it, against a real diff --git a/packages/review/test/git/sources.test.ts b/packages/review/test/git/sources.test.ts index 4567bd46..02b04a95 100644 --- a/packages/review/test/git/sources.test.ts +++ b/packages/review/test/git/sources.test.ts @@ -2,14 +2,8 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test" import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" -import { - baseCandidates, - branchChanges, - pickBase, - runGit, - withCounts, - worktreeChanges, -} from "../../src/core/git/sources.ts" +import { pickBase, withCounts } from "../../src/core/git/changes.ts" +import { baseCandidates, branchChanges, runGit, worktreeChanges } from "../../src/io/git.ts" /** * Driven against a real repository rather than a fake one. diff --git a/packages/review/test/image/viewer.test.ts b/packages/review/test/image/viewer.test.ts index f1038e2b..1639a9b6 100644 --- a/packages/review/test/image/viewer.test.ts +++ b/packages/review/test/image/viewer.test.ts @@ -3,7 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, import { tmpdir } from "node:os" import { join } from "node:path" import type { FileChange } from "../../src/core/model/review.ts" -import { createViewer, type Launched, oldName, type ViewerDeps } from "../../src/core/viewer.ts" +import { createViewer, type Launched, oldName, type ViewerDeps } from "../../src/io/viewer.ts" /** * `o`, with the system stubbed out: nothing here ever launches a real viewer. The opener is "found" diff --git a/packages/review/test/panel/keys.test.ts b/packages/review/test/panel/keys.test.ts new file mode 100644 index 00000000..6f21e1b5 --- /dev/null +++ b/packages/review/test/panel/keys.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from "bun:test" +import type { Guard } from "../../src/core/guard.ts" +import { REVIEW_KEYS } from "../../src/core/view/keys.ts" +import type { Actions } from "../../src/tui/panel/actions.ts" +import { paneLayer } from "../../src/tui/panel/keys.ts" + +/** Actions that only record what was asked of them. */ +function recorder() { + const called: string[] = [] + const actions = new Proxy({} as Actions, { + get: + (_, name) => + (...args: unknown[]) => { + called.push(args.length ? `${String(name)}(${args.join(",")})` : String(name)) + }, + }) + return { actions, called } +} +/** A guard that runs everything and says where. */ +function passThrough() { + const where: string[] = [] + const guard = { + run: (at: string, run: () => unknown) => { + where.push(at) + return run() + }, + } as unknown as Guard + return { guard, where } +} + +const layer = () => paneLayer(recorder().actions, passThrough().guard) +const bindings = () => layer().bindings ?? [] +const commands = () => layer().commands ?? [] +/** Every key a binding takes, as OpenTUI names it: `return`, `shift+l`, `escape`. */ +const boundKeys = () => bindings().flatMap((binding) => binding.key.split(",")) + +describe("the table", () => { + test("every binding runs a command the layer has, and every command has a key", () => { + const names = new Set(commands().map((command) => command.name)) + for (const binding of bindings()) expect(names).toContain(binding.cmd) + const bound = new Set(bindings().map((binding) => binding.cmd)) + for (const name of names) expect(bound).toContain(name) + }) + + test("no key means two things — `s` submitted and `S` switched source once, a shift apart", () => { + const keys = boundKeys() + const twice = keys.filter((key, at) => keys.indexOf(key) !== at) + expect(twice).toEqual([]) + }) +}) + +describe("running a key", () => { + const run = (name: string) => { + const { actions, called } = recorder() + const { guard, where } = passThrough() + const command = paneLayer(actions, guard).commands?.find((each) => each.name === name) + command?.run({} as never) + return { called, where } + } + + test("goes through the guard, under its own name", () => { + expect(run("cockpit.review.pane.submit").where).toEqual(["pane.submit"]) + }) + + test("a key that acts on the review leaves the keys screen first, then acts", () => { + expect(run("cockpit.review.pane.down").called).toEqual(["leaveKeys", "move(1)"]) + expect(run("cockpit.review.pane.submit").called).toEqual(["leaveKeys", "submit"]) + }) + + test("the keys that mean something on the keys screen keep it: ?, close and the numbers", () => { + expect(run("cockpit.review.pane.keys").called).toEqual(["toggleKeys"]) + expect(run("cockpit.review.pane.quit").called).toEqual(["quit"]) + expect(run("cockpit.review.pane.stats").called).toEqual(["toggleStats"]) + }) +}) + +describe("the keys screen says what is bound", () => { + /** How the screen writes a key, in the names OpenTUI binds. */ + const SCREEN: Record = { + "j/k": ["j", "k"], + "↑/↓": ["up", "down"], + enter: ["return"], + "←": ["left"], + L: ["shift+l"], + H: ["shift+h"], + B: ["shift+b"], + esc: ["escape"], + } + /** + * Bound, and left off the screen on purpose — each with where the screen does say it, or why not. + * A key bound anywhere else must be on the screen. + */ + const UNLISTED: Record = { + right: "opens, like enter and l — the screen teaches those two", + pagedown: "in d's line: (also pgdn, pgup)", + pageup: "in d's line: (also pgdn, pgup)", + "shift+right": "in L's line: (also shift+→, shift+←)", + "shift+left": "in L's line: (also shift+→, shift+←)", + "shift+/": "the same key as ?", + q: "closes, kept for hands that learned it; the screen teaches esc (design-system.md)", + } + const shown = () => new Set(REVIEW_KEYS.flatMap((line) => line.keys.flatMap((key) => SCREEN[key] ?? [key]))) + + test("every key on the screen is bound", () => { + const bound = new Set(boundKeys()) + for (const key of shown()) expect(bound).toContain(key) + }) + + test("every bound key is on the screen, or unlisted with a reason", () => { + const missing = boundKeys().filter((key) => !shown().has(key) && !(key in UNLISTED)) + expect(missing).toEqual([]) + }) + + test("an unlisted key is still bound — the list cannot outlive the binding", () => { + const bound = new Set(boundKeys()) + for (const key of Object.keys(UNLISTED)) expect(bound).toContain(key) + }) +}) diff --git a/packages/review/test/store/persist.test.ts b/packages/review/test/store/persist.test.ts index 1d0ebd94..386a942a 100644 --- a/packages/review/test/store/persist.test.ts +++ b/packages/review/test/store/persist.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os" import { join } from "node:path" import type { Thread } from "../../src/core/model/thread.ts" import { reviewPaths } from "../../src/core/store/paths.ts" -import { createPersistence } from "../../src/core/store/persist.ts" +import { createPersistence } from "../../src/io/persist.ts" /** * Driven against a real directory rather than a mocked one. The thing under test is the filesystem's diff --git a/packages/review/test/view/settings.test.ts b/packages/review/test/view/settings.test.ts index d6aaac1d..be510f6d 100644 --- a/packages/review/test/view/settings.test.ts +++ b/packages/review/test/view/settings.test.ts @@ -15,7 +15,7 @@ const changes = { source: "worktree" as const, files: [{ path: "src/a.ts", before: "a\n", after: "a\nb\n", additions: 1, deletions: 0 }], } -const OLD = 'settings: "review.sidebarOrder" is no longer read — run /cockpit-setup' +const OLD = 'settings: "review.keybinds" should be an object; the default is used' const text = (row: Row | undefined) => row?.runs.map((run) => run.text).join("") ?? "" const draw = (settings?: string[]) => layout( diff --git a/packages/review/test/watch/stats.test.ts b/packages/review/test/watch/stats.test.ts index 8aeaa5bb..b4c37d1b 100644 --- a/packages/review/test/watch/stats.test.ts +++ b/packages/review/test/watch/stats.test.ts @@ -45,18 +45,20 @@ describe("the numbers, said in one line", () => { * The footer is two rows whatever it has to say, because the body's height is measured from it — a * footer that grew would shove the diff about every time something went wrong. */ -describe("what the footer says instead of the keys", () => { +describe("what the footer says, and where", () => { const rows = (state: Parameters[2]) => footerRows(80, { list: 20, diff: 57 }, state) test("keys, normally — bracketed, so a key is a shape rather than a word to parse", () => { expect(text(rows({ pane: "diff" })[1]?.runs ?? [])).toContain("[tab] Files") }) - test("trouble wins the line, and still fits in two rows", () => { + /** A notice that stays — "more than N files changed" — took every key, the way out included. */ + test("trouble takes the rule's row, the keys keep theirs, and it still fits in two rows", () => { const shown = rows({ pane: "diff", notice: "paint: bad row" }) expect(shown).toHaveLength(2) - expect(text(shown[1]?.runs ?? [])).toContain("paint: bad row") - expect(text(shown[1]?.runs ?? [])).not.toContain("[tab]") + expect(text(shown[0]?.runs ?? [])).toContain("paint: bad row") + expect(text(shown[1]?.runs ?? [])).toContain("[tab]") + expect(text(shown[1]?.runs ?? [])).toContain("[esc]") }) test("the numbers take it when you asked and nothing is wrong", () => { @@ -65,13 +67,14 @@ describe("what the footer says instead of the keys", () => { expect(text(shown[1]?.runs ?? [])).toContain("paint") }) - test("trouble outranks the numbers: a broken review is the more useful fact", () => { + test("trouble and the numbers both show: trouble above, where the rule was", () => { const shown = rows({ pane: "diff", notice: "click: no such file", stats: statsRuns(createMeter(() => 0).snapshot()), }) - expect(text(shown[1]?.runs ?? [])).toContain("no such file") + expect(text(shown[0]?.runs ?? [])).toContain("no such file") + expect(text(shown[1]?.runs ?? [])).toContain("paint") }) }) diff --git a/packages/shell/README.md b/packages/shell/README.md index dc205d85..3a51bda0 100644 --- a/packages/shell/README.md +++ b/packages/shell/README.md @@ -208,9 +208,8 @@ unreadable file is ignored rather than fatal — a typo should never stop shells Where the block sits is the top-level `"sidebar"` list's to say (`["status", "subagents", "shell", "trail", "trust"]` by default). Before 0.9 these keys sat at the file's root, with the interface's -under `ui`; those places are no longer read — the Shells block shows a `!` row naming the new key -(`ui.historyMinutes` is now `hideFinishedAfterMinutes`, `ui.updateCheck` is `updater.updateCheck`), -and `/cockpit-setup` fixes it. +under `ui`; those places are not read (`ui.historyMinutes` is now `hideFinishedAfterMinutes`, +`ui.updateCheck` is `updater.updateCheck`), and `/cockpit-setup` moves them. The same keys can go on the plugin entry instead, which wins over both files — handy for one-offs: diff --git a/packages/shell/src/cli/preview.ts b/packages/shell/src/cli/preview.ts index d277b2d6..9ce8ecae 100644 --- a/packages/shell/src/cli/preview.ts +++ b/packages/shell/src/cli/preview.ts @@ -93,7 +93,7 @@ function fitRow(row: Row, width: number): Row { // --- the sidebar --------------------------------------------------------------------------------- /** A settings notice as the block draws it (client/settings `noticeText`), for `--notice`. */ -const NOTICE = 'settings: "ui.historyMinutes" is no longer read — run /cockpit-setup' +const NOTICE = 'settings: "shell.dockHeight" should be a number; the default is used' interface SidebarState { /** Expanded by a click on `+ N more`. */ diff --git a/packages/shell/src/core/config.ts b/packages/shell/src/core/config.ts index 2931b8a8..8ea12a23 100644 --- a/packages/shell/src/core/config.ts +++ b/packages/shell/src/core/config.ts @@ -9,8 +9,7 @@ import type { WatchRule } from "@opencode-cockpit/protocol/shell" * ~/.config/opencode-cockpit/config.json → /.cockpit.json → plugin-entry options * * Only the `shell` section of a file is read. Shell's keys used to sit at the file's root, with the - * interface's under `ui`; those are no longer read, and each one found is a notice naming the new - * place (drawn in the Shells block, and by doctor). Everything is optional, and an unreadable or + * interface's under `ui`; those are not read (`/cockpit-setup` and doctor name them). Everything is optional, and an unreadable or * invalid file is ignored rather than fatal: a typo in a config should never stop shells from working. */ export interface ShellConfig { diff --git a/packages/shell/src/tui/components/sidebar.tsx b/packages/shell/src/tui/components/sidebar.tsx index 70e99c2e..8db4b92b 100644 --- a/packages/shell/src/tui/components/sidebar.tsx +++ b/packages/shell/src/tui/components/sidebar.tsx @@ -10,6 +10,7 @@ import { warnRows, } from "@opencode-cockpit/client/design" import type { Host } from "@opencode-cockpit/client/host" +import { blockWidth } from "@opencode-cockpit/client/sidebar" import type { BoxRenderable } from "@opentui/core" import { createEffect, createMemo, createSignal, For, Show } from "solid-js" import { fold, sidebarRow } from "../lib/sidebar.ts" @@ -79,12 +80,7 @@ export function SidebarShells(props: SidebarProps) { const width = createMemo(() => { props.store.now() resized() - const parent = (block?.parent as { width?: number } | null | undefined)?.width ?? 0 - const own = block?.width ?? 0 - const measured = parent >= 12 ? Math.min(parent, own >= 12 ? own : parent) : own - return measured >= 12 - ? measured - : Math.max(20, Math.min(30, Math.floor(props.api.renderer.width / 4) - 2)) + return blockWidth(block, props.api.renderer.width, 30) }) const counts = createMemo(() => summaryRuns(tally(), Math.max(8, width() - "Shells ".length))) diff --git a/packages/shell/test/config.test.ts b/packages/shell/test/config.test.ts index b1f3c7a2..46a79b4d 100644 --- a/packages/shell/test/config.test.ts +++ b/packages/shell/test/config.test.ts @@ -93,7 +93,8 @@ describe("config", () => { expect(config.dockHeight).toBe(14) }) - test("the old places are not read: root keys, `ui`, `ui.historyMinutes` — each a notice", () => { + /** Names from before 0.9 are unknown names now: /cockpit-setup and doctor say so, the block does not. */ + test("the old places are not read: root keys, `ui`, `ui.historyMinutes`", () => { const { directory, env } = files({ guidance: false, ui: { dockHeight: 30, historyMinutes: 5, sidebarOrder: 1 }, @@ -103,16 +104,7 @@ describe("config", () => { expect(config.dockHeight).toBe(14) expect(config.hideFinishedAfterMinutes).toBe(30) expect(config.sidebarRows).toBe(5) - expect(notices.map((notice) => [notice.old, notice.new])).toEqual( - expect.arrayContaining([ - ["guidance", "shell.guidance"], - ["ui.dockHeight", "shell.dockHeight"], - ["ui.historyMinutes", "shell.hideFinishedAfterMinutes"], - ["ui.sidebarOrder", "sidebar"], - ["ui.sidebarRows", "shell.sidebarRows"], - ]), - ) - for (const notice of notices) expect(notice.text).toContain("/cockpit-setup") + expect(notices).toEqual([]) }) test("a value of the wrong kind is the default, and an unknown view is the screen", () => { diff --git a/packages/status/README.md b/packages/status/README.md index 959e516e..a680cdfa 100644 --- a/packages/status/README.md +++ b/packages/status/README.md @@ -134,11 +134,10 @@ segment is a `!` row: `override "gti" matches no segment in the sidebar preset Type it, or just ask ("put the statusline at the bottom"), and the agent sets the line up with you through the `status-setup` skill shipped in this package (`skills/status-setup/`): it reads what is -written now with `cockpit_settings`, fixes old names first, offers a preset to start from, asks what -you want, writes only what differs from the defaults, and checks the line in the preview that came -with your install — `cockpit_settings` names it under Previews — never `bunx`, which fetches another -release. `/statusline`, its name until 0.9, still works for one release and says the new -name. Both come from Status's agent side (`@opencode-cockpit/status/server`), which the bundle +written now with `cockpit_settings`, fixes what it cannot read first, offers a preset to start +from, asks what you want, writes only what differs from the defaults, and checks the line in the +preview that came with your install — `cockpit_settings` names it under Previews — never `bunx`, +which fetches another release. It comes from Status's agent side (`@opencode-cockpit/status/server`), which the bundle includes and the package's install line adds. ## Looking at it before a restart @@ -193,12 +192,12 @@ The keys every bay shares work here too: `enabled`, `sidebar` (`false` draws at 14, any other column's 8). Where the block sits among the others is the top-level `sidebar` list — `["status", "subagents", "shell", "trail", "trust"]` — and nowhere else. -**What is not read says so.** `"statusline"` (the section's name before 0.9), Status's keys at the -file's root, a bay-level `maxRows` (now `sidebarRows`) and `sidebarOrder` are no longer read; each is -a `!` row at the top of the column, `! settings: "statusline" is no longer read — run -/cockpit-setup`, until the file is fixed. So are a file that is not valid JSON, a top-level name -nothing reads, an entry in the `sidebar` list that is not a bay, a value of the wrong kind, and a -module that would not load. +**What is not read says so.** A top-level name nothing reads is a `!` row at the top of the column +— `! settings: "statusline" is not a setting: did you mean "status"?` for the section's name before +0.9 — until the file is fixed. So are a file that is not valid JSON, an entry in the `sidebar` list +that is not a bay, a value of the wrong kind, and a module that would not load. Status's keys at the +file's root are not read either, nor a bay-level `maxRows` (now `sidebarRows`); `/cockpit-setup` +names every key Status does not read. ### Surfaces diff --git a/packages/status/package.json b/packages/status/package.json index 5428b3d2..f53a4410 100644 --- a/packages/status/package.json +++ b/packages/status/package.json @@ -33,16 +33,12 @@ "default": "./dist/tui/index.js" }, "./config": { - "types": "./types/core/config.d.ts", - "default": "./dist/core/config.js" + "types": "./types/core/config/index.d.ts", + "default": "./dist/core/config/index.js" }, "./segment": { "types": "./types/core/authoring.d.ts", "default": "./dist/core/authoring.js" - }, - "./fixtures": { - "types": "./types/core/fixtures.d.ts", - "default": "./dist/core/fixtures.js" } }, "engines": { diff --git a/packages/status/skills/status-setup/SKILL.md b/packages/status/skills/status-setup/SKILL.md index dacb498c..d3b686b8 100644 --- a/packages/status/skills/status-setup/SKILL.md +++ b/packages/status/skills/status-setup/SKILL.md @@ -1,6 +1,6 @@ --- name: status-setup -description: Set up or design the Status bay of opencode-cockpit (its statusline) with the user - the table in the sidebar or a line under the prompt, which segments it shows, a preset, a shell command or Claude Code statusline script as a segment, or a TypeScript segment module. Use it whenever the user runs /status-setup or /statusline, or asks to change what the statusline or the Status table shows, e.g. "put the statusline at the bottom", "show the model and cost", "use my Claude Code statusline", "make the status table shorter", "show git against the branch", "hide the write row", or edits the "status" section of config.json or .cockpit.json. For which Cockpit blocks show and in what order, use the cockpit-setup skill. +description: Set up or design the Status bay of opencode-cockpit (its statusline) with the user - the table in the sidebar or a line under the prompt, which segments it shows, a preset, a shell command or Claude Code statusline script as a segment, or a TypeScript segment module. Use it whenever the user runs /status-setup, or asks to change what the statusline or the Status table shows, e.g. "put the statusline at the bottom", "show the model and cost", "use my Claude Code statusline", "make the status table shorter", "show git against the branch", "hide the write row", or edits the "status" section of config.json or .cockpit.json. For which Cockpit blocks show and in what order, use the cockpit-setup skill. --- # Setting up the Status bay @@ -18,10 +18,11 @@ guess any of that from memory. ## 2. Fix the notices first -Old names are **not read**, so their values do nothing today: `"statusline"` → `"status"`, a -bay-level `maxRows` → `sidebarRows`, Status keys at the file's root → under `"status"`, a -`sidebarOrder` → removed (the order is the top-level `"sidebar"` list). Fix them in the file each -notice names, keep everything else, and say what changed in one line each. +Names from before 0.9 are **not read**, so their values do nothing today: `"statusline"` → +`"status"`, a bay-level `maxRows` → `sidebarRows`, Status keys at the file's root → under +`"status"`, a `sidebarOrder` → removed (the order is the top-level `"sidebar"` list). Fix them, and +every other notice, in the file each names; keep everything else, and say what changed in one line +each. ## 3. Offer a starting point diff --git a/packages/status/skills/status-setup/references/design.md b/packages/status/skills/status-setup/references/design.md index 7d174990..0435c406 100644 --- a/packages/status/skills/status-setup/references/design.md +++ b/packages/status/skills/status-setup/references/design.md @@ -103,7 +103,7 @@ truncated; a track *was* being drawn and `panel` was the panel's own colour. | --- | --- | | Settings, every project | the `"status"` section of `~/.config/opencode-cockpit/config.json` | | Settings, one project | the `"status"` section of `/.cockpit.json` | -| Old names | `"statusline"`, Status keys at the file's root, a bay-level `maxRows`, `sidebarOrder`: not read; each is a `!` row. Write `"status"`, `sidebarRows`, and the top-level `"sidebar"` list | +| Old names | `"statusline"`, Status keys at the file's root, a bay-level `maxRows`, `sidebarOrder`: not read. Write `"status"`, `sidebarRows`, and the top-level `"sidebar"` list | | Modules | anywhere — `~/.config/opencode-cockpit/modules/` needs no `node_modules` beside it | | Which plugins load | `~/.config/opencode/tui.json` | diff --git a/packages/status/skills/statusline-design/SKILL.md b/packages/status/skills/statusline-design/SKILL.md deleted file mode 100644 index c5b08053..00000000 --- a/packages/status/skills/statusline-design/SKILL.md +++ /dev/null @@ -1,143 +0,0 @@ ---- -name: statusline-design -description: The design rules for an opencode-cockpit statusline (the Status bay), kept for 0.9 for anyone who copied this folder. The shipped status-setup skill replaces it and carries the same rules; prefer status-setup when it is available. Removed in 0.10. ---- - -> Kept for 0.9 only. `/status-setup` loads the **status-setup** skill shipped with this package, -> which carries these rules in `status-setup/references/design.md`. This copy is removed in 0.10. - -# Designing a statusline - -A statusline is a **visual artifact judged in a terminal**. The failure mode these rules exist to -prevent is designing it blind: editing TypeScript, restarting OpenCode, and scoring the result from -a sentence. One sidebar took about twenty restarts and five rejected iterations that way, and three -of the rejections were glyph choices that read completely differently on screen than they do in -prose. - -## Look at it before you ship it - -```sh - --watch # redraws on every save - --state full # one state -echo '{"status":{"override":{"write":false}}}' | --config - # a candidate on stdin, read as OpenCode will read it - --debug # name every row: ✓ drew, ✗ drew nothing, ? no such segment - --module mine.ts # that module alone, every segment it has -``` - -`` is the command `cockpit_settings` prints under **Previews** — this install's own copy. -Never `bunx`/`npx` it: that downloads another release. - -The preview draws the real segments against sample sessions, in this terminal, with no OpenCode -involved. **Use it after every change.** If a design decision cannot be checked in the preview, it -has not been checked. - -Before writing code for anything visual, **paste an ASCII mock and ask**. A mock costs a line; a -wrong reading of one ambiguous word costs two iterations. "Make it taller" once meant "make the bar -look solid", and the two interpretations share no code. - -## Rules with reasons - -| Rule | Why | -| --- | --- | -| **Never repeat what OpenCode already shows.** Its footer has path, branch, token total, spend; its prompt has agent and model. | Configured carelessly the same percentage lands on screen five times. The exception: a *better instrument* for the same fact — a bar you read without looking is not a second copy of `78.5K (39%)`. | -| **A segment with nothing to say says nothing.** | `cost` hides where no prices are declared rather than printing `$0.00`; `context` hides with no declared window rather than inventing a denominator. A confident wrong number is worse than an absent one. | -| **No walls of zeroes on a fresh session.** Check the `fresh` and `empty` fixtures. | Most designs look right mid-session and read as broken before the first reply. | -| **Every number gets a word.** Colour may repeat the meaning, never carry it alone. | A row distinguished only by colour is unreadable: "I read `mix` and I don't understand the colours." | -| **Labels in a fixed-width column, values after.** | Alignment is what makes a column read as designed rather than as output. | -| **Bars are solid.** Filled cells `█` coloured by level; the empty track is `█` in **`border`** tone. | `░` reads as floating gaps and `─` reads as `-----`. Both were rejected on sight. Swapping one rejected glyph for another is not iteration. `panel` is the colour of the panel the bar sits on, so a track drawn in it is invisible. | -| **Single-width glyphs only.** | An emoji is two cells in most terminals and one in a few — exactly what shears a fixed-width line. | -| **Prefer a coloured rule `▌` to a filled pill.** | A filled block must be as wide as its text, so a short label leaves a slab of colour and an empty one leaves an empty box. | -| **Prefer a figure to a moving picture.** | A sparkline redraws its shape every second; movement in peripheral vision is the one thing a statusline must not do. `+1.2%/min · 48m left` changes digits and nothing else. | -| **No section headings above optional rows.** | A heading cannot know whether the rows under it will draw, so `SPEND` strands itself above nothing on an unpriced model. Self-label the rows instead. | -| **Emphasis is a bonus, never the meaning.** Bold, italic and underline are ``, ``, `` markup — and a terminal with no bold face draws bold identically to plain. | Colour and background always render; weight may not. There is no strikethrough or inverse at all. | -| **Colours come from tones, not hexes.** `text muted accent success warning error info background panel border` | A literal ignores the user's theme, which is the first thing that makes a plugin look bolted on. Use a hex only where the exact colour *is* the meaning. | -| **No end caps on a bar in a column.** `▕` and `▏` are eighth-blocks whose ink sits hard against one edge of the cell. | An opening cap indents the row by most of a column, and the bar stops lining up with the labels above and below it. Caps are fine on a line, where nothing has to align. | -| **Print a number once.** A bar and the figure beside it are one row; the same percentage on the row below is a second copy. | In a column of ten rows the repeated figure is the thing the eye catches on. Let the bar be cells and let the labelled row carry the number. | -| **Say what a number means in the word, not the docs.** `cache` is cache reads, `write` is cache writes, `in` is fresh prompt tokens, `out` is output plus reasoning. | Read and write are not in and out; a reader who has to learn your mapping will misread it. | - -## The six states, and what each one catches - -A design is judged mid-session and ships broken everywhere else. `--state ` draws one; with -no flag the preview draws all six. Every one of these has caught something real: - -| State | What it is | What it catches | -| --- | --- | --- | -| `fresh` | a session before the first reply: no model, no tokens, no cost | the wall of zeroes, and `0%` against a window nobody has declared | -| `working` | a few turns in, most of the window served from cache | the ordinary case — and that `cache` dwarfs `in` and `out`, which a layout has to survive | -| `full` | nearly out of room, a long session | the widest every number gets: `191.6k`, `100%`, four-figure spend. Column widths that only fit `85.2k` shear here | -| `unpriced` | behind a proxy, nothing declared in the catalogue | segments that invent `$0.00` rather than staying silent | -| `retrying` | a stalled turn, retry pending | a status that reads "busy" forever, and rows that vanish mid-turn because a streaming message reports zeroes | -| `empty` | no session at all — what a window shows at start-up | the half of the line that is mounted before anything exists | - -Two rules fall out of them: **check `fresh` and `empty` before you call anything done**, because -they are what a new user sees first; and **size every column against `full`**, not against the -state you happen to be looking at. - -## The renderer's contract - -- One segment draws **one row**, unless it returns an **array** of pieces — then each element is a - row of its own. (Arrays used to be silently dropped; they work now.) -- A segment returns a string, `{ text, tone, color }`, `{ runs: [...] }`, an array of those, or - `undefined` to say nothing. -- A run takes `tone`, `color`, `bg`, `bgTone`, `bold`, `dim`, `italic`, `underline`. -- **A track drawn in `panel` tone is invisible** on most themes — it is the panel's own colour. - Use `border`. -- A **column** keeps at most `sidebarRows` rows (a line in `lines` says `maxRows`; 14 for the - `sidebar` preset, 8 for any other column) — **count your rows and raise it**, or the extras - vanish. The preview prints `↳ N dropped` when this happens. -- A `sep` hairline draws only with a row on either side of it, so a group that says nothing does - not strand one. -- A **line** drops the lowest-priority segments until it fits the width. -- A segment that throws loses only its own row. A module that fails to load raises a toast naming - the file. - -## Checking what actually reached the terminal - -`preview` paints with its own ANSI and the smoke harness serialises the screen as text, so both are -blind to colour and emphasis. When a design looks wrong and the code looks right: - -```sh -bun run capture --sidebar --find "40%" --find bold -``` - -It drives a real OpenCode and reports the escape codes written around the text you name. That is -how three separate "is this even working" questions were settled in minutes rather than rounds: -bold *was* being emitted and the font had no bold face; italic *was* being emitted and the word was -truncated; a track *was* being drawn and `panel` was the panel's own colour. - -## Where everything lives - -| What | Where | -| --- | --- | -| Settings, every project | the `"status"` section of `~/.config/opencode-cockpit/config.json` | -| Settings, one project | the `"status"` section of `/.cockpit.json` | -| Old names | `"statusline"`, Status keys at the file's root, a bay-level `maxRows`, `sidebarOrder`: not read; each is a `!` row. Write `"status"`, `sidebarRows`, and the top-level `"sidebar"` list | -| Modules | anywhere — `~/.config/opencode-cockpit/modules/` needs no `node_modules` beside it | -| Which plugins load | `~/.config/opencode/tui.json` | - -## Turning OpenCode's own blocks off - -Each block of the host's sidebar is an internal plugin that its config disables. OpenCode 1, in -`tui.json`: - -```jsonc -{ "plugin": ["opencode-cockpit"], "plugin_enabled": { "internal:sidebar-context": false } } -``` - -OpenCode 2, in `cli.json`: `{ "plugins": ["opencode-cockpit", "-opencode.sidebar.context"] }` -(`-internal:sidebar-context` does nothing there, and it has no LSP or Todo block). - -OpenCode 1 names: `internal:sidebar-{context,files,todo,lsp,mcp,footer}`, `internal:home-{footer,tips}`, -`internal:notifications`. Never suggest turning the Todo block off: nothing in Cockpit replaces it. **A sidebar meant to replace the Context block must carry what that block -carried** — percentage, token total, spend — or the user ends up with less than before. - -The footer under the prompt is core UI: it cannot be hidden. Design around it. - -## Start simple - -Most people want a good line, not a composition exercise. The default is the `sidebar` preset — the -table this skill's rules were learned on: `title`, a `solid` context bar, a `tokens` row, `in` `out` -`cache` `write`, `sep`, `spend` `avail` (a proxy's budget file; silent without one), `sep`, `git`. -Begin with the presets and the built-ins and a `format` string; reach for a module only when the answer needs the session read, decided on, or remembered -across ticks — a rate, a trend, a budget from a file. Reach for a shell `command` for anything a CLI -already prints; do not reimplement the shell as a segment. diff --git a/packages/status/src/cli/preview.ts b/packages/status/src/cli/preview.ts index 3c05c001..9ca5c91e 100644 --- a/packages/status/src/cli/preview.ts +++ b/packages/status/src/cli/preview.ts @@ -15,9 +15,9 @@ import { readFileSync, watch } from "node:fs" import { homedir } from "node:os" -import { budgetFile, readBudget } from "../core/budget.ts" -import { type ResolvedLine, resolveLines, type Surface } from "../core/config.ts" -import { loadCustomSegments, resolveModulePath } from "../core/custom.ts" +import { budgetFile } from "../core/budget.ts" +import { type ResolvedLine, resolveLines, type Surface } from "../core/config/index.ts" +import { resolveModulePath } from "../core/custom.ts" import { FIXTURES, type FixtureName } from "../core/fixtures.ts" import { moduleNoticeText } from "../core/notices.ts" import { @@ -29,6 +29,8 @@ import { SIDEBAR_WIDTH, } from "../core/preview.ts" import type { SegmentDef } from "../core/segments.ts" +import { readBudget } from "../io/budget.ts" +import { loadCustomSegments } from "../io/custom.ts" import { paintRuns as paintColour } from "./ansi.ts" const args = parseArgs(process.argv.slice(2)) @@ -84,8 +86,18 @@ const surface = flag("surface") as Surface | undefined * `override` — and draws the same `!` rows for what it will not read. A file that cannot be read * stops the preview: the defaults drawn in its place would look like a file that changed nothing. */ +/** The other file `--as` leaves in place, read as OpenCode reads it: missing or unreadable is none. */ +const readText = (path: string): string | undefined => { + try { + return readFileSync(path, "utf8") + } catch { + return undefined + } +} + function settings() { - if (stdinText !== undefined) return previewSettings({ directory, configText: stdinText, as, surface }) + if (stdinText !== undefined) + return previewSettings({ directory, configText: stdinText, as, surface, readFile: readText }) if (!configPath) return previewSettings({ directory, surface }) let configText: string try { @@ -94,7 +106,7 @@ function settings() { console.error(` cannot read --config ${configPath}: ${(error as Error).message}`) process.exit(2) } - return previewSettings({ directory, configText, surface, ...(as ? { as } : {}) }) + return previewSettings({ directory, configText, surface, ...(as ? { as, readFile: readText } : {}) }) } /** diff --git a/packages/status/src/core/authoring.ts b/packages/status/src/core/authoring.ts index dcb2a6a6..1fb00971 100644 --- a/packages/status/src/core/authoring.ts +++ b/packages/status/src/core/authoring.ts @@ -14,7 +14,7 @@ */ export { GAUGE, gaugeTone } from "@opencode-cockpit/client/design" export type { ClaudeCodeStatusInput } from "./claude-code.ts" -export type { SegmentConfig } from "./config.ts" +export type { SegmentConfig } from "./config/index.ts" export type { ServiceSnapshot, SessionSnapshot, diff --git a/packages/status/src/core/budget.ts b/packages/status/src/core/budget.ts index 63b44680..126fd8a7 100644 --- a/packages/status/src/core/budget.ts +++ b/packages/status/src/core/budget.ts @@ -7,7 +7,6 @@ * it say nothing: a made-up figure would be worse than none. */ -import { readFileSync } from "node:fs" import { homedir } from "node:os" import { join, resolve } from "node:path" @@ -39,14 +38,6 @@ export function parseBudget(text: string | undefined): Budget | undefined { return { spent: baseline + (typeof delta === "number" ? delta : 0), cap } } -export function readBudget(path: string): Budget | undefined { - try { - return parseBudget(readFileSync(path, "utf8")) - } catch { - return undefined - } -} - const BUDGET_SEGMENTS = new Set(["spend", "avail"]) /** diff --git a/packages/status/src/core/builtins/session.ts b/packages/status/src/core/builtins/session.ts index 574211e2..02fa3798 100644 --- a/packages/status/src/core/builtins/session.ts +++ b/packages/status/src/core/builtins/session.ts @@ -1,7 +1,7 @@ /** How the session is going: work outstanding, work in progress, time spent. */ import { toneOf } from "@opencode-cockpit/client/design" -import type { SegmentConfig } from "../config.ts" +import type { SegmentConfig } from "../config/index.ts" import { todoRemaining } from "../context.ts" import { duration, preciseDuration } from "../format.ts" import type { SegmentDef } from "../types.ts" diff --git a/packages/status/src/core/builtins/settings.ts b/packages/status/src/core/builtins/settings.ts index ba4e96b6..7c55d3b4 100644 --- a/packages/status/src/core/builtins/settings.ts +++ b/packages/status/src/core/builtins/settings.ts @@ -1,6 +1,6 @@ /** Reading a segment's own settings out of its config entry, safely. */ -import type { SegmentConfig } from "../config.ts" +import type { SegmentConfig } from "../config/index.ts" import type { Piece, Run, Tone } from "../types.ts" export function num(config: SegmentConfig, key: string, fallback: number): number { diff --git a/packages/status/src/core/command.ts b/packages/status/src/core/command.ts index 8108f4ab..a6892933 100644 --- a/packages/status/src/core/command.ts +++ b/packages/status/src/core/command.ts @@ -7,7 +7,7 @@ */ import { claudeCodeInput } from "./claude-code.ts" -import type { CommandConfig } from "./config.ts" +import type { CommandConfig } from "./config/index.ts" import type { StatusContext } from "./context.ts" /** @@ -79,22 +79,3 @@ export function createRunner(config: CommandConfig, host: RunnerHost): CommandRu }, } } - -/** The real shell, used outside tests. */ -export async function execShell(command: string, stdin: string, timeoutMs: number): Promise { - const proc = Bun.spawn(["/bin/sh", "-c", command], { - stdin: new TextEncoder().encode(stdin), - stdout: "pipe", - stderr: "ignore", - env: process.env, - }) - const timer = setTimeout(() => proc.kill(), timeoutMs) - try { - const out = await new Response(proc.stdout).text() - const code = await proc.exited - if (code !== 0) throw new Error(`exit ${code}`) - return out - } finally { - clearTimeout(timer) - } -} diff --git a/packages/status/src/core/config.ts b/packages/status/src/core/config.ts deleted file mode 100644 index 7e9b93fc..00000000 --- a/packages/status/src/core/config.ts +++ /dev/null @@ -1,616 +0,0 @@ -import { - baySettings, - closestName, - cockpitNotices, - noticeText, - OPTIONS_SOURCE, - type Settings, - type SettingsNotice, - type SettingsWhere, -} from "@opencode-cockpit/client/settings" - -/** - * Status's settings: the `status` section of the files every cockpit bay reads, through the one - * loader in `@opencode-cockpit/client/settings`: - * - * ~/.config/opencode-cockpit/config.json → /.cockpit.json → plugin-entry options - * - * Only `status` is read. `statusline` (the section's name until 0.9) and keys at the file's root are - * old names: the loader recognises them and the bay draws a `!` row for each, but their values are - * not read. A file that cannot be parsed is a notice too, never the end of the interface. - */ - -/** - * Where a line is drawn. - * - * Two surfaces, deliberately. A third sat inside the prompt box, which is both the narrowest place - * in the window and the one OpenCode already fills with the agent, the model and the elapsed time: - * a line there had almost no room and almost nothing left to say. - */ -export type Surface = "bottom" | "sidebar" - -/** Where Status draws when nothing says otherwise. The sidebar since 0.9. */ -export const DEFAULT_SURFACE: Surface = "sidebar" - -/** - * A segment is either a built-in named by string ("cwd"), or that name with settings. `when` and - * `priority` are what make a line survive a narrow terminal instead of wrapping into noise. - */ -export interface SegmentConfig { - type: string - /** Text placed before the value, e.g. "on ". */ - prefix?: string - suffix?: string - /** Higher survives when the line has to be shortened. Defaults per built-in. */ - priority?: number - /** Theme colour name (`success`, `warning`, `textMuted`…) or a literal `#rrggbb`. */ - color?: string - /** Built-in specific settings, e.g. `{ "style": "bar" }` for context. */ - [key: string]: unknown -} - -export interface CommandConfig { - /** Shell command whose stdout becomes the segment's text. */ - run: string - /** How often it may run. Defaults to 2000ms; it never runs more than once at a time. */ - intervalMs?: number - /** Kill and ignore the output after this long. Defaults to 1000ms. */ - timeoutMs?: number - /** - * Feed the command Claude Code's statusline JSON on stdin, so an existing statusline script - * works unchanged. On by default. - */ - claudeCodeCompat?: boolean - priority?: number -} - -/** - * How a line lays its segments out. A wide line under the prompt reads across; a sidebar four - * columns wide reads down. - */ -export type Stack = "horizontal" | "vertical" - -/** - * One segment's change, on top of the preset's list (or `segments`): `false` drops it, a name swaps - * it for that segment in the same place, an object merges into its settings. - */ -export type SegmentChange = false | string | Record - -/** - * Changes to a line's segments, keyed by segment name: `{ "git": { "against": "branch" } }`. A - * small change used to mean copying the preset's whole list into `segments`, which then stopped - * following the preset — and one wrong entry in fourteen was a row gone with no word said. - */ -export type Override = Record - -export interface LineConfig { - /** A whole line by name; anything written beside it wins. */ - preset?: string - surface?: Surface - segments?: (string | SegmentConfig)[] - /** Changes to the preset's segments, or to `segments`, by segment name. */ - override?: Override - /** Drawn between segments. Defaults to " · " across, and nothing down. */ - separator?: string - /** Defaults to vertical in the sidebar, horizontal everywhere else. */ - stack?: Stack - /** Built-in icons. On by default; switch off for a terminal missing the glyphs. */ - icons?: boolean - /** Draw a placeholder where a segment said nothing, so a typo and missing data look different. */ - debug?: boolean - /** Vertical only: rows to draw at most. Lowest priority goes first. The bay's `sidebarRows` by default. */ - maxRows?: number - /** - * Columns of space either side. The defaults line each surface up with OpenCode's own - * furniture -- its footer indents three, its prompt keeps two clear on the right -- so the line - * reads as part of the interface rather than as something bolted underneath it. - */ - paddingLeft?: number - paddingRight?: number - paddingTop?: number - paddingBottom?: number -} - -/** The `status` section. */ -export interface StatusConfig { - enabled?: boolean - /** - * Draw in the sidebar: the key every bay shares. `false` is read as `surface: "bottom"`, so the - * one switch works here as it does everywhere; `surface` says the same thing in Status's words. - */ - sidebar?: boolean - /** - * A whole line by name: `minimal`, `default`, `detailed`, `sidebar`. Anything you write - * alongside it wins, so a preset is a starting point rather than a mode. - */ - preset?: string - /** One line, for the common case. Use `lines` for more than one surface. */ - surface?: Surface - segments?: (string | SegmentConfig)[] - /** - * Changes to the preset's segments by name, so changing one row keeps the rest of the preset: - * `false` drops a segment, a name swaps it, an object merges into its settings. With `segments` - * written too, the changes apply to those. A line in `lines` may carry its own. - */ - override?: Override - separator?: string - stack?: Stack - /** Built-in icons. On by default; switch off for a terminal missing the glyphs. */ - icons?: boolean - /** Draw a placeholder where a segment said nothing, so a typo and missing data look different. */ - debug?: boolean - /** - * Rows a column draws at most: the name every bay's sidebar block uses. Inside `lines`, a line's - * own cap is still `maxRows`. - */ - sidebarRows?: number - paddingLeft?: number - paddingRight?: number - paddingTop?: number - paddingBottom?: number - lines?: LineConfig[] - /** Named commands usable as segments: `{"type": "command", "name": "budget"}`. */ - commands?: Record - /** - * Your own segments: paths to modules that export them by name, usable in `segments` exactly - * like the built-ins. `~` and a path relative to the project both work. - */ - modules?: string[] -} - -/** - * The kind of value each key takes, as the loader checks it: a value of another kind is dropped - * with a `!` row naming the key, where it used to reach the renderer and fail there — or nowhere. - */ -export const KINDS = { - preset: "", - surface: "", - segments: [] as unknown[], - override: {} as Record, - separator: "", - stack: "", - icons: true, - debug: false, - paddingLeft: 0, - paddingRight: 0, - paddingTop: 0, - paddingBottom: 0, - lines: [] as unknown[], - commands: {} as Record, - modules: [] as unknown[], -} - -const SURFACES: readonly string[] = ["bottom", "sidebar"] - -export interface LoadedStatus { - /** Every source merged, as written: the gaps are `resolveLines`'s to fill. */ - config: StatusConfig - /** The block's place in the sidebar, from the top-level `sidebar` list. */ - order: number - /** - * What to fix, one `!` row each: Status's own settings, and — because Status is the one bay every - * install draws — the notices that belong to no bay (a file that would not parse, a top-level - * name nothing reads, an entry in the `sidebar` list that is not a bay). - */ - notices: string[] - settings: Settings -} - -export interface StatusInput { - /** The plugin entry's options: the section's own keys, or a whole config with a `status` section. */ - options?: unknown - where?: SettingsWhere - /** Already loaded, as `cockpit_settings` and doctor have them. */ - settings?: Settings -} - -/** Reads and merges every source. Never throws. */ -export function loadStatus(input: StatusInput = {}): LoadedStatus { - const loaded = baySettings("status", KINDS, { - options: input.options, - ...(input.settings ? { settings: input.settings } : { where: input.where }), - }) - const written = loaded.written as StatusConfig - const config: StatusConfig = { ...written } - /** `features.status: false` turns the bay off as `enabled: false` does. */ - if (!loaded.config.enabled) config.enabled = false - /** The shared switch, in Status's words: off the sidebar means at the bottom. */ - if (written.sidebar === false && written.surface === undefined) config.surface = "bottom" - if (typeof written.sidebarRows === "number") config.sidebarRows = loaded.config.sidebarRows - /** - * Modules add up rather than replace: a project can bring its own segments without losing the ones - * you use everywhere. Every other list replaces the one before it, as in every bay. - */ - const modules = [ - ...loaded.settings.layers.flatMap((layer) => strings(layer.sections.status?.modules)), - ...strings(optionsSection(input.options)?.modules), - ] - if (modules.length > 0) config.modules = [...new Set(modules)] - else delete config.modules - - const own = [...cockpitNotices(loaded.settings), ...loaded.notices].map(noticeText) - return { - config, - order: loaded.order, - notices: [...own, ...configNotices(config)], - settings: loaded.settings, - } -} - -const isObject = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value) - -const strings = (value: unknown): string[] => - Array.isArray(value) ? value.filter((each): each is string => typeof each === "string") : [] - -/** Plugin options as the section: a whole config's `status`, else the options themselves. */ -function optionsSection(options: unknown): Record | undefined { - if (!isObject(options)) return undefined - return isObject(options.status) ? options.status : options -} - -/** One thing wrong in Status's settings that only Status can tell, and the key it is about. */ -export interface StatusProblem { - /** The section key it is about: `preset`, `surface`, `override`, or `lines` for one of the lines'. */ - key: "preset" | "surface" | "override" | "lines" - /** Without the `settings: ` the row adds. */ - text: string -} - -/** - * What the loader cannot know is wrong, because only Status knows its vocabulary: a preset nothing - * answers to, a surface that does not exist. Each used to fall back in silence — an unknown preset - * was quietly the default line, which looks like a preset that does nothing. - */ -export function configNotices(config: StatusConfig): string[] { - return configProblems(config).map((problem) => `settings: ${problem.text}`) -} - -/** The same, with the key each one is about: for a notice that names its file (`statusNotices`). */ -export function configProblems(config: StatusConfig): StatusProblem[] { - const out: StatusProblem[] = [] - const names = Object.keys(PRESETS).join(", ") - const lines: LineConfig[] = [config, ...(Array.isArray(config.lines) ? config.lines : [])] - for (const [index, line] of lines.entries()) { - const where = index === 0 ? "status" : `status.lines[${index - 1}]` - const key = index === 0 ? undefined : "lines" - /** The name first: in a 24-column sidebar it is what survives the wrap. */ - if (typeof line.preset === "string" && !PRESETS[line.preset]) { - out.push({ key: key ?? "preset", text: `no preset "${line.preset}" (${names})` }) - } - if (line.surface !== undefined && !SURFACES.includes(line.surface)) { - out.push({ key: key ?? "surface", text: `"${where}.surface" is "sidebar" or "bottom"` }) - } - } - return [...out, ...overrideProblems(config)] -} - -/** - * An override that changes nothing is said out loud: `"gti"` matching no segment would otherwise - * be a row that kept its old look with no word as to why. A section-wide override is checked against - * every line that uses it, and is only wrong when it matches none of them. - */ -function overrideProblems(config: StatusConfig): StatusProblem[] { - const out: StatusProblem[] = [] - const checked = new Map }>() - for (const [index, source] of lineSources(config).entries()) { - const raw = source.line.override ?? config.override - const where = source.line.override !== undefined && config.lines?.length ? `status.lines[${index}].` : "" - if (raw === undefined) continue - if (!isObject(raw)) { - out.push({ - key: where ? "lines" : "override", - text: `"${where || "status."}override" should be an object of segment names`, - }) - continue - } - const base = baseSegments(source.line, config) - const seen = checked.get(raw) ?? { where, from: base.from, types: new Set() } - for (const entry of base.segments) seen.types.add(segmentType(entry)) - checked.set(raw, seen) - } - for (const [override, { where, from, types }] of checked) { - const key = where ? "lines" : "override" - for (const [name, change] of Object.entries(override)) { - if (!isChange(change)) { - out.push({ - key, - text: `${where}override "${name}" is false, a segment name, or an object of its settings`, - }) - } else if (!types.has(name)) { - const meant = closestSegment(name, [...types]) - out.push({ - key, - text: `${where}override "${name}" matches no segment in ${from}${meant ? ` — did you mean "${meant}"?` : ""}`, - }) - } - } - } - return out -} - -/** - * Every notice Status draws for these settings, as notices — the loader's, its own keys' kinds, and - * its vocabulary's — each naming the file its key was written in. Offered to `cockpit_settings` and - * doctor (`offerSettingsCheck`), so "Notices: none" there means no `!` row here. - */ -export function statusNotices(input: { settings: Settings; options?: unknown }): SettingsNotice[] { - const loaded = loadStatus({ options: input.options, settings: input.settings }) - const options = optionsSection(input.options) - /** The last source that wrote the key: plugin options win, then the project file, then the global. */ - const fileOf = (key: StatusProblem["key"]) => - options && key in options - ? OPTIONS_SOURCE - : ([...input.settings.layers] - .reverse() - .find((layer) => layer.sections.status && key in layer.sections.status)?.path ?? "status") - const own = baySettings("status", KINDS, { options: input.options, settings: input.settings }).notices - return [ - ...own, - ...configProblems(loaded.config).map( - (problem): SettingsNotice => ({ - bay: "status", - file: fileOf(problem.key), - kind: "invalid", - text: problem.text, - }), - ), - ] -} - -/** The name a typo most likely meant: the same letters in another order first (`gti` → `git`). */ -function closestSegment(name: string, valid: string[]): string | undefined { - const letters = (text: string) => [...text].sort().join("") - return valid.find((each) => letters(each) === letters(name)) ?? closestName(name, valid) -} - -const isChange = (change: unknown): change is SegmentChange => - change === false || (typeof change === "string" && change.length > 0) || isObject(change) - -const segmentType = (entry: string | SegmentConfig) => (typeof entry === "string" ? entry : entry.type) - -/** - * A line's segments with an override applied, each change in the place of the segment it names — - * every segment of that name, so `{ "sep": false }` takes out every hairline. A change that is not - * one (`true`, a number) leaves the segment as it was; `configNotices` says so. - */ -export function applyOverride( - segments: readonly (string | SegmentConfig)[], - override: unknown, -): (string | SegmentConfig)[] { - if (!isObject(override)) return [...segments] - const out: (string | SegmentConfig)[] = [] - for (const entry of segments) { - const type = segmentType(entry) - const change = Object.hasOwn(override, type) ? override[type] : undefined - if (!isChange(change)) out.push(entry) - else if (change === false) continue - else if (typeof change === "string") out.push(change) - else out.push({ ...asSegmentConfig(entry), ...change } as SegmentConfig) - } - return out -} - -/** - * The default line: what someone who writes nothing at all should see. - * - * It took a long walk to arrive here, and the shape is the point. A capacity bar that means - * something at a glance, the total beside the three quantities that make it up, what changed, how - * long the last answer took. Words say which is which, muted, with the figures beside them in the text colour; - * colour is left for what it signals — the bar's level, what was added and removed, a retry. It used - * to carry the labels too, and three greens on one line meant three different things. - * - * It does repeat one thing OpenCode already shows -- the token count and the percentage, which its - * footer carries in a corner. That is deliberate. The rule is not to avoid every fact the host - * mentions, it is to avoid saying it no better than the host does: a bar you can read without - * looking, with the breakdown beside it, is a different instrument from "78.5K (39%)" in the - * corner. What stays out are the facts a second copy adds nothing to -- the path, the branch, the - * model, the spend. - */ -export const DEFAULT_SEGMENTS: (string | SegmentConfig)[] = [ - { type: "context", style: "bar", width: 14, icon: "" }, - { type: "tokens", format: "tk {total}", icon: "" }, - { type: "tokens", format: "cache {cacheRead}", icon: "" }, - { type: "tokens", format: "in {input}", icon: "" }, - { type: "tokens", format: "out {output}", icon: "" }, - { type: "git.diff", icon: "" }, - { type: "session.time", of: "turn", icon: "" }, - "todo", - "session.status", - "diagnostics", -] - -export const DEFAULT_SEPARATOR = " │ " - -/** - * The sidebar's column: a table. A heading, the window as one solid bar, the tokens broken into named - * rows, a proxy's budget, and the branch's whole diff. - * - * It is the layout a user arrived at after five rejected iterations, and the reasons are worth more - * than the rows: every number gets a word, the labels are a fixed column so the values line up, the - * bar is solid rather than dashed, and the groups are separated by hairlines rather than headings — a - * heading cannot know whether the rows under it will draw. A row whose figure is zero is not drawn, - * the budget rows say nothing without a proxy, and a hairline with nothing on one side of it goes too. - * - * It sits beside OpenCode's own Context block and says it better; turn that one off with - * `{ "plugin_enabled": { "internal:sidebar-context": false } }` in `tui.json` (OpenCode 1) or - * `"-opencode.sidebar.context"` in `cli.json`'s `plugins` (OpenCode 2). - */ -export const SIDEBAR_SEGMENTS: (string | SegmentConfig)[] = [ - "title", - { type: "context", style: "solid", width: 16, icon: "" }, - /** - * Why it stalled — `retry 2 in 5s` — which OpenCode shows as a spinner and nothing more. It is the - * reason this bay exists, so it outranks everything but the bar when rows run out; under the bar - * rather than above it, so a row that comes and goes does not move the bar about. - */ - { type: "session.status", priority: 95, icon: "", working: false }, - /** - * A broken MCP or language server — `! github, linear +2` in red — and nothing while every one is - * healthy. The table is the default now, so it is where most people would ever learn one failed. - */ - "diagnostics", - { type: "tokens", style: "row", icon: "" }, - "in", - "out", - "cache", - "write", - "sep", - "spend", - "avail", - "sep", - "git", -] - -/** - * Whole lines, by the name of what you want. - * - * Composing a good statusline from fourteen segments is a design exercise, and most people want a - * good line rather than the exercise. Every preset is built-ins only — none needs a module, a - * command, or anything installed beside it. - */ -export const PRESETS: Record< - string, - { about: string; surface: Surface; segments: (string | SegmentConfig)[]; maxRows?: number } -> = { - minimal: { - about: "how full the context is, and what changed", - surface: "bottom", - segments: [ - { type: "context", style: "bar", width: 12, icon: "" }, - { type: "git.diff", icon: "" }, - "session.status", - "diagnostics", - ], - }, - default: { - about: "the capacity bar, where the tokens went, what changed, how long", - surface: "bottom", - segments: DEFAULT_SEGMENTS, - }, - detailed: { - about: "everything the built-ins know, for a wide window", - surface: "bottom", - segments: [ - { type: "context", style: "split", width: 14, icon: "" }, - { type: "tokens", style: "parts", icon: "" }, - { type: "model", icon: "" }, - "cost", - { type: "git.diff", icon: "" }, - "todo", - { type: "session.time", of: "turn", icon: "" }, - "session.status", - "diagnostics", - ], - }, - sidebar: { - about: "a table: the window, where the tokens went, a proxy's budget, the branch's diff", - surface: "sidebar", - segments: SIDEBAR_SEGMENTS, - /** Every row it has, on a busy session with a budget: the table is the point of it. */ - maxRows: 14, - }, -} - -/** What a line draws when it names no preset and lists no segments: the surface's own. */ -const PRESET_FOR: Record = { sidebar: "sidebar", bottom: "default" } - -export interface ResolvedLine { - surface: Surface - segments: (string | SegmentConfig)[] - separator: string - stack: Stack - maxRows: number - icons: boolean - debug: boolean - paddingLeft: number - paddingRight: number - paddingTop: number - paddingBottom: number -} - -/** What each surface needs to sit level with the host's own content. */ -const PADDING: Record = { - // OpenCode's footer indents three columns, and a line hard against the bottom of the window - // reads as clipped, so this one keeps a row clear underneath it. - bottom: { left: 3, right: 2, top: 0, bottom: 1 }, - // Flush with the sidebar's own content, which the shell bay draws with no padding at all. - sidebar: { left: 0, right: 0, top: 0, bottom: 0 }, -} - -/** Rows a column draws when neither the line, the bay nor the preset says. */ -const MAX_ROWS = 8 - -/** The lines as written: `lines`, or the section itself as the one line. */ -function lineSources(config: StatusConfig): { line: LineConfig }[] { - if (config.lines?.length) return config.lines.map((line) => ({ line })) - return [ - { - line: { - preset: config.preset, - surface: config.surface, - segments: config.segments, - override: config.override, - separator: config.separator, - stack: config.stack, - icons: config.icons, - debug: config.debug, - }, - }, - ] -} - -/** - * Where a line draws and the segments it starts from, before its override — and what to call that - * list in a notice: `the sidebar preset`, or the `segments` that were written. - */ -function baseSegments( - line: LineConfig, - config: StatusConfig, -): { surface: Surface; segments: (string | SegmentConfig)[]; from: string; maxRows?: number } { - // A preset fills in what was not written; it never overrides what was. - const name = line.preset ?? config.preset ?? "" - const named = PRESETS[name] - const asked = line.surface ?? config.surface - const surface: Surface = SURFACES.includes(asked ?? "") - ? (asked as Surface) - : (named?.surface ?? DEFAULT_SURFACE) - /** No preset by a name that exists: the one for the surface, so the sidebar is never blank. */ - const presetName = named ? name : PRESET_FOR[surface] - const preset = PRESETS[presetName] - const written = line.segments ?? config.segments - return { - surface, - segments: written ?? preset?.segments ?? DEFAULT_SEGMENTS, - from: written ? '"segments"' : `the ${presetName} preset`, - ...(preset?.maxRows !== undefined ? { maxRows: preset.maxRows } : {}), - } -} - -/** Normalises whatever the config said into the lines the renderer draws. */ -export function resolveLines(config: StatusConfig): ResolvedLine[] { - return lineSources(config).map(({ line }) => { - const base = baseSegments(line, config) - const surface = base.surface - // The sidebar is a narrow column: across, it would be three truncated words. - const stack = line.stack ?? config.stack ?? (surface === "sidebar" ? "vertical" : "horizontal") - return { - surface, - segments: applyOverride(base.segments, line.override ?? config.override), - separator: line.separator ?? config.separator ?? (stack === "vertical" ? "" : DEFAULT_SEPARATOR), - stack, - maxRows: line.maxRows ?? config.sidebarRows ?? base.maxRows ?? MAX_ROWS, - icons: line.icons ?? config.icons ?? true, - debug: line.debug ?? config.debug ?? false, - paddingLeft: line.paddingLeft ?? config.paddingLeft ?? PADDING[surface].left, - paddingRight: line.paddingRight ?? config.paddingRight ?? PADDING[surface].right, - paddingTop: line.paddingTop ?? config.paddingTop ?? PADDING[surface].top, - paddingBottom: line.paddingBottom ?? config.paddingBottom ?? PADDING[surface].bottom, - } - }) -} - -/** A segment written as a bare string is that built-in with no settings. */ -export function asSegmentConfig(entry: string | SegmentConfig): SegmentConfig { - return typeof entry === "string" ? { type: entry } : entry -} diff --git a/packages/status/src/core/config/index.ts b/packages/status/src/core/config/index.ts new file mode 100644 index 00000000..1d92cfc7 --- /dev/null +++ b/packages/status/src/core/config/index.ts @@ -0,0 +1,35 @@ +/** + * Status's settings: the `status` section of the files every cockpit bay reads, through the one + * loader in `@opencode-cockpit/client/settings`: + * + * ~/.config/opencode-cockpit/config.json → /.cockpit.json → plugin-entry options + * + * Only `status` is read: not `statusline` (the section's name until 0.9), not keys at the file's + * root. A top-level name nothing reads is a `!` row; a file that cannot be parsed is a notice too, + * never the end of the interface. + */ + +export { + applyOverride, + asSegmentConfig, + DEFAULT_SEGMENTS, + DEFAULT_SEPARATOR, + PRESETS, + type ResolvedLine, + resolveLines, + SIDEBAR_SEGMENTS, +} from "./lines.ts" +export { type LoadedStatus, loadStatus, type StatusInput, statusNotices } from "./load.ts" +export { configNotices, configProblems, type StatusProblem } from "./problems.ts" +export { + type CommandConfig, + DEFAULT_SURFACE, + KINDS, + type LineConfig, + type Override, + type SegmentChange, + type SegmentConfig, + type Stack, + type StatusConfig, + type Surface, +} from "./shape.ts" diff --git a/packages/status/src/core/config/lines.ts b/packages/status/src/core/config/lines.ts new file mode 100644 index 00000000..2d76ee06 --- /dev/null +++ b/packages/status/src/core/config/lines.ts @@ -0,0 +1,262 @@ +import { + DEFAULT_SURFACE, + isObject, + type LineConfig, + type SegmentChange, + type SegmentConfig, + type Stack, + type StatusConfig, + SURFACES, + type Surface, +} from "./shape.ts" + +export const isChange = (change: unknown): change is SegmentChange => + change === false || (typeof change === "string" && change.length > 0) || isObject(change) + +export const segmentType = (entry: string | SegmentConfig) => (typeof entry === "string" ? entry : entry.type) + +/** + * A line's segments with an override applied, each change in the place of the segment it names — + * every segment of that name, so `{ "sep": false }` takes out every hairline. A change that is not + * one (`true`, a number) leaves the segment as it was; `configNotices` says so. + */ +export function applyOverride( + segments: readonly (string | SegmentConfig)[], + override: unknown, +): (string | SegmentConfig)[] { + if (!isObject(override)) return [...segments] + const out: (string | SegmentConfig)[] = [] + for (const entry of segments) { + const type = segmentType(entry) + const change = Object.hasOwn(override, type) ? override[type] : undefined + if (!isChange(change)) out.push(entry) + else if (change === false) continue + else if (typeof change === "string") out.push(change) + else out.push({ ...asSegmentConfig(entry), ...change } as SegmentConfig) + } + return out +} + +/** + * The default line: what someone who writes nothing at all should see. + * + * It took a long walk to arrive here, and the shape is the point. A capacity bar that means + * something at a glance, the total beside the three quantities that make it up, what changed, how + * long the last answer took. Words say which is which, muted, with the figures beside them in the text colour; + * colour is left for what it signals — the bar's level, what was added and removed, a retry. It used + * to carry the labels too, and three greens on one line meant three different things. + * + * It does repeat one thing OpenCode already shows -- the token count and the percentage, which its + * footer carries in a corner. That is deliberate. The rule is not to avoid every fact the host + * mentions, it is to avoid saying it no better than the host does: a bar you can read without + * looking, with the breakdown beside it, is a different instrument from "78.5K (39%)" in the + * corner. What stays out are the facts a second copy adds nothing to -- the path, the branch, the + * model, the spend. + */ +export const DEFAULT_SEGMENTS: (string | SegmentConfig)[] = [ + { type: "context", style: "bar", width: 14, icon: "" }, + { type: "tokens", format: "tk {total}", icon: "" }, + { type: "tokens", format: "cache {cacheRead}", icon: "" }, + { type: "tokens", format: "in {input}", icon: "" }, + { type: "tokens", format: "out {output}", icon: "" }, + { type: "git.diff", icon: "" }, + { type: "session.time", of: "turn", icon: "" }, + "todo", + "session.status", + "diagnostics", +] + +export const DEFAULT_SEPARATOR = " │ " + +/** + * The sidebar's column: a table. A heading, the window as one solid bar, the tokens broken into named + * rows, a proxy's budget, and the branch's whole diff. + * + * It is the layout a user arrived at after five rejected iterations, and the reasons are worth more + * than the rows: every number gets a word, the labels are a fixed column so the values line up, the + * bar is solid rather than dashed, and the groups are separated by hairlines rather than headings — a + * heading cannot know whether the rows under it will draw. A row whose figure is zero is not drawn, + * the budget rows say nothing without a proxy, and a hairline with nothing on one side of it goes too. + * + * It sits beside OpenCode's own Context block and says it better; turn that one off with + * `{ "plugin_enabled": { "internal:sidebar-context": false } }` in `tui.json` (OpenCode 1) or + * `"-opencode.sidebar.context"` in `cli.json`'s `plugins` (OpenCode 2). + */ +export const SIDEBAR_SEGMENTS: (string | SegmentConfig)[] = [ + "title", + { type: "context", style: "solid", width: 16, icon: "" }, + /** + * Why it stalled — `retry 2 in 5s` — which OpenCode shows as a spinner and nothing more. It is the + * reason this bay exists, so it outranks everything but the bar when rows run out; under the bar + * rather than above it, so a row that comes and goes does not move the bar about. + */ + { type: "session.status", priority: 95, icon: "", working: false }, + /** + * A broken MCP or language server — `! github, linear +2` in red — and nothing while every one is + * healthy. The table is the default now, so it is where most people would ever learn one failed. + */ + "diagnostics", + { type: "tokens", style: "row", icon: "" }, + "in", + "out", + "cache", + "write", + "sep", + "spend", + "avail", + "sep", + "git", +] + +/** + * Whole lines, by the name of what you want. + * + * Composing a good statusline from fourteen segments is a design exercise, and most people want a + * good line rather than the exercise. Every preset is built-ins only — none needs a module, a + * command, or anything installed beside it. + */ +export const PRESETS: Record< + string, + { about: string; surface: Surface; segments: (string | SegmentConfig)[]; maxRows?: number } +> = { + minimal: { + about: "how full the context is, and what changed", + surface: "bottom", + segments: [ + { type: "context", style: "bar", width: 12, icon: "" }, + { type: "git.diff", icon: "" }, + "session.status", + "diagnostics", + ], + }, + default: { + about: "the capacity bar, where the tokens went, what changed, how long", + surface: "bottom", + segments: DEFAULT_SEGMENTS, + }, + detailed: { + about: "everything the built-ins know, for a wide window", + surface: "bottom", + segments: [ + { type: "context", style: "split", width: 14, icon: "" }, + { type: "tokens", style: "parts", icon: "" }, + { type: "model", icon: "" }, + "cost", + { type: "git.diff", icon: "" }, + "todo", + { type: "session.time", of: "turn", icon: "" }, + "session.status", + "diagnostics", + ], + }, + sidebar: { + about: "a table: the window, where the tokens went, a proxy's budget, the branch's diff", + surface: "sidebar", + segments: SIDEBAR_SEGMENTS, + /** Every row it has, on a busy session with a budget: the table is the point of it. */ + maxRows: 14, + }, +} + +/** What a line draws when it names no preset and lists no segments: the surface's own. */ +const PRESET_FOR: Record = { sidebar: "sidebar", bottom: "default" } + +export interface ResolvedLine { + surface: Surface + segments: (string | SegmentConfig)[] + separator: string + stack: Stack + maxRows: number + icons: boolean + debug: boolean + paddingLeft: number + paddingRight: number + paddingTop: number + paddingBottom: number +} + +/** What each surface needs to sit level with the host's own content. */ +const PADDING: Record = { + // OpenCode's footer indents three columns, and a line hard against the bottom of the window + // reads as clipped, so this one keeps a row clear underneath it. + bottom: { left: 3, right: 2, top: 0, bottom: 1 }, + // Flush with the sidebar's own content, which the shell bay draws with no padding at all. + sidebar: { left: 0, right: 0, top: 0, bottom: 0 }, +} + +/** Rows a column draws when neither the line, the bay nor the preset says. */ +const MAX_ROWS = 8 + +/** The lines as written: `lines`, or the section itself as the one line. */ +export function lineSources(config: StatusConfig): { line: LineConfig }[] { + if (config.lines?.length) return config.lines.map((line) => ({ line })) + return [ + { + line: { + preset: config.preset, + surface: config.surface, + segments: config.segments, + override: config.override, + separator: config.separator, + stack: config.stack, + icons: config.icons, + debug: config.debug, + }, + }, + ] +} + +/** + * Where a line draws and the segments it starts from, before its override — and what to call that + * list in a notice: `the sidebar preset`, or the `segments` that were written. + */ +export function baseSegments( + line: LineConfig, + config: StatusConfig, +): { surface: Surface; segments: (string | SegmentConfig)[]; from: string; maxRows?: number } { + // A preset fills in what was not written; it never overrides what was. + const name = line.preset ?? config.preset ?? "" + const named = PRESETS[name] + const asked = line.surface ?? config.surface + const surface: Surface = SURFACES.includes(asked ?? "") + ? (asked as Surface) + : (named?.surface ?? DEFAULT_SURFACE) + /** No preset by a name that exists: the one for the surface, so the sidebar is never blank. */ + const presetName = named ? name : PRESET_FOR[surface] + const preset = PRESETS[presetName] + const written = line.segments ?? config.segments + return { + surface, + segments: written ?? preset?.segments ?? DEFAULT_SEGMENTS, + from: written ? '"segments"' : `the ${presetName} preset`, + ...(preset?.maxRows !== undefined ? { maxRows: preset.maxRows } : {}), + } +} + +/** Normalises whatever the config said into the lines the renderer draws. */ +export function resolveLines(config: StatusConfig): ResolvedLine[] { + return lineSources(config).map(({ line }) => { + const base = baseSegments(line, config) + const surface = base.surface + // The sidebar is a narrow column: across, it would be three truncated words. + const stack = line.stack ?? config.stack ?? (surface === "sidebar" ? "vertical" : "horizontal") + return { + surface, + segments: applyOverride(base.segments, line.override ?? config.override), + separator: line.separator ?? config.separator ?? (stack === "vertical" ? "" : DEFAULT_SEPARATOR), + stack, + maxRows: line.maxRows ?? config.sidebarRows ?? base.maxRows ?? MAX_ROWS, + icons: line.icons ?? config.icons ?? true, + debug: line.debug ?? config.debug ?? false, + paddingLeft: line.paddingLeft ?? config.paddingLeft ?? PADDING[surface].left, + paddingRight: line.paddingRight ?? config.paddingRight ?? PADDING[surface].right, + paddingTop: line.paddingTop ?? config.paddingTop ?? PADDING[surface].top, + paddingBottom: line.paddingBottom ?? config.paddingBottom ?? PADDING[surface].bottom, + } + }) +} + +/** A segment written as a bare string is that built-in with no settings. */ +export function asSegmentConfig(entry: string | SegmentConfig): SegmentConfig { + return typeof entry === "string" ? { type: entry } : entry +} diff --git a/packages/status/src/core/config/load.ts b/packages/status/src/core/config/load.ts new file mode 100644 index 00000000..8aeffac1 --- /dev/null +++ b/packages/status/src/core/config/load.ts @@ -0,0 +1,106 @@ +/** Status's settings read through the loader every bay shares, and its notices as `cockpit_settings` lists them. */ + +import { + baySettings, + cockpitNotices, + noticeText, + OPTIONS_SOURCE, + type Settings, + type SettingsNotice, + type SettingsWhere, +} from "@opencode-cockpit/client/settings" +import { configNotices, configProblems, type StatusProblem } from "./problems.ts" +import { isObject, KINDS, type StatusConfig } from "./shape.ts" + +export interface LoadedStatus { + /** Every source merged, as written: the gaps are `resolveLines`'s to fill. */ + config: StatusConfig + /** The block's place in the sidebar, from the top-level `sidebar` list. */ + order: number + /** + * What to fix, one `!` row each: Status's own settings, and — because Status is the one bay every + * install draws — the notices that belong to no bay (a file that would not parse, a top-level + * name nothing reads, an entry in the `sidebar` list that is not a bay). + */ + notices: string[] + settings: Settings +} + +export interface StatusInput { + /** The plugin entry's options: the section's own keys, or a whole config with a `status` section. */ + options?: unknown + where?: SettingsWhere + /** Already loaded, as `cockpit_settings` and doctor have them. */ + settings?: Settings +} + +/** Reads and merges every source. Never throws. */ +export function loadStatus(input: StatusInput = {}): LoadedStatus { + const loaded = baySettings("status", KINDS, { + options: input.options, + ...(input.settings ? { settings: input.settings } : { where: input.where }), + }) + const written = loaded.written as StatusConfig + const config: StatusConfig = { ...written } + /** `features.status: false` turns the bay off as `enabled: false` does. */ + if (!loaded.config.enabled) config.enabled = false + /** The shared switch, in Status's words: off the sidebar means at the bottom. */ + if (written.sidebar === false && written.surface === undefined) config.surface = "bottom" + if (typeof written.sidebarRows === "number") config.sidebarRows = loaded.config.sidebarRows + /** + * Modules add up rather than replace: a project can bring its own segments without losing the ones + * you use everywhere. Every other list replaces the one before it, as in every bay. + */ + const modules = [ + ...loaded.settings.layers.flatMap((layer) => strings(layer.sections.status?.modules)), + ...strings(optionsSection(input.options)?.modules), + ] + if (modules.length > 0) config.modules = [...new Set(modules)] + else delete config.modules + + const own = [...cockpitNotices(loaded.settings), ...loaded.notices].map(noticeText) + return { + config, + order: loaded.order, + notices: [...own, ...configNotices(config)], + settings: loaded.settings, + } +} + +const strings = (value: unknown): string[] => + Array.isArray(value) ? value.filter((each): each is string => typeof each === "string") : [] + +/** Plugin options as the section: a whole config's `status`, else the options themselves. */ +function optionsSection(options: unknown): Record | undefined { + if (!isObject(options)) return undefined + return isObject(options.status) ? options.status : options +} + +/** + * Every notice Status draws for these settings, as notices — the loader's, its own keys' kinds, and + * its vocabulary's — each naming the file its key was written in. Offered to `cockpit_settings` and + * doctor (`offerSettingsCheck`), so "Notices: none" there means no `!` row here. + */ +export function statusNotices(input: { settings: Settings; options?: unknown }): SettingsNotice[] { + const loaded = loadStatus({ options: input.options, settings: input.settings }) + const options = optionsSection(input.options) + /** The last source that wrote the key: plugin options win, then the project file, then the global. */ + const fileOf = (key: StatusProblem["key"]) => + options && key in options + ? OPTIONS_SOURCE + : ([...input.settings.layers] + .reverse() + .find((layer) => layer.sections.status && key in layer.sections.status)?.path ?? "status") + const own = baySettings("status", KINDS, { options: input.options, settings: input.settings }).notices + return [ + ...own, + ...configProblems(loaded.config).map( + (problem): SettingsNotice => ({ + bay: "status", + file: fileOf(problem.key), + kind: "invalid", + text: problem.text, + }), + ), + ] +} diff --git a/packages/status/src/core/config/problems.ts b/packages/status/src/core/config/problems.ts new file mode 100644 index 00000000..64ec2c8a --- /dev/null +++ b/packages/status/src/core/config/problems.ts @@ -0,0 +1,91 @@ +/** What is wrong in Status's settings that only Status can tell: presets, surfaces, segments, overrides. */ + +import { closestName } from "@opencode-cockpit/client/settings" +import { baseSegments, isChange, lineSources, PRESETS, segmentType } from "./lines.ts" +import { isObject, type LineConfig, type Override, type StatusConfig, SURFACES } from "./shape.ts" + +/** One thing wrong in Status's settings that only Status can tell, and the key it is about. */ +export interface StatusProblem { + /** The section key it is about: `preset`, `surface`, `override`, or `lines` for one of the lines'. */ + key: "preset" | "surface" | "override" | "lines" + /** Without the `settings: ` the row adds. */ + text: string +} + +/** + * What the loader cannot know is wrong, because only Status knows its vocabulary: a preset nothing + * answers to, a surface that does not exist. Each used to fall back in silence — an unknown preset + * was quietly the default line, which looks like a preset that does nothing. + */ +export function configNotices(config: StatusConfig): string[] { + return configProblems(config).map((problem) => `settings: ${problem.text}`) +} + +/** The same, with the key each one is about: for a notice that names its file (`statusNotices`). */ +export function configProblems(config: StatusConfig): StatusProblem[] { + const out: StatusProblem[] = [] + const names = Object.keys(PRESETS).join(", ") + const lines: LineConfig[] = [config, ...(Array.isArray(config.lines) ? config.lines : [])] + for (const [index, line] of lines.entries()) { + const where = index === 0 ? "status" : `status.lines[${index - 1}]` + const key = index === 0 ? undefined : "lines" + /** The name first: in a 24-column sidebar it is what survives the wrap. */ + if (typeof line.preset === "string" && !PRESETS[line.preset]) { + out.push({ key: key ?? "preset", text: `no preset "${line.preset}" (${names})` }) + } + if (line.surface !== undefined && !SURFACES.includes(line.surface)) { + out.push({ key: key ?? "surface", text: `"${where}.surface" is "sidebar" or "bottom"` }) + } + } + return [...out, ...overrideProblems(config)] +} + +/** + * An override that changes nothing is said out loud: `"gti"` matching no segment would otherwise + * be a row that kept its old look with no word as to why. A section-wide override is checked against + * every line that uses it, and is only wrong when it matches none of them. + */ +function overrideProblems(config: StatusConfig): StatusProblem[] { + const out: StatusProblem[] = [] + const checked = new Map }>() + for (const [index, source] of lineSources(config).entries()) { + const raw = source.line.override ?? config.override + const where = source.line.override !== undefined && config.lines?.length ? `status.lines[${index}].` : "" + if (raw === undefined) continue + if (!isObject(raw)) { + out.push({ + key: where ? "lines" : "override", + text: `"${where || "status."}override" should be an object of segment names`, + }) + continue + } + const base = baseSegments(source.line, config) + const seen = checked.get(raw) ?? { where, from: base.from, types: new Set() } + for (const entry of base.segments) seen.types.add(segmentType(entry)) + checked.set(raw, seen) + } + for (const [override, { where, from, types }] of checked) { + const key = where ? "lines" : "override" + for (const [name, change] of Object.entries(override)) { + if (!isChange(change)) { + out.push({ + key, + text: `${where}override "${name}" is false, a segment name, or an object of its settings`, + }) + } else if (!types.has(name)) { + const meant = closestSegment(name, [...types]) + out.push({ + key, + text: `${where}override "${name}" matches no segment in ${from}${meant ? ` — did you mean "${meant}"?` : ""}`, + }) + } + } + } + return out +} + +/** The name a typo most likely meant: the same letters in another order first (`gti` → `git`). */ +function closestSegment(name: string, valid: string[]): string | undefined { + const letters = (text: string) => [...text].sort().join("") + return valid.find((each) => letters(each) === letters(name)) ?? closestName(name, valid) +} diff --git a/packages/status/src/core/config/shape.ts b/packages/status/src/core/config/shape.ts new file mode 100644 index 00000000..d07eed34 --- /dev/null +++ b/packages/status/src/core/config/shape.ts @@ -0,0 +1,166 @@ +/** What Status's settings are made of: the section's shape, and the kind each key takes. */ + +/** + * Where a line is drawn. + * + * Two surfaces, deliberately. A third sat inside the prompt box, which is both the narrowest place + * in the window and the one OpenCode already fills with the agent, the model and the elapsed time: + * a line there had almost no room and almost nothing left to say. + */ +export type Surface = "bottom" | "sidebar" + +/** Where Status draws when nothing says otherwise. The sidebar since 0.9. */ +export const DEFAULT_SURFACE: Surface = "sidebar" + +/** + * A segment is either a built-in named by string ("cwd"), or that name with settings. `when` and + * `priority` are what make a line survive a narrow terminal instead of wrapping into noise. + */ +export interface SegmentConfig { + type: string + /** Text placed before the value, e.g. "on ". */ + prefix?: string + suffix?: string + /** Higher survives when the line has to be shortened. Defaults per built-in. */ + priority?: number + /** Theme colour name (`success`, `warning`, `textMuted`…) or a literal `#rrggbb`. */ + color?: string + /** Built-in specific settings, e.g. `{ "style": "bar" }` for context. */ + [key: string]: unknown +} + +export interface CommandConfig { + /** Shell command whose stdout becomes the segment's text. */ + run: string + /** How often it may run. Defaults to 2000ms; it never runs more than once at a time. */ + intervalMs?: number + /** Kill and ignore the output after this long. Defaults to 1000ms. */ + timeoutMs?: number + /** + * Feed the command Claude Code's statusline JSON on stdin, so an existing statusline script + * works unchanged. On by default. + */ + claudeCodeCompat?: boolean + priority?: number +} + +/** + * How a line lays its segments out. A wide line under the prompt reads across; a sidebar four + * columns wide reads down. + */ +export type Stack = "horizontal" | "vertical" + +/** + * One segment's change, on top of the preset's list (or `segments`): `false` drops it, a name swaps + * it for that segment in the same place, an object merges into its settings. + */ +export type SegmentChange = false | string | Record + +/** + * Changes to a line's segments, keyed by segment name: `{ "git": { "against": "branch" } }`. A + * small change used to mean copying the preset's whole list into `segments`, which then stopped + * following the preset — and one wrong entry in fourteen was a row gone with no word said. + */ +export type Override = Record + +export interface LineConfig { + /** A whole line by name; anything written beside it wins. */ + preset?: string + surface?: Surface + segments?: (string | SegmentConfig)[] + /** Changes to the preset's segments, or to `segments`, by segment name. */ + override?: Override + /** Drawn between segments. Defaults to " · " across, and nothing down. */ + separator?: string + /** Defaults to vertical in the sidebar, horizontal everywhere else. */ + stack?: Stack + /** Built-in icons. On by default; switch off for a terminal missing the glyphs. */ + icons?: boolean + /** Draw a placeholder where a segment said nothing, so a typo and missing data look different. */ + debug?: boolean + /** Vertical only: rows to draw at most. Lowest priority goes first. The bay's `sidebarRows` by default. */ + maxRows?: number + /** + * Columns of space either side. The defaults line each surface up with OpenCode's own + * furniture -- its footer indents three, its prompt keeps two clear on the right -- so the line + * reads as part of the interface rather than as something bolted underneath it. + */ + paddingLeft?: number + paddingRight?: number + paddingTop?: number + paddingBottom?: number +} + +/** The `status` section. */ +export interface StatusConfig { + enabled?: boolean + /** + * Draw in the sidebar: the key every bay shares. `false` is read as `surface: "bottom"`, so the + * one switch works here as it does everywhere; `surface` says the same thing in Status's words. + */ + sidebar?: boolean + /** + * A whole line by name: `minimal`, `default`, `detailed`, `sidebar`. Anything you write + * alongside it wins, so a preset is a starting point rather than a mode. + */ + preset?: string + /** One line, for the common case. Use `lines` for more than one surface. */ + surface?: Surface + segments?: (string | SegmentConfig)[] + /** + * Changes to the preset's segments by name, so changing one row keeps the rest of the preset: + * `false` drops a segment, a name swaps it, an object merges into its settings. With `segments` + * written too, the changes apply to those. A line in `lines` may carry its own. + */ + override?: Override + separator?: string + stack?: Stack + /** Built-in icons. On by default; switch off for a terminal missing the glyphs. */ + icons?: boolean + /** Draw a placeholder where a segment said nothing, so a typo and missing data look different. */ + debug?: boolean + /** + * Rows a column draws at most: the name every bay's sidebar block uses. Inside `lines`, a line's + * own cap is still `maxRows`. + */ + sidebarRows?: number + paddingLeft?: number + paddingRight?: number + paddingTop?: number + paddingBottom?: number + lines?: LineConfig[] + /** Named commands usable as segments: `{"type": "command", "name": "budget"}`. */ + commands?: Record + /** + * Your own segments: paths to modules that export them by name, usable in `segments` exactly + * like the built-ins. `~` and a path relative to the project both work. + */ + modules?: string[] +} + +/** + * The kind of value each key takes, as the loader checks it: a value of another kind is dropped + * with a `!` row naming the key, where it used to reach the renderer and fail there — or nowhere. + */ +export const KINDS = { + preset: "", + surface: "", + segments: [] as unknown[], + override: {} as Record, + separator: "", + stack: "", + icons: true, + debug: false, + paddingLeft: 0, + paddingRight: 0, + paddingTop: 0, + paddingBottom: 0, + lines: [] as unknown[], + commands: {} as Record, + modules: [] as unknown[], +} + +export const SURFACES: readonly string[] = ["bottom", "sidebar"] + +export const isObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) diff --git a/packages/status/src/core/custom.ts b/packages/status/src/core/custom.ts index 5a4ab14a..191bafa2 100644 --- a/packages/status/src/core/custom.ts +++ b/packages/status/src/core/custom.ts @@ -1,10 +1,8 @@ -import { existsSync, rmSync } from "node:fs" import { homedir } from "node:os" -import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path" -import { pathToFileURL } from "node:url" -import type { SegmentConfig } from "./config.ts" +import { basename, isAbsolute, resolve } from "node:path" +import type { SegmentConfig } from "./config/index.ts" import type { StatusContext } from "./context.ts" -import type { Piece, Pieces, Run, SegmentDef, Tone } from "./segments.ts" +import type { Piece, Run, SegmentDef, Tone } from "./segments.ts" /** * Your own segments, written in TypeScript. @@ -54,119 +52,6 @@ export function resolveModulePath(path: string, directory: string, home = homedi return resolve(directory, path) } -const DEFAULT_PRIORITY = 45 - -/** The specifier a module is written against, which is the whole point of the failure below. */ -const AUTHORING = "@opencode-cockpit/status/segment" -/** - * What the failure names. v1 says `Cannot find module '@opencode-cockpit/status/segment'`; v2 names - * only the package — `Cannot find package '@opencode-cockpit/status'` — and matching the full - * specifier left every module outside a project unloaded there. - */ -const AUTHORING_PACKAGE = "@opencode-cockpit/status" - -/** - * Loading a module that lives outside a project. - * - * A statusline module belongs next to the config it serves, and the natural home for that is - * `~/.config/opencode-cockpit/`. But a bare import resolves from the importing file's own - * directory, and a config directory has no `node_modules` -- so every example in our own README - * fails for exactly the people the README is written for, and its segments vanish from the line - * with only a start-up toast to say why. - * - * The specifier resolves perfectly well from *this* file, so the fallback rewrites it to that - * resolved path and imports a copy placed beside the original, where the module's own relative - * imports still work. Only on failure: a module inside a project that installed the bay never - * takes this path. - */ -/** - * This file's own authoring module, from a checkout or from a build. - * - * The specifier is a string argument rather than an import, so the build's `./x.ts` → `./x.js` - * rewrite never touched it: published copies asked for a `.ts` that is not beside them and threw, - * which took out the whole fallback and with it every module living outside a project. Ask for - * both, in the order that keeps a checkout resolving to its source. - */ -function authoringModule(): string { - for (const candidate of ["./authoring.ts", "./authoring.js"]) { - try { - return Bun.resolveSync(candidate, import.meta.dir) - } catch { - // try the other extension - } - } - throw new Error("cannot find the statusline authoring module beside this one") -} - -async function importWithAuthoring(full: string): Promise { - const resolved = authoringModule() - const source = await Bun.file(full).text() - const patched = source.replaceAll(AUTHORING, pathToFileURL(resolved).href) - if (patched === source) throw new Error(`does not import ${AUTHORING}`) - - // Beside the original, so `./helpers.ts` next to a module keeps resolving. - const shim = join(dirname(full), `.${basename(full, extname(full))}.cockpit.${extname(full).slice(1)}`) - try { - await Bun.write(shim, patched) - return await import(`${shim}?t=${Date.now()}`) - } finally { - rmSync(shim, { force: true }) - } -} - -export async function loadCustomSegments( - paths: readonly string[], - directory: string, - importer: (path: string) => Promise = (path) => import(path), -): Promise { - const segments = new Map() - const errors: string[] = [] - - for (const path of paths) { - const full = resolveModulePath(path, directory) - try { - let loaded: { default?: CustomModule } & CustomModule - try { - loaded = (await importer(full)) as { default?: CustomModule } & CustomModule - } catch (err) { - // Only the one failure is worth retrying; anything else is the module's own problem. - const message = err instanceof Error ? err.message : String(err) - if (!message.includes(AUTHORING_PACKAGE)) throw err - loaded = (await importWithAuthoring(full)) as { default?: CustomModule } & CustomModule - } - const module = loaded.default ?? loaded - for (const [name, entry] of Object.entries(module.segments ?? {})) { - const render = typeof entry === "function" ? entry : entry.render - if (typeof render !== "function") { - errors.push(`${path}: segment "${name}" is not a function`) - continue - } - const priority = typeof entry === "function" ? DEFAULT_PRIORITY : (entry.priority ?? DEFAULT_PRIORITY) - segments.set(name, { - name, - priority, - render(ctx, config): Pieces | undefined { - const value = render(ctx, config) - if (value === undefined) return undefined - // Several rows: each is drawn on its own, and empty ones are left out. - if (Array.isArray(value)) return value.length > 0 ? value : undefined - if (typeof value === "string") return value ? { text: value, tone: "muted" } : undefined - if ("runs" in value) return value.runs.length > 0 ? value : undefined - return value.text - ? { text: value.text, tone: value.tone ?? "muted", color: value.color } - : undefined - }, - }) - } - } catch (err) { - errors.push( - `${path}: ${existsSync(full) ? (err instanceof Error ? err.message : String(err)) : missing(full)}`, - ) - } - } - return { segments, errors } -} - /** * The sidebar examples 0.9 removed when the `sidebar` preset became the table they built toward. A * config that still points at one in the package gets a sentence that says what replaced it, rather @@ -177,6 +62,7 @@ const REMOVED_EXAMPLES = new Set(["sidebar.ts", "sidebar-full.ts", "sidebar-budg /** What a config pointing at one of them is told — in the brief, the log and the `!` row. */ export const REMOVED_EXAMPLE = `removed in 0.9: use "preset": "sidebar"` -function missing(full: string): string { +/** What a module path that names no file is told: a removed example says what replaced it. */ +export function missingModule(full: string): string { return REMOVED_EXAMPLES.has(basename(full)) ? REMOVED_EXAMPLE : "no file there" } diff --git a/packages/status/src/core/notices.ts b/packages/status/src/core/notices.ts index 12983a74..ef32746b 100644 --- a/packages/status/src/core/notices.ts +++ b/packages/status/src/core/notices.ts @@ -3,7 +3,7 @@ * * Everything else here follows the rule that a segment with nothing to say says nothing — which is * right for data and wrong for the line's own failures. A module that would not load, a setting that - * is no longer read and a column that ran out of rows all end as *segments that are simply not + * is not read and a column that ran out of rows all end as *segments that are simply not * there*, indistinguishable from a segment that had nothing to report, and the only notice any of * them got was a toast that is gone in ten seconds. Each cost a debugging session to tell apart from * a bug in the module itself. diff --git a/packages/status/src/core/preview.ts b/packages/status/src/core/preview.ts index 66daa6e6..9c4b5370 100644 --- a/packages/status/src/core/preview.ts +++ b/packages/status/src/core/preview.ts @@ -5,7 +5,6 @@ * `sidebarRows: 14` capped at 8 — and could not tell whether the preview or the setting was wrong. */ -import { readFileSync } from "node:fs" import { type SettingsWhere, settingsPaths } from "@opencode-cockpit/client/settings" import type { Budget } from "./budget.ts" import { @@ -16,7 +15,7 @@ import { type ResolvedLine, resolveLines, type Surface, -} from "./config.ts" +} from "./config/index.ts" import type { StatusContext } from "./context.ts" import { noticeRows } from "./notices.ts" import { fit, fitColumn } from "./render.ts" @@ -80,7 +79,7 @@ export interface PreviewInput { env?: Record /** * A config's text — a file's, or a candidate piped on stdin. Read through the same loader as the - * TUI's, so its `status` section — comments, old names, `sidebarRows`, `override` and all — means + * TUI's, so its `status` section — comments, `sidebarRows`, `override` and all — means * here exactly what it will mean in OpenCode. */ configText?: string @@ -90,7 +89,7 @@ export interface PreviewInput { * the text stands alone in place of the global config, with no project file beside it. */ as?: ConfigAs - /** How the other file is read; the disk by default. */ + /** How the other file is read, with `as` — the disk, in the preview CLI. */ readFile?: (path: string) => string | undefined /** Draw on this surface whatever the settings say. */ surface?: Surface @@ -103,14 +102,6 @@ export interface PreviewSettings { target?: string } -const fromDisk = (path: string): string | undefined => { - try { - return readFileSync(path, "utf8") - } catch { - return undefined - } -} - /** The settings and the lines the TUI would draw from them: `loadStatus`, then `resolveLines`. */ export function previewSettings(input: PreviewInput): PreviewSettings { const env = input.env ? { env: input.env } : {} @@ -119,7 +110,8 @@ export function previewSettings(input: PreviewInput): PreviewSettings { if (input.configText !== undefined && input.as) { const paths = settingsPaths({ directory: input.directory, ...env }) target = input.as === "project" ? paths.project : paths.global - const readFile = input.readFile ?? fromDisk + const readFile = input.readFile + if (!readFile) throw new Error("previewSettings: `as` reads the other file, so it needs readFile") where = { ...where, read: (path) => (path === target ? input.configText : readFile(path)) } } else if (input.configText !== undefined) { // With no `directory` the loader asks for one file, the global one: this is it. diff --git a/packages/status/src/core/reference.ts b/packages/status/src/core/reference.ts index ef2382a7..27533207 100644 --- a/packages/status/src/core/reference.ts +++ b/packages/status/src/core/reference.ts @@ -7,7 +7,7 @@ import { bayKeys, shownDefault } from "@opencode-cockpit/client/catalog" import { BUILTINS } from "./builtins/index.ts" -import { DEFAULT_SURFACE, PRESETS, type SegmentConfig } from "./config.ts" +import { DEFAULT_SURFACE, PRESETS, type SegmentConfig } from "./config/index.ts" /** * What each built-in says, in a line, with the settings worth knowing. Keyed by name, so a built-in diff --git a/packages/status/src/core/segments.ts b/packages/status/src/core/segments.ts index 10f141cd..9fe7d06e 100644 --- a/packages/status/src/core/segments.ts +++ b/packages/status/src/core/segments.ts @@ -1,5 +1,5 @@ import { BUILTINS } from "./builtins/index.ts" -import type { SegmentConfig } from "./config.ts" +import type { SegmentConfig } from "./config/index.ts" import type { StatusContext } from "./context.ts" import type { Piece, Pieces, Run, Segment, SegmentDef, Tone } from "./types.ts" diff --git a/packages/status/src/core/setup.ts b/packages/status/src/core/setup.ts index f68cbd2b..2f1113f0 100644 --- a/packages/status/src/core/setup.ts +++ b/packages/status/src/core/setup.ts @@ -13,13 +13,9 @@ import { fileURLToPath } from "node:url" export const SETUP_SKILL = "status-setup" /** The command, by the name it has had since 0.9. */ export const SETUP_SLASH = "status-setup" -/** Its name until 0.9, kept one release as a command that says the new one. Removed in 0.10. */ -export const OLD_SLASH = "statusline" -/** What either command, and the palette, hands the agent. */ +/** What the command, and the palette, hands the agent. */ export const SETUP_PROMPT = "Use the status-setup skill to help me set up the Status bay." -/** The old name says its new one where the person reads it: first in the line it sends. */ -export const OLD_PROMPT = `/${OLD_SLASH} is now /${SETUP_SLASH}. ${SETUP_PROMPT}` /** Where the skill sits in this package: `src/core/` and `dist/core/` are both two levels under its root. */ export const SETUP_SKILL_DIR = fileURLToPath(new URL(`../../skills/${SETUP_SKILL}`, import.meta.url)) diff --git a/packages/status/src/core/types.ts b/packages/status/src/core/types.ts index c5c276ee..189dc1b2 100644 --- a/packages/status/src/core/types.ts +++ b/packages/status/src/core/types.ts @@ -1,4 +1,4 @@ -import type { SegmentConfig } from "./config.ts" +import type { SegmentConfig } from "./config/index.ts" import type { StatusContext } from "./context.ts" /** diff --git a/packages/status/src/io/budget.ts b/packages/status/src/io/budget.ts new file mode 100644 index 00000000..eda9ad77 --- /dev/null +++ b/packages/status/src/io/budget.ts @@ -0,0 +1,12 @@ +/** Reading the budget file a proxy writes; what it says is `core/budget.ts`. */ + +import { readFileSync } from "node:fs" +import { type Budget, parseBudget } from "../core/budget.ts" + +export function readBudget(path: string): Budget | undefined { + try { + return parseBudget(readFileSync(path, "utf8")) + } catch { + return undefined + } +} diff --git a/packages/status/src/io/command.ts b/packages/status/src/io/command.ts new file mode 100644 index 00000000..85ea2e97 --- /dev/null +++ b/packages/status/src/io/command.ts @@ -0,0 +1,20 @@ +/** Running a command segment's shell command; when to run it and what its output means is `core/command.ts`. */ + +/** The real shell, used outside tests. */ +export async function execShell(command: string, stdin: string, timeoutMs: number): Promise { + const proc = Bun.spawn(["/bin/sh", "-c", command], { + stdin: new TextEncoder().encode(stdin), + stdout: "pipe", + stderr: "ignore", + env: process.env, + }) + const timer = setTimeout(() => proc.kill(), timeoutMs) + try { + const out = await new Response(proc.stdout).text() + const code = await proc.exited + if (code !== 0) throw new Error(`exit ${code}`) + return out + } finally { + clearTimeout(timer) + } +} diff --git a/packages/status/src/io/custom.ts b/packages/status/src/io/custom.ts new file mode 100644 index 00000000..2db12c5b --- /dev/null +++ b/packages/status/src/io/custom.ts @@ -0,0 +1,123 @@ +/** + * Loading your own segments: importing each module the config names, and the fallback for a module + * that lives outside a project. What a module is and where its path points is `core/custom.ts`. + */ + +import { existsSync, rmSync } from "node:fs" +import { basename, dirname, extname, join } from "node:path" +import { pathToFileURL } from "node:url" +import { type CustomModule, type LoadResult, missingModule, resolveModulePath } from "../core/custom.ts" +import type { Pieces, SegmentDef } from "../core/segments.ts" + +const DEFAULT_PRIORITY = 45 + +/** The specifier a module is written against, which is the whole point of the failure below. */ +const AUTHORING = "@opencode-cockpit/status/segment" +/** + * What the failure names. v1 says `Cannot find module '@opencode-cockpit/status/segment'`; v2 names + * only the package — `Cannot find package '@opencode-cockpit/status'` — and matching the full + * specifier left every module outside a project unloaded there. + */ +const AUTHORING_PACKAGE = "@opencode-cockpit/status" + +/** + * Loading a module that lives outside a project. + * + * A statusline module belongs next to the config it serves, and the natural home for that is + * `~/.config/opencode-cockpit/`. But a bare import resolves from the importing file's own + * directory, and a config directory has no `node_modules` -- so every example in our own README + * fails for exactly the people the README is written for, and its segments vanish from the line + * with only a start-up toast to say why. + * + * The specifier resolves perfectly well from *this* file, so the fallback rewrites it to that + * resolved path and imports a copy placed beside the original, where the module's own relative + * imports still work. Only on failure: a module inside a project that installed the bay never + * takes this path. + */ +/** + * Status's own authoring module (core/authoring.ts), from a checkout or from a build. + * + * The specifier is a string argument rather than an import, so the build's `./x.ts` → `./x.js` + * rewrite never touched it: published copies asked for a `.ts` that is not beside them and threw, + * which took out the whole fallback and with it every module living outside a project. Ask for + * both, in the order that keeps a checkout resolving to its source. + */ +function authoringModule(): string { + for (const candidate of ["../core/authoring.ts", "../core/authoring.js"]) { + try { + return Bun.resolveSync(candidate, import.meta.dir) + } catch { + // try the other extension + } + } + throw new Error("cannot find the statusline authoring module in core/") +} + +async function importWithAuthoring(full: string): Promise { + const resolved = authoringModule() + const source = await Bun.file(full).text() + const patched = source.replaceAll(AUTHORING, pathToFileURL(resolved).href) + if (patched === source) throw new Error(`does not import ${AUTHORING}`) + + // Beside the original, so `./helpers.ts` next to a module keeps resolving. + const shim = join(dirname(full), `.${basename(full, extname(full))}.cockpit.${extname(full).slice(1)}`) + try { + await Bun.write(shim, patched) + return await import(`${shim}?t=${Date.now()}`) + } finally { + rmSync(shim, { force: true }) + } +} + +export async function loadCustomSegments( + paths: readonly string[], + directory: string, + importer: (path: string) => Promise = (path) => import(path), +): Promise { + const segments = new Map() + const errors: string[] = [] + + for (const path of paths) { + const full = resolveModulePath(path, directory) + try { + let loaded: { default?: CustomModule } & CustomModule + try { + loaded = (await importer(full)) as { default?: CustomModule } & CustomModule + } catch (err) { + // Only the one failure is worth retrying; anything else is the module's own problem. + const message = err instanceof Error ? err.message : String(err) + if (!message.includes(AUTHORING_PACKAGE)) throw err + loaded = (await importWithAuthoring(full)) as { default?: CustomModule } & CustomModule + } + const module = loaded.default ?? loaded + for (const [name, entry] of Object.entries(module.segments ?? {})) { + const render = typeof entry === "function" ? entry : entry.render + if (typeof render !== "function") { + errors.push(`${path}: segment "${name}" is not a function`) + continue + } + const priority = typeof entry === "function" ? DEFAULT_PRIORITY : (entry.priority ?? DEFAULT_PRIORITY) + segments.set(name, { + name, + priority, + render(ctx, config): Pieces | undefined { + const value = render(ctx, config) + if (value === undefined) return undefined + // Several rows: each is drawn on its own, and empty ones are left out. + if (Array.isArray(value)) return value.length > 0 ? value : undefined + if (typeof value === "string") return value ? { text: value, tone: "muted" } : undefined + if ("runs" in value) return value.runs.length > 0 ? value : undefined + return value.text + ? { text: value.text, tone: value.tone ?? "muted", color: value.color } + : undefined + }, + }) + } + } catch (err) { + errors.push( + `${path}: ${existsSync(full) ? (err instanceof Error ? err.message : String(err)) : missingModule(full)}`, + ) + } + } + return { segments, errors } +} diff --git a/packages/status/src/server.ts b/packages/status/src/server.ts index 988eb90e..9926ce40 100644 --- a/packages/status/src/server.ts +++ b/packages/status/src/server.ts @@ -16,8 +16,8 @@ import { offerSettingsCheck } from "@opencode-cockpit/client/checks" import { claimFeature } from "@opencode-cockpit/client/feature" import { dualServer, type ServerStart } from "@opencode-cockpit/client/server" import { offerPreview } from "@opencode-cockpit/client/setup" -import { statusNotices } from "./core/config.ts" -import { OLD_PROMPT, OLD_SLASH, SETUP_PROMPT, SETUP_SKILL_DIR, SETUP_SLASH } from "./core/setup.ts" +import { statusNotices } from "./core/config/index.ts" +import { SETUP_PROMPT, SETUP_SKILL_DIR, SETUP_SLASH } from "./core/setup.ts" const STATUS_PACKAGE = "@opencode-cockpit/status" @@ -41,16 +41,6 @@ export function createStatusServer({ source = STATUS_PACKAGE }: { source?: strin description: "design the Status bay's line with the agent: what it shows, where", prompt: SETUP_PROMPT, }, - /** - * The old name, for one release (removed in 0.10): a command of its own, because neither - * OpenCode tells a command which of its names was typed, so only its own line can say it was - * renamed — and it says so first, in the message the person sees in the conversation. - */ - { - name: OLD_SLASH, - description: `renamed: use /${SETUP_SLASH}`, - prompt: OLD_PROMPT, - }, ], dispose: () => claim.release(), } diff --git a/packages/status/src/tui/index.tsx b/packages/status/src/tui/index.tsx index b008dc2c..c000451d 100644 --- a/packages/status/src/tui/index.tsx +++ b/packages/status/src/tui/index.tsx @@ -6,12 +6,12 @@ import { dualTui, type Host } from "@opencode-cockpit/client/host" import type { BoxRenderable } from "@opentui/core" import { createMemo } from "solid-js" import pkg from "../../package.json" with { type: "json" } -import { asSegmentConfig, loadStatus, type ResolvedLine, resolveLines } from "../core/config.ts" -import { loadCustomSegments } from "../core/custom.ts" +import { asSegmentConfig, loadStatus, type ResolvedLine, resolveLines } from "../core/config/index.ts" import { moduleNoticeText, noticeRows, overflowNotice } from "../core/notices.ts" import { fit, fitColumn } from "../core/render.ts" import { buildSegments, type SegmentDef } from "../core/segments.ts" import { SETUP_PROMPT } from "../core/setup.ts" +import { loadCustomSegments } from "../io/custom.ts" import { StatusLine } from "./components/statusline.tsx" import { buildContext } from "./state/snapshot.ts" import { createStatusStore } from "./state/store.ts" @@ -71,7 +71,7 @@ export function createStatusTui({ source = STATUS_PACKAGE }: { source?: string } api.lifecycle.onDispose(() => store.dispose()) /** - * The line's own trouble — a setting no longer read, a file that would not parse, a module that + * The line's own trouble — a setting not read, a file that would not parse, a module that * would not load — drawn once, as `!` rows above the first line, and for the whole session: the * toast is gone in ten seconds and the log is not where anyone looks at a line that seems to have * quietly done nothing. Status draws the notices that belong to no bay, too, being the one bay diff --git a/packages/status/src/tui/state/store.ts b/packages/status/src/tui/state/store.ts index 8111b607..9e3b03a7 100644 --- a/packages/status/src/tui/state/store.ts +++ b/packages/status/src/tui/state/store.ts @@ -1,8 +1,8 @@ import type { Host } from "@opencode-cockpit/client/host" import { type Accessor, createMemo, createRoot, createSignal } from "solid-js" -import { BUDGET_EVERY_MS, type Budget, budgetFile, readBudget } from "../../core/budget.ts" -import { type CommandRunner, createRunner, execShell } from "../../core/command.ts" -import { resolveLines, type StatusConfig } from "../../core/config.ts" +import { BUDGET_EVERY_MS, type Budget, budgetFile } from "../../core/budget.ts" +import { type CommandRunner, createRunner } from "../../core/command.ts" +import { resolveLines, type StatusConfig } from "../../core/config/index.ts" import type { StatusContext } from "../../core/context.ts" import { branchDiffCommand, @@ -12,6 +12,8 @@ import { wantsBranchDiff, wantsDiff, } from "../../core/diff.ts" +import { readBudget } from "../../io/budget.ts" +import { execShell } from "../../io/command.ts" /** * Keeps one snapshot of OpenCode's state for every line to read. One memo rather than one per diff --git a/packages/status/test/checks.test.ts b/packages/status/test/checks.test.ts index 83a9b004..9e84669e 100644 --- a/packages/status/test/checks.test.ts +++ b/packages/status/test/checks.test.ts @@ -2,7 +2,7 @@ import { afterEach, expect, test } from "bun:test" import { offerSettingsCheck } from "@opencode-cockpit/client/checks" import { loadSettings } from "@opencode-cockpit/client/settings" import { settingsReport, settingsText } from "@opencode-cockpit/client/setup" -import { loadStatus, statusNotices } from "../src/core/config.ts" +import { loadStatus, statusNotices } from "../src/core/config/index.ts" /** * Status offers its own check to `cockpit_settings` (and doctor), so what its line warns about is diff --git a/packages/status/test/command.test.ts b/packages/status/test/command.test.ts index abcd5bbb..0863af25 100644 --- a/packages/status/test/command.test.ts +++ b/packages/status/test/command.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from "bun:test" import { type ClaudeCodeStatusInput, claudeCodeInput } from "../src/core/claude-code.ts" -import { cleanOutput, createRunner, execShell, outputRows } from "../src/core/command.ts" +import { cleanOutput, createRunner, outputRows } from "../src/core/command.ts" import type { StatusContext } from "../src/core/context.ts" +import { execShell } from "../src/io/command.ts" const ctx = (over: Partial = {}): StatusContext => ({ now: 60_000, diff --git a/packages/status/test/config.test.ts b/packages/status/test/config.test.ts index 68b8ccf1..e097e294 100644 --- a/packages/status/test/config.test.ts +++ b/packages/status/test/config.test.ts @@ -10,7 +10,7 @@ import { resolveLines, SIDEBAR_SEGMENTS, type StatusConfig, -} from "../src/core/config.ts" +} from "../src/core/config/index.ts" import { FIXTURES } from "../src/core/fixtures.ts" import { fitColumn } from "../src/core/render.ts" import { buildSegments, findSegment } from "../src/core/segments.ts" @@ -75,34 +75,27 @@ describe("reading the config", () => { }) /** - * 0.9 renamed the section and stopped reading the file's root as Status's. Neither is read, and - * neither is silent: each is a `!` row, which is how a 0.8 config learns what changed. + * 0.9 renamed the section and stopped reading the file's root as Status's; 0.10 stopped recognising + * the old names. Neither is read: each is a name like any other that nothing reads. */ -describe("old names", () => { - test("`statusline` is not read: it is a notice that names the fix", () => { +describe("names from before 0.9", () => { + test("`statusline` is not read: it is a notice that offers `status`", () => { const loaded = load({ [GLOBAL]: { statusline: { separator: " | " } } }) expect(loaded.config.separator).toBeUndefined() - expect(loaded.notices).toEqual(['settings: "statusline" is no longer read — run /cockpit-setup']) + expect(loaded.notices).toEqual(['settings: "statusline" is not a setting: did you mean "status"?']) }) - test("keys at the file's root are not Status's, and say where they belong", () => { + test("keys at the file's root are not Status's", () => { const loaded = load({ [GLOBAL]: { enabled: false, debug: true } }) expect(loaded.config.enabled).toBeUndefined() - expect(loaded.notices).toContain( - 'settings: "enabled" at the top level is not read: it belongs in "status"', - ) + expect(loaded.notices).toContain('settings: "enabled" is not a setting') }) - test("a bay-level `maxRows` is `sidebarRows` now; inside `lines` it is still `maxRows`", () => { + test("a bay-level `maxRows` is not read; inside `lines` it is still `maxRows`", () => { const loaded = load({ [GLOBAL]: { status: { maxRows: 4, lines: [{ surface: "sidebar", maxRows: 3 }] } } }) - expect(loaded.notices).toEqual(['settings: "status.maxRows" is no longer read — run /cockpit-setup']) + expect(loaded.config.sidebarRows).toBeUndefined() expect(resolveLines(loaded.config)[0]?.maxRows).toBe(3) }) - - test("`sidebarOrder` is the top-level `sidebar` list now", () => { - const loaded = load({ [GLOBAL]: { status: { sidebarOrder: 120 } } }) - expect(loaded.notices).toEqual(['settings: "status.sidebarOrder" is no longer read — run /cockpit-setup']) - }) }) /** Status is the one bay every install draws, so the notices that belong to no bay are its to draw. */ diff --git a/packages/status/test/custom.test.ts b/packages/status/test/custom.test.ts index fae52534..59782d58 100644 --- a/packages/status/test/custom.test.ts +++ b/packages/status/test/custom.test.ts @@ -2,9 +2,10 @@ import { afterEach, describe, expect, test } from "bun:test" import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs" import { join } from "node:path" import type { StatusContext } from "../src/core/context.ts" -import { type CustomModule, loadCustomSegments, resolveModulePath } from "../src/core/custom.ts" +import { type CustomModule, resolveModulePath } from "../src/core/custom.ts" import { fitColumn } from "../src/core/render.ts" import { buildSegments, type Segment, segmentText } from "../src/core/segments.ts" +import { loadCustomSegments } from "../src/io/custom.ts" const ctx = (over: Partial = {}): StatusContext => ({ now: 60_000, diff --git a/packages/status/test/examples.test.ts b/packages/status/test/examples.test.ts index 20cebb83..f46e277a 100644 --- a/packages/status/test/examples.test.ts +++ b/packages/status/test/examples.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test" import { join } from "node:path" import type { SessionSnapshot, StatusContext } from "../src/core/context.ts" -import { loadCustomSegments } from "../src/core/custom.ts" import { buildSegments, type Segment, segmentText, segmentWidth } from "../src/core/segments.ts" +import { loadCustomSegments } from "../src/io/custom.ts" /** * The shipped examples, loaded exactly the way a user's own module is. An example that does not diff --git a/packages/status/test/notices.test.ts b/packages/status/test/notices.test.ts index a54790c2..a8c82665 100644 --- a/packages/status/test/notices.test.ts +++ b/packages/status/test/notices.test.ts @@ -43,18 +43,18 @@ describe("a module that would not load", () => { describe("the rows a notice draws", () => { test("`!` in the warning tone, wrapped to the column, every row its full width", () => { - const rows = noticeRows(['settings: "statusline" is no longer read — run /cockpit-setup'], 24) + const rows = noticeRows(['settings: "stauts" is not a setting: did you mean "status"?'], 24) expect(rows.map((row) => segmentText(row).trimEnd())).toEqual([ - '! settings: "statusline"', - " is no longer read —", - " run /cockpit-setup", + '! settings: "stauts" is', + " not a setting: did you", + ' mean "status"?', ]) expect(rows[0]?.runs[0]).toMatchObject({ text: "! ", tone: "warning" }) for (const row of rows) expect(segmentText(row)).toHaveLength(24) }) test("a wide surface keeps a notice to one row", () => { - expect(noticeRows(['settings: "statusline" is no longer read — run /cockpit-setup'], 120)).toHaveLength(1) + expect(noticeRows(['settings: "stauts" is not a setting: did you mean "status"?'], 120)).toHaveLength(1) }) test("they outrank every segment, so a broken line still reports why", () => { diff --git a/packages/status/test/preview.test.ts b/packages/status/test/preview.test.ts index 0e3e7481..7dcff8ea 100644 --- a/packages/status/test/preview.test.ts +++ b/packages/status/test/preview.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { asSegmentConfig, SIDEBAR_SEGMENTS } from "../src/core/config.ts" +import { asSegmentConfig, SIDEBAR_SEGMENTS } from "../src/core/config/index.ts" import { FIXTURES } from "../src/core/fixtures.ts" import { drawState, diff --git a/packages/status/test/setup.test.ts b/packages/status/test/setup.test.ts index b2bfc0b1..a9c81f97 100644 --- a/packages/status/test/setup.test.ts +++ b/packages/status/test/setup.test.ts @@ -11,9 +11,9 @@ import { resolveLines, SIDEBAR_SEGMENTS, type StatusConfig, -} from "../src/core/config.ts" +} from "../src/core/config/index.ts" import { SEGMENT_ABOUT, statusReference } from "../src/core/reference.ts" -import { OLD_PROMPT, SETUP_PROMPT, SETUP_SKILL, SETUP_SKILL_DIR } from "../src/core/setup.ts" +import { SETUP_PROMPT, SETUP_SKILL, SETUP_SKILL_DIR } from "../src/core/setup.ts" import { createStatusServer } from "../src/server.ts" /** @@ -37,11 +37,6 @@ describe("the reference", () => { test("every preset is in it", () => { for (const name of Object.keys(PRESETS)) expect(read("settings.md")).toContain(`| \`${name}\` |`) }) - - test("the design rules a copied statusline-design still carries are the same rules", () => { - const old = readFileSync(join(SETUP_SKILL_DIR, "..", "statusline-design", "SKILL.md"), "utf8") - expect(old.endsWith(read("design.md"))).toBe(true) - }) }) describe("the skill", () => { @@ -50,7 +45,7 @@ describe("the skill", () => { expect(parsed?.name).toBe(SETUP_SKILL) const description = parsed?.description ?? "" expect(description.length).toBeLessThan(1024) - for (const trigger of ["/status-setup", "/statusline", "Claude Code statusline", "at the bottom"]) + for (const trigger of ["/status-setup", "Claude Code statusline", "at the bottom"]) expect(description).toContain(trigger) expect(skill).toContain("`cockpit_settings`") expect(skill).toContain("(references/settings.md)") @@ -144,14 +139,12 @@ describe("the agent side", () => { const host = () => ({ version: 1, directory: "/work/app", scope: {}, log: { info() {}, warn() {} } }) as never - test("the skill and both commands, the old one saying the new name first", async () => { + test("the skill and its command", async () => { const parts = await createStatusServer()(host(), {}) expect(parts.skills).toEqual([{ dir: SETUP_SKILL_DIR }]) expect(parts.commands?.map((command) => [command.name, command.prompt])).toEqual([ ["status-setup", SETUP_PROMPT], - ["statusline", OLD_PROMPT], ]) - expect(OLD_PROMPT.startsWith("/statusline is now /status-setup.")).toBe(true) }) test("the bundle and this package side by side register them once", async () => { diff --git a/packages/status/test/table.test.ts b/packages/status/test/table.test.ts index 1257f571..95b9f6ff 100644 --- a/packages/status/test/table.test.ts +++ b/packages/status/test/table.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { budgetFile, parseBudget } from "../src/core/budget.ts" -import { asSegmentConfig, PRESETS, resolveLines, SIDEBAR_SEGMENTS } from "../src/core/config.ts" +import { asSegmentConfig, PRESETS, resolveLines, SIDEBAR_SEGMENTS } from "../src/core/config/index.ts" import type { SessionSnapshot, StatusContext } from "../src/core/context.ts" import { branchDiffCommand, wantsBranchDiff, wantsDiff } from "../src/core/diff.ts" import { FIXTURES } from "../src/core/fixtures.ts" diff --git a/packages/status/test/turn.test.ts b/packages/status/test/turn.test.ts index 75c680f9..6c46ac83 100644 --- a/packages/status/test/turn.test.ts +++ b/packages/status/test/turn.test.ts @@ -7,7 +7,7 @@ import { describe, expect, test } from "bun:test" import type { TuiPluginApi } from "@opencode-ai/plugin/tui" import type { V2Context } from "@opencode-cockpit/client/host" -import { DEFAULT_SEGMENTS, PRESETS, type SegmentConfig } from "../src/core/config.ts" +import { DEFAULT_SEGMENTS, PRESETS, type SegmentConfig } from "../src/core/config/index.ts" import { lastTurn, type SessionSnapshot, type StatusContext } from "../src/core/context.ts" import { buildSegments, segmentText } from "../src/core/segments.ts" import { sessionSnapshot, sessionSnapshotV2 } from "../src/tui/state/snapshot.ts" diff --git a/packages/subagents/README.md b/packages/subagents/README.md index 27bc2eb9..f57b0928 100644 --- a/packages/subagents/README.md +++ b/packages/subagents/README.md @@ -116,8 +116,7 @@ own), which wins over both files. Where the block sits is the top-level `"sidebar"` list's to say (`["status", "subagents", "shell", "trail", "trust"]` by default). The names from before 0.9 — `hideFinishedAfter`, `hideNestedAfter`, -`sidebarOrder` — are no longer read: the block shows a `!` row naming the new one, and -`/cockpit-setup` fixes it. +`sidebarOrder` — are not read; `/cockpit-setup` names them and fixes them. ## See it without OpenCode diff --git a/packages/subagents/src/cli/preview.ts b/packages/subagents/src/cli/preview.ts index cc6c44cd..fd58f46f 100644 --- a/packages/subagents/src/cli/preview.ts +++ b/packages/subagents/src/cli/preview.ts @@ -88,7 +88,7 @@ const FIXTURES: Record Change[]; root: string; about: s } /** A settings notice as the block draws it (client/settings `noticeText`), for `--notice`. */ -const NOTICE = 'settings: "subagents.hideFinishedAfter" is no longer read — run /cockpit-setup' +const NOTICE = 'settings: "subagents.sidebarRows" should be a number; the default is used' const args = process.argv.slice(2) if (args.includes("--help") || args.includes("-h")) { diff --git a/packages/subagents/src/core/config.ts b/packages/subagents/src/core/config.ts index 27bdd6a8..1736f0b6 100644 --- a/packages/subagents/src/core/config.ts +++ b/packages/subagents/src/core/config.ts @@ -6,7 +6,7 @@ * Only the `subagents` section of a file is read, by both halves: the interface's keys and the * agent's `guidance` sit in one place instead of in `tui.json` and `opencode.json` apart. Before 0.9 * the bay read no file at all. An old name (`hideFinishedAfter`, `hideNestedAfter`, `sidebarOrder`) - * is not read; it is a notice naming the new one, drawn in the Subagents block. + * is not read. */ import { baySettings, type SettingsNotice } from "@opencode-cockpit/client/settings" diff --git a/packages/subagents/src/core/view/calls.ts b/packages/subagents/src/core/view/calls.ts new file mode 100644 index 00000000..e9797d89 --- /dev/null +++ b/packages/subagents/src/core/view/calls.ts @@ -0,0 +1,409 @@ +import type { Entry, Node, Session } from "../model/model.ts" +import { ARG_PREVIEW, argumentRows, large } from "./args.ts" +import { markdownRows, plain } from "./markdown.ts" +import { elapsed, fit, type Row, type Run, rowText, spin, spread, widthOf, wrap } from "./rows.ts" +import { type Renderer, rendererOf, type Todo, targetOf, todosOf } from "./tools.ts" + +export const PAD = " " + +export type Tool = Extract + +/** A row without the padding `fit` put at its end. */ +export function trimEnd(row: Row): Row { + const out = [...row] + while (out.length > 1 && (out.at(-1) as Run).text.trim() === "") out.pop() + const last = out.at(-1) + if (last) out[out.length - 1] = { ...last, text: last.text.trimEnd() } + return out +} + +/** A body row, and the item it belongs to. */ +export interface Line { + row: Row + item?: string +} + +export const itemKey = (entry: Entry): string => + entry.kind === "tool" ? `tool:${entry.call}` : `${entry.kind}:${entry.key}` + +export const running = (s: Session) => + s.status === "running" || s.status === "starting" || s.status === "waiting" + +function timeOf(call: Tool, now: number): string { + if (call.state === "running" || call.state === "pending") return `running ${elapsed(now - call.at)}` + const ms = (call.ended ?? call.at) - call.at + /** A 7 ms read says nothing; a time is shown when it is worth reading. */ + if (ms < 1000) return "" + return ms < 10_000 ? `${(ms / 1000).toFixed(1)}s` : elapsed(ms) +} + +/** + * Lines of output a box shows: folded, open, and shown whole (`a`). An open read of a 2,000-line + * file was 400 rows to scroll through; open now shows enough to see what came back, and all of it is + * one more key away — capped even then, so one call cannot take over the pane. Arguments climb the + * same ladder, from `ARG_PREVIEW` rows each. + */ +const PREVIEW = 10 +const OPEN = 60 +const MOST = 2000 + +/** How a call is drawn: its row in the table, and what follows its title. */ +interface Drawing { + renderer: Renderer + target: string + /** A todo call's list; a todo call without one is drawn generic. */ + todos?: Todo[] + /** The subagent a `task` call launched, when it can be told. */ + child?: Session +} + +export function drawingOf( + call: Tool, + servers: readonly string[] | undefined, + child: Session | undefined, +): Drawing { + let renderer = rendererOf(call.name, servers) + const todos = renderer.kind === "todos" ? todosOf(call.input, call.output) : undefined + if (renderer.kind === "todos" && !todos) renderer = { ...renderer, kind: "generic" } + const target = + renderer.kind === "task" && child + ? [ + child.agent, + /** OpenCode's "(@explore subagent)" only repeats the agent named just before it. */ + child.title.replace(/\s*\(@[\w.-]+ subagent\)$/, "") || targetOf(call.name, "generic", call.input), + ] + .filter(Boolean) + .join(" · ") + : targetOf(call.name, renderer.kind, call.input) + return { renderer, target, ...(todos ? { todos } : {}), ...(child ? { child } : {}) } +} + +/** + * The subagent a `task` call launched: the one OpenCode's answer names (`task_id: ses_…`), or else + * the only child of this run titled by the call's description. Nothing when it cannot be told — + * `enter` then opens the call like any other, rather than going to the wrong subagent. + */ +export function childOf(call: Tool, session: Session, nodes: readonly Node[]): Session | undefined { + const children = nodes.map((node) => node.session).filter((s) => s.parentID === session.id) + if (children.length === 0) return undefined + const named = /\bses_[A-Za-z0-9]+/.exec(call.output.slice(0, 2000))?.[0] + const byId = named ? children.find((s) => s.id === named) : undefined + if (byId) return byId + const description = typeof call.input.description === "string" ? call.input.description.trim() : "" + if (!description) return undefined + /** OpenCode titles a subagent " (@ subagent)". */ + const titled = children.filter((s) => s.title === description || s.title.startsWith(`${description} (@`)) + return titled.length === 1 ? titled[0] : undefined +} + +function inlineLines(call: Tool, drawing: Drawing, width: number, now: number, frame: number): Line[] { + const key = itemKey(call) + const live = call.state === "running" || call.state === "pending" + const { icon, title } = drawing.renderer + /** A call that leads to a subagent says so; `enter` follows it. */ + const right = [ + [call.summary ?? "", timeOf(call, now)].filter(Boolean).join(" · "), + drawing.child ? "›" : "", + ] + .filter(Boolean) + .join(" ") + const lines: Line[] = [ + { + item: key, + row: spread( + [ + { text: PAD }, + live + ? { text: spin(frame), tone: "accent" } + : { text: icon, tone: call.state === "failed" ? "error" : "muted" }, + { text: ` ${title} `, tone: "text" }, + { text: drawing.target, tone: "muted" }, + ], + right ? [{ text: `${right} `, tone: live ? "accent" : "muted" }] : [], + width, + ), + }, + ] + if (call.state === "failed" && call.error) { + lines.push({ + item: key, + row: fit([{ text: `${PAD} ` }, { text: call.error.split("\n")[0] ?? "", tone: "error" }], width), + }) + } + return lines +} + +/** OpenCode's own marks for a todo's state. */ +const TODO: Record = { + completed: { mark: "[✓]", tone: "muted", done: true }, + in_progress: { mark: "[•]", tone: "accent" }, + cancelled: { mark: "[-]", tone: "muted", done: true }, + pending: { mark: "[ ]", tone: "muted" }, +} + +/** A todo call as the list it wrote: one line saying how far along, then the items. */ +function todoLines( + call: Tool, + drawing: Drawing, + width: number, + now: number, + frame: number, + limit: number, +): Line[] { + const key = itemKey(call) + const todos = drawing.todos ?? [] + const done = todos.filter((todo) => todo.status === "completed").length + const lines = inlineLines( + call, + { ...drawing, target: todos.length > 0 ? `${done}/${todos.length} done` : "empty" }, + width, + now, + frame, + ) + for (const todo of todos.slice(0, limit)) { + const state = TODO[todo.status] ?? (TODO.pending as (typeof TODO)[string]) + lines.push({ + item: key, + row: fit( + [ + { text: `${PAD} ` }, + { text: `${state.mark} `, tone: state.tone }, + { text: todo.content.replace(/\s+/g, " "), tone: state.done ? "muted" : "text", faint: state.done }, + ], + width, + ), + }) + } + const more = todos.length - limit + if (more > 0) + lines.push({ + item: key, + row: fit([{ text: `${PAD} … ${more} more`, tone: "muted" }], width), + }) + return lines +} + +/** + * A call as a box: a coloured edge, its own background, the command or the arguments, then the + * output — each climbing the same ladder: a few rows folded, more open, nearly all with `a`, and a + * line saying what is still hidden. + */ +function boxLines( + call: Tool, + drawing: Drawing, + width: number, + now: number, + frame: number, + isOpen: boolean, + whole: boolean, +): Line[] { + const key = itemKey(call) + const live = call.state === "running" || call.state === "pending" + const failed = call.state === "failed" + const { kind, icon, title } = drawing.renderer + const edge: Run = { + text: `${PAD.slice(1)}▎`, + tone: failed ? "error" : live ? "accent" : "border", + fill: "block", + } + const inner = width - widthOf(edge.text) - 3 + const row = (runs: Run[]): Line => ({ + item: key, + row: fit( + [edge, { text: " ", fill: "block" }, ...runs.map((run) => ({ ...run, fill: "block" as const }))], + width, + ), + }) + const blank = () => row([]) + + const shell = kind === "shell" + const right = [call.summary ?? "", timeOf(call, now)].filter(Boolean).join(" · ") + const heading: Run[] = shell + ? [ + { text: "$ ", tone: "muted" }, + { text: drawing.target, tone: "text", bold: true }, + ] + : [ + { text: `${icon} `, tone: "muted" }, + { text: `${title} `, tone: "text", bold: true }, + { text: drawing.target, tone: "text" }, + ] + const lines: Line[] = [blank()] + const tail: Run[] = live + ? [{ text: `${spin(frame)} ${right} `, tone: "accent" }] + : right + ? [{ text: `${right} `, tone: failed ? "error" : "muted" }] + : [] + lines.push({ + item: key, + row: spread( + [edge, { text: " ", fill: "block" }, ...heading.map((run) => ({ ...run, fill: "block" as const }))], + tail.map((run) => ({ ...run, fill: "block" as const })), + width, + ), + }) + + /** + * What it was called with, by type. A shell command's command is its heading; a file change's + * arguments are its whole contents, shown once you open it; anything else shows them always. + */ + let args = 0 + /** The last argument that was cut short, so the hint can join the row that says so. */ + let shortened: { at: number; said: string } | undefined + if (!shell && (isOpen || kind === "generic") && Object.keys(call.input).length > 0) { + const limit = isOpen ? (whole ? MOST : OPEN) : ARG_PREVIEW + const drawn = argumentRows(call.input, inner, limit, kind === "file" ? "verbatim" : "markdown") + args = drawn.most + lines.push(blank()) + for (const each of drawn.rows) { + const said = rowText(each).trimEnd() + if (/^ +… [\d,]+ more lines?$/.test(said)) shortened = { at: lines.length, said } + lines.push(row(each)) + } + } + + const output = (call.error ?? call.output ?? "").replace(/\s+$/, "") + const all = output ? output.split("\n") : [] + const limit = isOpen ? (whole ? MOST : OPEN) : PREVIEW + /** + * The hint counts both halves: a folded call with a long argument and no output still says it + * opens, and `a` is offered when either would show more. A running call's output is its tail, and + * the spinner already says there is more to come. + * + * Keys, not "Click to expand": the pane is driven from the keyboard, and a mouse-only instruction + * told a keyboard user nothing. `enter` opens and folds the selected call; a click still does too. + */ + const printed = live ? 0 : all.length + const opens = printed > PREVIEW || args > ARG_PREVIEW + const grows = printed > OPEN || args > OPEN + const hint = !opens + ? "" + : !isOpen + ? "[enter] Expand" + : grows && !whole + ? "[a] Show All · [enter] Collapse" + : "[enter] Collapse" + let hinted = false + if (all.length > 0) { + lines.push(blank()) + const shown = live ? all.slice(-limit) : all.slice(0, limit) + /** Cut to the pane before anything measures it: a minified file is one enormous line. */ + for (const text of shown) + lines.push(row([{ text: text.slice(0, width * 2), tone: call.error ? "error" : "text" }])) + const more = all.length - limit + /** What it hid and how to see it are one thought, so they are one row. */ + if (more > 0) { + const said = live + ? `… ${more.toLocaleString("en")} lines above` + : `… ${more.toLocaleString("en")} more line${more === 1 ? "" : "s"}` + hinted = Boolean(hint) && !live + lines.push(row([{ text: hinted ? `${said} · ${hint}` : said, tone: "muted" }])) + } + } + /** Only an argument was cut: the hint joins the row that says so. */ + if (hint && !hinted && shortened) { + lines[shortened.at] = row([{ text: `${shortened.said} · ${hint}`, tone: "muted" }]) + hinted = true + } + /** Nothing is cut — the call is open and all of it shows: the hint stands on its own. */ + if (hint && !hinted) lines.push(blank(), row([{ text: hint, tone: "muted" }])) + lines.push(blank()) + return lines +} + +/** Whether a call draws as a box, given whether it is open. */ +export function boxed(call: Tool, drawing: Drawing, isOpen: boolean, width: number): boolean { + switch (drawing.renderer.kind) { + case "shell": + case "file": + return true + case "todos": + return false + case "generic": + return isOpen || large(call.input, width) + default: + return isOpen + } +} + +export function toolLines( + call: Tool, + drawing: Drawing, + width: number, + now: number, + frame: number, + isOpen: boolean, + whole = false, +): Line[] { + if (drawing.renderer.kind === "todos") + return todoLines(call, drawing, width, now, frame, isOpen ? (whole ? MOST : OPEN) : PREVIEW) + return boxed(call, drawing, isOpen, width) + ? boxLines(call, drawing, width, now, frame, isOpen, whole) + : inlineLines(call, drawing, width, now, frame) +} + +/** + * Thinking the way OpenCode shows its own: "Thought · 1.2s", then the words, muted. Folded, the + * words follow on the same line, markup taken out, and are cut there. Open, they are markdown like + * the answer — a fence, a heading, a list drawn as one — only quieter: every run muted and faint. + */ +export function thinkingLines( + entry: Extract, + width: number, + isOpen: boolean, + took: number | undefined, +): Line[] { + const key = itemKey(entry) + const label = entry.done + ? `Thought${took !== undefined && took >= 100 ? ` · ${duration(took)}` : ""}` + : "Thinking…" + if (!isOpen) { + return [ + { + item: key, + row: fit( + [ + { text: `${PAD}◇ ${label} `, tone: "warning" }, + { text: plain(entry.text, width * 4), tone: "muted", faint: true }, + ], + width, + ), + }, + ] + } + const lines: Line[] = [{ item: key, row: fit([{ text: `${PAD}◆ ${label}`, tone: "warning" }], width) }] + for (const row of markdownRows(entry.text.trim() || "…", width - PAD.length, { indent: PAD.length + 2 })) { + lines.push({ + item: key, + row: fit( + row.map((run) => (run.text.trim() === "" ? run : { ...run, tone: "muted" as const, faint: true })), + width, + ), + }) + } + return lines +} + +/** `590ms`, `1.2s`, `2m04s`. */ +function duration(ms: number): string { + if (ms < 1000) return `${Math.round(ms)}ms` + return ms < 10_000 ? `${(ms / 1000).toFixed(1)}s` : elapsed(ms) +} + +export function cardLines( + label: string, + text: string, + width: number, + tone: Run["tone"], + fill: Run["fill"], + item?: string, +): Line[] { + const bar: Run = { text: `${PAD}▎ `, tone, fill } + const lines: Line[] = [ + { ...(item ? { item } : {}), row: fit([bar, { text: label, tone, bold: true, fill }], width) }, + ] + for (const line of wrap(text, width - PAD.length - 3)) { + lines.push({ ...(item ? { item } : {}), row: fit([bar, { text: line, tone: "text", fill }], width) }) + } + return lines +} diff --git a/packages/subagents/src/core/view/screen.ts b/packages/subagents/src/core/view/screen.ts index c3a511ad..5f2b8837 100644 --- a/packages/subagents/src/core/view/screen.ts +++ b/packages/subagents/src/core/view/screen.ts @@ -21,6 +21,8 @@ * Every item — a call, a block of thinking, a message you sent — is selectable (`j`/`k`) and opens * or folds (`enter`, a click). Every row is exactly `width`; there are exactly `height` rows; only * the body scrolls. Pure, like everything in `core/`. + * + * Each item's lines — a call's row or box, a todo list, a card, the thinking — come from `calls.ts`. */ import { @@ -33,23 +35,23 @@ import { toneOf, } from "@opencode-cockpit/client/design" import { callsOf, type Entry, type Node, type Session, titleOf } from "../model/model.ts" -import { ARG_PREVIEW, argumentRows, large } from "./args.ts" -import { markdownRows, plain } from "./markdown.ts" import { - compact, - cut, - elapsed, - fit, - type Row, - type Run, - rowText, - spin, - spread, - widthOf, - wrap, -} from "./rows.ts" + boxed, + cardLines, + childOf, + drawingOf, + itemKey, + type Line, + PAD, + running, + type Tool, + thinkingLines, + toolLines, + trimEnd, +} from "./calls.ts" +import { markdownRows } from "./markdown.ts" +import { compact, cut, fit, type Row, type Run, rowText, spread, widthOf, wrap } from "./rows.ts" import { firstStarted, runPhrase, stateOf } from "./sidebar.ts" -import { type Renderer, rendererOf, type Todo, targetOf, todosOf } from "./tools.ts" export interface ScreenInput { session: Session @@ -122,405 +124,6 @@ export interface Screen { links: Map } -const PAD = " " - -type Tool = Extract - -/** A row without the padding `fit` put at its end. */ -function trimEnd(row: Row): Row { - const out = [...row] - while (out.length > 1 && (out.at(-1) as Run).text.trim() === "") out.pop() - const last = out.at(-1) - if (last) out[out.length - 1] = { ...last, text: last.text.trimEnd() } - return out -} - -/** A body row, and the item it belongs to. */ -export interface Line { - row: Row - item?: string -} - -export const itemKey = (entry: Entry): string => - entry.kind === "tool" ? `tool:${entry.call}` : `${entry.kind}:${entry.key}` - -const running = (s: Session) => s.status === "running" || s.status === "starting" || s.status === "waiting" - -function timeOf(call: Tool, now: number): string { - if (call.state === "running" || call.state === "pending") return `running ${elapsed(now - call.at)}` - const ms = (call.ended ?? call.at) - call.at - /** A 7 ms read says nothing; a time is shown when it is worth reading. */ - if (ms < 1000) return "" - return ms < 10_000 ? `${(ms / 1000).toFixed(1)}s` : elapsed(ms) -} - -/** - * Lines of output a box shows: folded, open, and shown whole (`a`). An open read of a 2,000-line - * file was 400 rows to scroll through; open now shows enough to see what came back, and all of it is - * one more key away — capped even then, so one call cannot take over the pane. Arguments climb the - * same ladder, from `ARG_PREVIEW` rows each. - */ -const PREVIEW = 10 -const OPEN = 60 -const MOST = 2000 - -/** How a call is drawn: its row in the table, and what follows its title. */ -interface Drawing { - renderer: Renderer - target: string - /** A todo call's list; a todo call without one is drawn generic. */ - todos?: Todo[] - /** The subagent a `task` call launched, when it can be told. */ - child?: Session -} - -function drawingOf(call: Tool, servers: readonly string[] | undefined, child: Session | undefined): Drawing { - let renderer = rendererOf(call.name, servers) - const todos = renderer.kind === "todos" ? todosOf(call.input, call.output) : undefined - if (renderer.kind === "todos" && !todos) renderer = { ...renderer, kind: "generic" } - const target = - renderer.kind === "task" && child - ? [ - child.agent, - /** OpenCode's "(@explore subagent)" only repeats the agent named just before it. */ - child.title.replace(/\s*\(@[\w.-]+ subagent\)$/, "") || targetOf(call.name, "generic", call.input), - ] - .filter(Boolean) - .join(" · ") - : targetOf(call.name, renderer.kind, call.input) - return { renderer, target, ...(todos ? { todos } : {}), ...(child ? { child } : {}) } -} - -/** - * The subagent a `task` call launched: the one OpenCode's answer names (`task_id: ses_…`), or else - * the only child of this run titled by the call's description. Nothing when it cannot be told — - * `enter` then opens the call like any other, rather than going to the wrong subagent. - */ -function childOf(call: Tool, session: Session, nodes: readonly Node[]): Session | undefined { - const children = nodes.map((node) => node.session).filter((s) => s.parentID === session.id) - if (children.length === 0) return undefined - const named = /\bses_[A-Za-z0-9]+/.exec(call.output.slice(0, 2000))?.[0] - const byId = named ? children.find((s) => s.id === named) : undefined - if (byId) return byId - const description = typeof call.input.description === "string" ? call.input.description.trim() : "" - if (!description) return undefined - /** OpenCode titles a subagent " (@ subagent)". */ - const titled = children.filter((s) => s.title === description || s.title.startsWith(`${description} (@`)) - return titled.length === 1 ? titled[0] : undefined -} - -function inlineLines(call: Tool, drawing: Drawing, width: number, now: number, frame: number): Line[] { - const key = itemKey(call) - const live = call.state === "running" || call.state === "pending" - const { icon, title } = drawing.renderer - /** A call that leads to a subagent says so; `enter` follows it. */ - const right = [ - [call.summary ?? "", timeOf(call, now)].filter(Boolean).join(" · "), - drawing.child ? "›" : "", - ] - .filter(Boolean) - .join(" ") - const lines: Line[] = [ - { - item: key, - row: spread( - [ - { text: PAD }, - live - ? { text: spin(frame), tone: "accent" } - : { text: icon, tone: call.state === "failed" ? "error" : "muted" }, - { text: ` ${title} `, tone: "text" }, - { text: drawing.target, tone: "muted" }, - ], - right ? [{ text: `${right} `, tone: live ? "accent" : "muted" }] : [], - width, - ), - }, - ] - if (call.state === "failed" && call.error) { - lines.push({ - item: key, - row: fit([{ text: `${PAD} ` }, { text: call.error.split("\n")[0] ?? "", tone: "error" }], width), - }) - } - return lines -} - -/** OpenCode's own marks for a todo's state. */ -const TODO: Record = { - completed: { mark: "[✓]", tone: "muted", done: true }, - in_progress: { mark: "[•]", tone: "accent" }, - cancelled: { mark: "[-]", tone: "muted", done: true }, - pending: { mark: "[ ]", tone: "muted" }, -} - -/** A todo call as the list it wrote: one line saying how far along, then the items. */ -function todoLines( - call: Tool, - drawing: Drawing, - width: number, - now: number, - frame: number, - limit: number, -): Line[] { - const key = itemKey(call) - const todos = drawing.todos ?? [] - const done = todos.filter((todo) => todo.status === "completed").length - const lines = inlineLines( - call, - { ...drawing, target: todos.length > 0 ? `${done}/${todos.length} done` : "empty" }, - width, - now, - frame, - ) - for (const todo of todos.slice(0, limit)) { - const state = TODO[todo.status] ?? (TODO.pending as (typeof TODO)[string]) - lines.push({ - item: key, - row: fit( - [ - { text: `${PAD} ` }, - { text: `${state.mark} `, tone: state.tone }, - { text: todo.content.replace(/\s+/g, " "), tone: state.done ? "muted" : "text", faint: state.done }, - ], - width, - ), - }) - } - const more = todos.length - limit - if (more > 0) - lines.push({ - item: key, - row: fit([{ text: `${PAD} … ${more} more`, tone: "muted" }], width), - }) - return lines -} - -/** - * A call as a box: a coloured edge, its own background, the command or the arguments, then the - * output — each climbing the same ladder: a few rows folded, more open, nearly all with `a`, and a - * line saying what is still hidden. - */ -function boxLines( - call: Tool, - drawing: Drawing, - width: number, - now: number, - frame: number, - isOpen: boolean, - whole: boolean, -): Line[] { - const key = itemKey(call) - const live = call.state === "running" || call.state === "pending" - const failed = call.state === "failed" - const { kind, icon, title } = drawing.renderer - const edge: Run = { - text: `${PAD.slice(1)}▎`, - tone: failed ? "error" : live ? "accent" : "border", - fill: "block", - } - const inner = width - widthOf(edge.text) - 3 - const row = (runs: Run[]): Line => ({ - item: key, - row: fit( - [edge, { text: " ", fill: "block" }, ...runs.map((run) => ({ ...run, fill: "block" as const }))], - width, - ), - }) - const blank = () => row([]) - - const shell = kind === "shell" - const right = [call.summary ?? "", timeOf(call, now)].filter(Boolean).join(" · ") - const heading: Run[] = shell - ? [ - { text: "$ ", tone: "muted" }, - { text: drawing.target, tone: "text", bold: true }, - ] - : [ - { text: `${icon} `, tone: "muted" }, - { text: `${title} `, tone: "text", bold: true }, - { text: drawing.target, tone: "text" }, - ] - const lines: Line[] = [blank()] - const tail: Run[] = live - ? [{ text: `${spin(frame)} ${right} `, tone: "accent" }] - : right - ? [{ text: `${right} `, tone: failed ? "error" : "muted" }] - : [] - lines.push({ - item: key, - row: spread( - [edge, { text: " ", fill: "block" }, ...heading.map((run) => ({ ...run, fill: "block" as const }))], - tail.map((run) => ({ ...run, fill: "block" as const })), - width, - ), - }) - - /** - * What it was called with, by type. A shell command's command is its heading; a file change's - * arguments are its whole contents, shown once you open it; anything else shows them always. - */ - let args = 0 - /** The last argument that was cut short, so the hint can join the row that says so. */ - let shortened: { at: number; said: string } | undefined - if (!shell && (isOpen || kind === "generic") && Object.keys(call.input).length > 0) { - const limit = isOpen ? (whole ? MOST : OPEN) : ARG_PREVIEW - const drawn = argumentRows(call.input, inner, limit, kind === "file" ? "verbatim" : "markdown") - args = drawn.most - lines.push(blank()) - for (const each of drawn.rows) { - const said = rowText(each).trimEnd() - if (/^ +… [\d,]+ more lines?$/.test(said)) shortened = { at: lines.length, said } - lines.push(row(each)) - } - } - - const output = (call.error ?? call.output ?? "").replace(/\s+$/, "") - const all = output ? output.split("\n") : [] - const limit = isOpen ? (whole ? MOST : OPEN) : PREVIEW - /** - * The hint counts both halves: a folded call with a long argument and no output still says it - * opens, and `a` is offered when either would show more. A running call's output is its tail, and - * the spinner already says there is more to come. - * - * Keys, not "Click to expand": the pane is driven from the keyboard, and a mouse-only instruction - * told a keyboard user nothing. `enter` opens and folds the selected call; a click still does too. - */ - const printed = live ? 0 : all.length - const opens = printed > PREVIEW || args > ARG_PREVIEW - const grows = printed > OPEN || args > OPEN - const hint = !opens - ? "" - : !isOpen - ? "[enter] Expand" - : grows && !whole - ? "[a] Show All · [enter] Collapse" - : "[enter] Collapse" - let hinted = false - if (all.length > 0) { - lines.push(blank()) - const shown = live ? all.slice(-limit) : all.slice(0, limit) - /** Cut to the pane before anything measures it: a minified file is one enormous line. */ - for (const text of shown) - lines.push(row([{ text: text.slice(0, width * 2), tone: call.error ? "error" : "text" }])) - const more = all.length - limit - /** What it hid and how to see it are one thought, so they are one row. */ - if (more > 0) { - const said = live - ? `… ${more.toLocaleString("en")} lines above` - : `… ${more.toLocaleString("en")} more line${more === 1 ? "" : "s"}` - hinted = Boolean(hint) && !live - lines.push(row([{ text: hinted ? `${said} · ${hint}` : said, tone: "muted" }])) - } - } - /** Only an argument was cut: the hint joins the row that says so. */ - if (hint && !hinted && shortened) { - lines[shortened.at] = row([{ text: `${shortened.said} · ${hint}`, tone: "muted" }]) - hinted = true - } - /** Nothing is cut — the call is open and all of it shows: the hint stands on its own. */ - if (hint && !hinted) lines.push(blank(), row([{ text: hint, tone: "muted" }])) - lines.push(blank()) - return lines -} - -/** Whether a call draws as a box, given whether it is open. */ -function boxed(call: Tool, drawing: Drawing, isOpen: boolean, width: number): boolean { - switch (drawing.renderer.kind) { - case "shell": - case "file": - return true - case "todos": - return false - case "generic": - return isOpen || large(call.input, width) - default: - return isOpen - } -} - -function toolLines( - call: Tool, - drawing: Drawing, - width: number, - now: number, - frame: number, - isOpen: boolean, - whole = false, -): Line[] { - if (drawing.renderer.kind === "todos") - return todoLines(call, drawing, width, now, frame, isOpen ? (whole ? MOST : OPEN) : PREVIEW) - return boxed(call, drawing, isOpen, width) - ? boxLines(call, drawing, width, now, frame, isOpen, whole) - : inlineLines(call, drawing, width, now, frame) -} - -/** - * Thinking the way OpenCode shows its own: "Thought · 1.2s", then the words, muted. Folded, the - * words follow on the same line, markup taken out, and are cut there. Open, they are markdown like - * the answer — a fence, a heading, a list drawn as one — only quieter: every run muted and faint. - */ -function thinkingLines( - entry: Extract, - width: number, - isOpen: boolean, - took: number | undefined, -): Line[] { - const key = itemKey(entry) - const label = entry.done - ? `Thought${took !== undefined && took >= 100 ? ` · ${duration(took)}` : ""}` - : "Thinking…" - if (!isOpen) { - return [ - { - item: key, - row: fit( - [ - { text: `${PAD}◇ ${label} `, tone: "warning" }, - { text: plain(entry.text, width * 4), tone: "muted", faint: true }, - ], - width, - ), - }, - ] - } - const lines: Line[] = [{ item: key, row: fit([{ text: `${PAD}◆ ${label}`, tone: "warning" }], width) }] - for (const row of markdownRows(entry.text.trim() || "…", width - PAD.length, { indent: PAD.length + 2 })) { - lines.push({ - item: key, - row: fit( - row.map((run) => (run.text.trim() === "" ? run : { ...run, tone: "muted" as const, faint: true })), - width, - ), - }) - } - return lines -} - -/** `590ms`, `1.2s`, `2m04s`. */ -function duration(ms: number): string { - if (ms < 1000) return `${Math.round(ms)}ms` - return ms < 10_000 ? `${(ms / 1000).toFixed(1)}s` : elapsed(ms) -} - -function cardLines( - label: string, - text: string, - width: number, - tone: Run["tone"], - fill: Run["fill"], - item?: string, -): Line[] { - const bar: Run = { text: `${PAD}▎ `, tone, fill } - const lines: Line[] = [ - { ...(item ? { item } : {}), row: fit([bar, { text: label, tone, bold: true, fill }], width) }, - ] - for (const line of wrap(text, width - PAD.length - 3)) { - lines.push({ ...(item ? { item } : {}), row: fit([bar, { text: line, tone: "text", fill }], width) }) - } - return lines -} - /** * How much a call's arguments hold, for the paint cache: a call's input can arrive, or grow, after * the call is first drawn. Strings by length — a long question is not hashed on every paint. diff --git a/packages/subagents/src/tui/index.tsx b/packages/subagents/src/tui/index.tsx index 9a77db80..fbb19747 100644 --- a/packages/subagents/src/tui/index.tsx +++ b/packages/subagents/src/tui/index.tsx @@ -1,26 +1,26 @@ /** @jsxImportSource @opentui/solid */ +/** + * Subagents' interface half: the sidebar block — the subagents this conversation launched, live — + * and the pane that opens one of them. + * + * Everything that decides is in `core/`; this file wires it to the host: the conversation on screen + * followed, its subagents loaded and kept current, the painter (`paint.ts`), the pane and its keys + * (`pane.ts`), messages to a subagent (`messages.ts`), the palette's commands and the two slots. + */ + import { defaultKeys } from "@opencode-cockpit/client/catalog" import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client/feature" -import { bindingLookup, dualTui, type Host, type Layer, onPaste } from "@opencode-cockpit/client/host" +import { bindingLookup, dualTui, type Host } from "@opencode-cockpit/client/host" import { noticeText } from "@opencode-cockpit/client/settings" -import type { BoxRenderable } from "@opentui/core" -import { createSignal } from "solid-js" import { loadSubagents, type SubagentsConfig } from "../core/config.ts" import type { Change } from "../core/model/changes.ts" -import { - applyAll, - emptyModel, - type Node, - rootOf, - type Session, - subagentsOf, - titleOf, -} from "../core/model/model.ts" -import { createScreenCache, type Screen, screenRows } from "../core/view/screen.ts" -import { type SidebarLine, sidebarLines } from "../core/view/sidebar.ts" -import { createRowPool, type RowPool, solidSurface } from "./render.ts" +import { applyAll, emptyModel, type Node, rootOf, type Session, subagentsOf } from "../core/model/model.ts" +import { createMessages, type Messages, YOURS_KEY } from "./messages.ts" +import { createPainter } from "./paint.ts" +import { createPane, FULL_KEY, THINKING_KEY } from "./pane.ts" import { createSource } from "./source.ts" +import type { Surface } from "./surface.ts" import { Overlay } from "./view/overlay.tsx" import { SidebarBlock } from "./view/sidebar.tsx" @@ -31,48 +31,11 @@ const DEFAULT_KEYS = defaultKeys("subagents") /** The `subagents` section of the config files, then the plugin entry's options (core/config.ts). */ export type SubagentsTuiOptions = SubagentsConfig -/** The pane's state: which subagent, and how it is being looked at. */ -interface Surface { - open?: string - /** First body row shown; undefined follows the run. */ - top?: number - selected?: string - /** Items opened, or folded, by hand. */ - opened: Set - closed: Set - thinking: boolean - details: boolean - /** `?`: every key, in the body's place. */ - keys?: boolean - /** Half the window, or all of it. Remembered. */ - full: boolean - /** A message being typed, at the foot of the pane. */ - draft?: string - notice?: string - /** Calls shown whole rather than their first lines. */ - whole: Set - /** The cursor just moved: the next paint brings it into view, and only that one. */ - reveal?: boolean - /** The subagent a first `x` asked to stop; a second `x` in time stops it. */ - stopping?: string -} - -const FULL_KEY = "cockpit.subagents.full" -/** Messages you sent, as `:` — so the pane can tell yours from the main agent's. */ -const YOURS_KEY = "cockpit.subagents.yours" -const YOURS_MAX = 200 -/** How much of an answer is relayed to the main agent. */ -const RELAY_MAX = 4000 -const clip = (text: string, most: number) => (text.length > most ? `${text.slice(0, most - 1)}…` : text) -/** Thinking shown or folded — shown until you say otherwise, then as you left it. */ -const THINKING_KEY = "cockpit.subagents.thinking" /** Subagents removed from the list, by id — kept across restarts, the newest few hundred. */ const HIDDEN_KEY = "cockpit.subagents.hidden" const HIDDEN_MAX = 300 /** How long a run may say nothing before the host is asked whether it is still going. */ const QUIET_MS = 20_000 -/** How long the second `x` that confirms a stop is waited for. */ -const CONFIRM_MS = 4_000 /** Subagents' TUI half as a factory, so the `opencode-cockpit` bundle can include it. */ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: string } = {}) { @@ -109,16 +72,7 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st details: false, full: api.kv.get(FULL_KEY, false), } - let frame = 0 let root: string | undefined - let backdrop: BoxRenderable | undefined - let panel: BoxRenderable | undefined - let pool: RowPool | undefined - let shown: Screen | undefined - /** Each item's rows between paints: a scroll or a tick redraws only what changed. */ - const cache = createScreenCache() - let disposeKeys: (() => void) | undefined - const [lines, setLines] = createSignal([]) /** The conversation on screen — or the one a subagent you are looking at belongs to. */ const current = (): string | undefined => { @@ -137,150 +91,13 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st const nodes = (): Node[] => root ? subagentsOf(model, root).filter((node) => !isHidden(node.session.id)) : [] const opened = (): Session | undefined => (surface.open ? model.sessions.get(surface.open) : undefined) - let block: BoxRenderable | undefined - let drawnAt = 0 - let sidebarSaid = "" - /** - * The width the sidebar gave the block, once laid out; a guess before that. Guessed, the rows ran - * past the edge and were clipped: "3 done" drew as "3 d". - */ - let told = 0 - const sidebarWidth = () => { - /** - * The container the host gave the block, not the block: rows wider than the sidebar stretch the - * block with them, so its own width only ever agreed with the guess. - */ - const parent = (block?.parent as { width?: number } | null | undefined)?.width ?? 0 - const own = block?.width ?? 0 - const measured = parent >= 12 ? Math.min(parent, own >= 12 ? own : parent) : own - if (measured !== told) { - told = measured - log.debug("sidebar width", { measured, own, parent, window: api.renderer.width }) - } - return measured >= 12 ? measured : Math.max(20, Math.min(40, Math.floor(api.renderer.width / 4) - 2)) - } const busy = (session: Session) => session.status === "running" || session.status === "starting" const working = () => nodes().some(({ session }) => busy(session) || session.status === "waiting") - /** Half the window, but never so narrow a call's arguments cannot be read. */ - const paneWidth = () => { - const width = api.renderer.width - return surface.full ? width : Math.min(width, Math.max(72, Math.floor(width / 2))) - } - - // --- painting ---------------------------------------------------------------------------------- - - /** Milliseconds a finished nested subagent stays in the sidebar; undefined keeps it. */ - const nested = options.hideNestedAfterSeconds ?? 30 - const fadeAfter = nested >= 0 ? nested * 1000 : undefined - /** A finished nested one that has yet to leave: the clock has to keep drawing until it does. */ - const fading = () => - fadeAfter !== undefined && - nodes().some( - ({ session, depth }) => - depth >= 1 && - (session.status === "done" || session.status === "failed") && - Date.now() - (session.ended ?? 0) < fadeAfter + 2000, - ) - - const paint = () => { - const now = Date.now() - const list = nodes() - drawnAt = sidebarWidth() - const after = options.hideFinishedAfterMinutes - const listed = - typeof after === "number" && after >= 0 - ? list.filter( - ({ session }) => - !(session.status === "done" || session.status === "failed") || - now - (session.ended ?? now) < after * 60_000, - ) - : list - const next = sidebarLines({ - nodes: listed, - width: drawnAt, - now, - frame, - limit: options.sidebarRows, - ...(fadeAfter !== undefined ? { fadeAfter } : {}), - hideWhenEmpty: options.hideWhenEmpty, - notices: warnings, - }) - /** Only when they changed: new rows rebuild every line of the block, and a scroll is many paints. */ - const said = JSON.stringify(next) - if (said !== sidebarSaid) { - sidebarSaid = said - setLines(next) - } - const session = opened() - if (backdrop && panel && pool) { - const show = Boolean(session) - const height = api.renderer.height - backdrop.width = api.renderer.width - backdrop.height = show ? height : 0 - backdrop.visible = show - panel.backgroundColor = solidSurface(api.theme.current) - panel.width = paneWidth() - panel.height = show ? height : 0 - if (session && show) { - const launcher = session.parentID ? model.sessions.get(session.parentID)?.agent : undefined - const started = performance.now() - const screen = screenRows({ - cache, - session, - nodes: list, - ...(launcher ? { launcher } : {}), - width: paneWidth(), - height, - now, - frame, - ...(surface.top !== undefined ? { top: surface.top } : {}), - ...(surface.selected ? { selected: surface.selected } : {}), - open: surface.opened, - closed: surface.closed, - thinking: surface.thinking, - details: surface.details, - keys: surface.keys === true, - whole: surface.whole, - yours: (entry) => yours.has(`${session.id}:${entry.text}`), - reveal: surface.reveal === true, - ...(surface.draft !== undefined ? { input: { draft: surface.draft, busy: busy(session) } } : {}), - ...(surface.notice ? { notice: surface.notice } : {}), - }) - /** Scrolled back to the end: follow the run again. */ - /** Where the reveal left the view is where it stays. */ - if (surface.reveal && surface.top !== undefined) surface.top = screen.top - surface.reveal = false - if (surface.top !== undefined && screen.top >= screen.most && !surface.selected) - surface.top = undefined - shown = screen - pool.draw(screen.rows, api.theme.current) - const took = performance.now() - started - /** A paint past a frame is worth knowing about; the run's size says why. */ - if (took > 16) log.debug("slow paint", { ms: Math.round(took), entries: session.entries.length }) - /** The prompt's cursor would otherwise blink through the pane, as it did over Review. */ - setTimeout(() => { - if (surface.open) api.renderer.setCursorPosition(0, 0, false) - }, 0) - } else { - shown = undefined - pool.clear() - } - } - api.renderer.requestRender() - } - /** Several asks in one turn are one paint, of the state the turn ended on (Shell's painter). */ - let scheduled = false - const draw = () => { - if (scheduled) return - scheduled = true - setTimeout(() => { - scheduled = false - try { - paint() - } catch (error) { - log.error("paint failed", { error }) - } - }, 0) + const painter = createPainter({ api, log, options, warnings, model, surface, yours, nodes, opened, busy }) + const draw = painter.draw + const set = (change: Partial) => { + Object.assign(surface, change) + draw() } // --- data -------------------------------------------------------------------------------------- @@ -290,7 +107,7 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st applyAll(model, changes) for (const change of changes) { if (change.type === "status" && change.status !== "busy" && change.status !== "waiting") - relay(change.id) + messages.relay(change.id) if (change.type === "session" && change.parentID) log.debug("subagent", { id: change.id, parent: change.parentID, agent: change.agent }) /** Removed, then started again (the main agent continued it): it belongs in the list again. */ @@ -357,535 +174,43 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st follow() reconcile() /** The sidebar was laid out, or resized, since the rows were drawn. */ - if (sidebarWidth() !== drawnAt) draw() + if (painter.resized()) draw() if (working()) { - frame++ + painter.tick() draw() - } else if (options.hideFinishedAfterMinutes !== undefined || fading()) draw() // finished ones age out with nothing running + } else if (options.hideFinishedAfterMinutes !== undefined || painter.fading()) draw() // finished ones age out with nothing running }, 1000) - let fast: ReturnType | undefined - - // --- the pane ---------------------------------------------------------------------------------- - - /** The pane's letters, taken only while it is up — a global layer; a targeted one never fires. */ - const takeKeys = () => { - disposeKeys ??= api.keymap.registerLayer(layer()) - } - const dropKeys = () => { - disposeKeys?.() - disposeKeys = undefined - } - - const close = () => { - if (!surface.open) return - log.debug("close", { id: surface.open }) - surface.open = undefined - surface.draft = undefined - dropKeys() - clearInterval(fast) - fast = undefined - backdrop?.blur() - draw() - } - - const open = (id: string) => { - if (!model.sessions.has(id)) return - log.debug("open", { id, full: surface.full }) - const same = surface.open === id - Object.assign(surface, { - open: id, - notice: undefined, - stopping: undefined, - draft: undefined, - ...(same ? {} : { top: undefined, selected: undefined, details: false, keys: false }), - }) - takeKeys() - fast ??= setInterval(() => { - const session = opened() - if (session && busy(session)) { - frame++ - draw() - } - }, 150) - /** As Shell's full screen does: focus leaves the prompt, so its cursor stops blinking through. */ - backdrop?.focus() - draw() - } - - const step = (by: number) => { - const list = nodes() - const at = list.findIndex((node) => node.session.id === surface.open) - const next = list[(at + by + list.length) % list.length] - if (next) open(next.session.id) - } - - const scroll = (by: number) => { - const most = shown?.most ?? 0 - const top = Math.max(0, Math.min(most, (surface.top ?? most) + by)) - surface.top = top >= most ? undefined : top - draw() - } - - /** The cursor onto the next or previous item; the first press lands on the last one in view. */ - const select = (by: number) => { - const list = shown?.keys ?? [] - if (list.length === 0) return - const at = surface.selected ? list.indexOf(surface.selected) : -1 - const next = at < 0 ? list.length - 1 : Math.max(0, Math.min(list.length - 1, at + by)) - surface.selected = list[next] - /** Pinned where it is, so the pane scrolls to the cursor rather than the run. */ - surface.top = shown?.top - surface.reveal = true - draw() - } - - /** Opens the item, or folds it: whichever it is not now. */ - const toggle = (key: string | undefined = surface.selected) => { - if (!key) return - /** A `task` call that launched a subagent is a way into it — the same pane ←/→ would reach. */ - const child = shown?.links.get(key) - if (child && model.sessions.has(child) && !isHidden(child)) { - log.debug("follow task", { key, child }) - open(child) - return - } - const isOpen = shown?.opened.includes(key) ?? false - if (isOpen) { - surface.opened.delete(key) - surface.closed.add(key) - } else { - surface.closed.delete(key) - surface.opened.add(key) - } - if (isOpen) surface.whole.delete(key) - surface.selected = key - surface.top = shown?.top - /** Folding a long call you had scrolled into brings its first line back into view. */ - surface.reveal = true - log.debug("toggle", { key, open: !isOpen }) - draw() - } - - /** `a`: the selected call's whole output, or back to its first lines. */ - const showAll = () => { - const key = surface.selected - if (!key?.startsWith("tool:")) return - const all = !surface.whole.has(key) - if (all) { - surface.whole.add(key) - surface.closed.delete(key) - surface.opened.add(key) - } else surface.whole.delete(key) - surface.top = shown?.top - surface.reveal = !all - log.debug("show all", { key, all }) - draw() - } - /** Every call open, or every one folded. */ - const expandAll = () => { - const calls = (shown?.keys ?? []).filter((key) => key.startsWith("tool:")) - const all = calls.length > 0 && calls.every((key) => shown?.opened.includes(key)) - surface.opened = all ? new Set() : new Set(calls) - surface.closed = all ? new Set(calls) : new Set() - draw() - } - - const click = (y: number) => { - if (!shown || surface.draft !== undefined) return - const key = shown.items[y] - if (key) toggle(key) - } - - const startMessage = () => { - if (!opened()) return - surface.draft = "" - surface.notice = undefined - log.debug("message: typing", { id: surface.open }) - draw() - } - - /** - * Messages you sent a finished subagent, until it answers. Its answer goes nowhere on its own — the - * main agent's task returned long ago — so once it is idle again, what you asked and what it said - * are added to the main conversation, quietly: no turn starts, and the main agent knows next time. - */ - const asked = new Map() - /** How long a subagent that went idle is given to start a run of its own for a queued message. */ - const SETTLE_MS = 3000 - - /** The entries after your message: what the subagent did with it, if anything. */ - const after = (id: string, question: string) => { - const entries = model.sessions.get(id)?.entries ?? [] - const at = entries.findLastIndex((entry) => entry.kind === "prompt" && entry.text === question) - return at < 0 ? [] : entries.slice(at + 1) - } - - const relay = (id: string) => { - if (!asked.has(id)) return - /** - * Settled first: sent while it was busy, OpenCode 1 may queue the message and start a run for it - * straight after — or finish without reading it at all, which happened: the message sat in the - * run as "Round 3" and nothing answered it. - */ - setTimeout(() => { - const pending = asked.get(id) - const session = model.sessions.get(id) - if (!pending || !session) return - if (busy(session) || session.status === "waiting") { - /** A run of its own for the message: its answer goes nowhere unless it is relayed. */ - pending.busy = false - return - } - asked.delete(id) - const followed = after(id, pending.question) - if (!followed.some((entry) => entry.kind !== "prompt")) { - log.warn("message not answered", { id }) - if (surface.open === id && surface.draft === undefined) - set({ - draft: pending.question, - notice: `${session.agent} finished without reading your message — enter sends it again, esc drops it.`, - }) - return - } - /** Read inside a run the main agent was waiting on: its answer already went there. */ - if (pending.busy || !session.parentID) return - const answer = followed - .filter((entry): entry is Extract => entry.kind === "reply") - .map((entry) => entry.text.trim()) - .filter(Boolean) - .join("\n\n") - const parent = model.sessions.get(session.parentID) - const how = api.v1 ? "task_id" : "sessionID" - const text = [ - "[Cockpit notification — information, not a request. Nothing to do unless the user asks.]", - `The user messaged your ${session.agent} subagent "${titleOf(session)}" (${how} ${id}) directly.`, - `They asked: ${pending.question}`, - session.status === "failed" - ? `It failed: ${session.error ?? "no reason given"}` - : `It answered: ${answer ? clip(answer, RELAY_MAX) : "(nothing)"}`, - ].join("\n") - feed - .quiet(session.parentID, text, parent && parent.agent !== "agent" ? parent.agent : undefined) - .then(() => { - log.info("relayed to the main agent", { id, parent: session.parentID }) - if (surface.open === id) - set({ notice: `The main agent now knows what ${session.agent} answered you.` }) - }) - .catch((error) => log.warn("relay failed", { id, error })) - }, SETTLE_MS) - } - - const sendMessage = () => { - const session = opened() - const text = surface.draft?.trim() - surface.draft = undefined - if (!session || !text) return draw() - /** Taken now: the model may learn something about the session before the call returns. */ - const { id, agent } = session - const wasBusy = busy(session) - yours.add(`${id}:${text}`) - api.kv.set(YOURS_KEY, [...yours].slice(-YOURS_MAX)) - /** Working, it answers the main agent itself; finished, its answer is relayed once it comes. */ - /** Watched either way: answered in a run of its own, it is relayed; not answered, it comes back. */ - asked.set(id, { question: text, busy: wasBusy }) - surface.notice = `Sending to ${agent}…` - surface.top = undefined - draw() - feed - .send(id, text, wasBusy, agent) - .then(() => { - log.info("message sent", { id, agent, busy: wasBusy }) - surface.notice = `Sent to ${agent}.` - }) - .catch((error) => { - log.error("message failed", { id, error }) - surface.notice = `Not sent: ${error instanceof Error ? error.message : String(error)}` - }) - .finally(draw) - } - - /** A paste while typing goes into the message — one line, as the field is. */ - const offPaste = onPaste(api, (text) => { - if (!surface.open || surface.draft === undefined) return false - surface.draft += text.replace(/\r?\n/g, " ") - log.debug("message: pasted", { chars: text.length }) - draw() - return true + const messages: Messages = createMessages({ + api, + log, + model, + surface, + yours, + feed: () => feed, + opened, + busy, + draw, + set, }) - api.lifecycle.onDispose(offPaste) - - /** Typing a message takes every key before the layer, so `e`, `t`, `j`… go into the words. */ - api.keymap.intercept( - (ctx) => { - if (!surface.open || surface.draft === undefined) return - const event = ctx.event - ctx.consume({ preventDefault: true, stopPropagation: true }) - if (event.name === "escape") { - surface.draft = undefined - return draw() - } - if (event.name === "return" || event.name === "enter") return sendMessage() - if (event.name === "backspace") surface.draft = surface.draft.slice(0, -1) - else if (event.sequence && !event.ctrl && !event.meta && event.sequence >= " ") - surface.draft += event.sequence - draw() - }, - { priority: 10_000 }, - ) - - let confirmTimer: ReturnType | undefined - - /** Out of the list — the session itself stays in OpenCode, and comes back if it works again. */ - const remove = (ids: string[]) => { - if (ids.length === 0) return - for (const id of ids) { - hidden.delete(id) - hidden.add(id) - } - saveHidden() - log.info("removed", { count: ids.length }) - if (surface.open && isHidden(surface.open)) { - const next = nodes().at(-1) - if (next) open(next.session.id) - else close() - } - draw() - } - - const finished = () => - nodes() - .map((node) => node.session) - .filter((session) => session.status === "done" || session.status === "failed") - .map((session) => session.id) - - /** `x`: a working one stops — on a second `x`, since it cannot be undone; a finished one leaves the list. */ - const stopOrRemove = () => { - const session = opened() - if (!session) return - if (!busy(session) && session.status !== "waiting") return remove([session.id]) - /** - * Stopping is only for a run OpenCode says is going. One Cockpit had wrong — shown running after a - * reopen — was "stopped", and the main agent was told about work that had long ended. - */ - const said = feed.check(session.id) - if ( - said.some( - (change) => change.type === "status" && change.status !== "busy" && change.status !== "waiting", - ) - ) { - applyAll(model, said) - log.info("stop skipped: not running", { id: session.id }) - return set({ - notice: `${session.agent} had already finished — nothing to stop. x again removes it.`, - stopping: undefined, - }) - } - if (surface.stopping !== session.id) { - surface.stopping = session.id - surface.notice = `Press x again to stop ${session.agent}.` - clearTimeout(confirmTimer) - confirmTimer = setTimeout(() => { - if (surface.stopping) set({ stopping: undefined, notice: undefined }) - }, CONFIRM_MS) - return draw() - } - clearTimeout(confirmTimer) - const { id, agent, title, parentID } = session - surface.stopping = undefined - surface.notice = `Stopping ${agent}…` - draw() - const parent = parentID ? model.sessions.get(parentID) : undefined - /** - * The main agent is told first, and why, so the stop reaches it with a reason. Measured on both: - * told, it says the subagent was stopped and waits; untold, it read a failure and relaunched. - */ - const note = parentID - ? feed - .note( - parentID, - `[Cockpit] I stopped the ${agent} subagent${title ? ` "${title}"` : ""} on purpose. Don't start it again unless I ask.`, - parent ? busy(parent) || parent.status === "waiting" : true, - parent && parent.agent !== "agent" ? parent.agent : undefined, - ) - .catch((error: unknown) => log.warn("stop note failed", { parentID, error })) - : Promise.resolve() - note - .then(() => feed.stop(id)) - .then(() => { - log.info("stopped", { id, agent, told: Boolean(parentID) }) - surface.notice = `Stopped ${agent}. The main agent was told you stopped it.` - }) - .catch((error) => { - log.error("stop failed", { id, error }) - surface.notice = `Not stopped: ${error instanceof Error ? error.message : String(error)}` - }) - .finally(draw) - } - - /** - * `b`: the conversation stops waiting for this subagent — OpenCode's `ctrl+b`, from here. It moves - * every subagent that conversation is blocked on, which is what the host offers. - */ - const toBackground = () => { - const session = opened() - if (!session?.parentID) return - if (!busy(session)) return set({ notice: `${session.agent} is not running.` }) - if (session.background) return set({ notice: `${session.agent} already runs in the background.` }) - const { id, agent, parentID } = session - surface.notice = `Moving ${agent} to the background…` - draw() - feed - .background(parentID) - .then((moved) => { - log.info("background", { id, parentID, moved }) - if (moved) applyAll(model, [{ type: "session", id, background: true, at: Date.now() }]) - surface.notice = moved - ? `${agent} runs in the background; the main agent carries on and hears when it finishes.` - : "OpenCode 1 does this only when started with OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true." - }) - .catch((error) => { - log.error("background failed", { id, error }) - surface.notice = `Not moved: ${error instanceof Error ? error.message : String(error)}` - }) - .finally(draw) - } - - const clearFinished = () => { - const ids = finished() - remove(ids) - if (ids.length === 0) - api.ui.toast({ variant: "info", title: "Subagents", message: "No finished subagents to clear." }) - } - - const restore = () => { - const count = hidden.size - hidden.clear() - saveHidden() - log.info("restored", { count }) - api.ui.toast({ - variant: "info", - title: "Subagents", - message: count - ? `${count} removed subagent${count === 1 ? "" : "s"} back in the list.` - : "None removed.", - }) - draw() - } - - const set = (change: Partial) => { - Object.assign(surface, change) - draw() - } - - const width = () => { - surface.full = !surface.full - api.kv.set(FULL_KEY, surface.full) - log.debug("width", { full: surface.full }) - draw() - } - - const layer = (): Layer => ({ - priority: 100, - commands: [ - { name: "cockpit.subagents.next", title: "Next subagent", run: () => step(1) }, - { name: "cockpit.subagents.prev", title: "Previous subagent", run: () => step(-1) }, - { name: "cockpit.subagents.message", title: "Message this subagent", run: () => startMessage() }, - { name: "cockpit.subagents.stop", title: "Stop, or remove from the list", run: () => stopOrRemove() }, - { - name: "cockpit.subagents.clear", - title: "Remove every finished subagent", - run: () => clearFinished(), - }, - { name: "cockpit.subagents.background", title: "Move to the background", run: () => toBackground() }, - { name: "cockpit.subagents.all", title: "Show a call's whole output", run: () => showAll() }, - { name: "cockpit.subagents.down", title: "Next item", run: () => select(1) }, - { name: "cockpit.subagents.up", title: "Previous item", run: () => select(-1) }, - { name: "cockpit.subagents.toggle", title: "Open or fold", run: () => toggle() }, - { name: "cockpit.subagents.expand", title: "Open every call", run: () => expandAll() }, - { - name: "cockpit.subagents.thinking", - title: "Show or hide thinking", - run: () => { - api.kv.set(THINKING_KEY, !surface.thinking) - /** A block folded or opened by hand follows the new choice. */ - surface.opened.clear() - surface.closed.clear() - set({ thinking: !surface.thinking }) - }, - }, - { - name: "cockpit.subagents.details", - title: "Details", - run: () => set({ details: !surface.details, top: undefined }), - }, - { name: "cockpit.subagents.width", title: "Half or full width", run: () => width() }, - { - name: "cockpit.subagents.keys", - title: "Show every key", - /** From the top of the list going in; back where the run was coming out. */ - run: () => set({ keys: !surface.keys, top: surface.keys ? undefined : 0 }), - }, - { name: "cockpit.subagents.pageDown", title: "Scroll down", run: () => scroll(10) }, - { name: "cockpit.subagents.pageUp", title: "Scroll up", run: () => scroll(-10) }, - { - name: "cockpit.subagents.follow", - title: "Follow the run", - run: () => set({ top: undefined, selected: undefined }), - }, - { name: "cockpit.subagents.top", title: "Scroll to the start", run: () => set({ top: 0 }) }, - { - name: "cockpit.subagents.close", - title: "Close", - /** Esc first hides the keys, then lets go of the cursor, then closes. */ - run: () => - surface.keys - ? set({ keys: false, top: undefined }) - : surface.selected - ? set({ selected: undefined, top: undefined }) - : close(), - }, - ], - bindings: [ - { key: "],right", cmd: "cockpit.subagents.next" }, - { key: "[,left", cmd: "cockpit.subagents.prev" }, - { key: "m", cmd: "cockpit.subagents.message" }, - { key: "x", cmd: "cockpit.subagents.stop" }, - { key: "shift+x", cmd: "cockpit.subagents.clear" }, - { key: "b", cmd: "cockpit.subagents.background" }, - { key: "a", cmd: "cockpit.subagents.all" }, - { key: "j,down", cmd: "cockpit.subagents.down" }, - { key: "k,up", cmd: "cockpit.subagents.up" }, - { key: "return,space", cmd: "cockpit.subagents.toggle" }, - { key: "e", cmd: "cockpit.subagents.expand" }, - { key: "t", cmd: "cockpit.subagents.thinking" }, - { key: "i", cmd: "cockpit.subagents.details" }, - { key: "w", cmd: "cockpit.subagents.width" }, - { key: "?,shift+/", cmd: "cockpit.subagents.keys" }, - { key: "d,pagedown", cmd: "cockpit.subagents.pageDown" }, - { key: "u,pageup", cmd: "cockpit.subagents.pageUp" }, - { key: "shift+g,end", cmd: "cockpit.subagents.follow" }, - { key: "g,home", cmd: "cockpit.subagents.top" }, - { key: "q,escape", cmd: "cockpit.subagents.close" }, - ], + const pane = createPane({ + api, + log, + model, + surface, + painter, + messages, + feed, + hidden, + saveHidden, + isHidden, + nodes, + opened, + busy, + follow, + set, }) - /** From anywhere: the working subagent, or the latest one, of the conversation on screen. */ - const openLatest = () => { - follow() - const list = nodes() - const pick = - list.find(({ session }) => session.status === "running" || session.status === "waiting") ?? - list.at(-1) - if (pick) open(pick.session.id) - else - api.ui.toast({ - variant: "info", - title: "Subagents", - message: "No subagents in this conversation yet.", - }) - } - api.keymap.registerLayer({ commands: [ { @@ -894,7 +219,7 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st category: "Cockpit · Subagents", namespace: "palette", slashName: "subagents", - run: () => openLatest(), + run: () => pane.openLatest(), }, { name: "cockpit.subagents.clearFinished", @@ -902,14 +227,14 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st desc: "from the sidebar", category: "Cockpit · Subagents", namespace: "palette", - run: () => clearFinished(), + run: () => pane.clearFinished(), }, { name: "cockpit.subagents.restore", title: "Show removed subagents again", category: "Cockpit · Subagents", namespace: "palette", - run: () => restore(), + run: () => pane.restore(), }, ], bindings: keys.gather("cockpit", Object.keys(DEFAULT_KEYS)), @@ -922,11 +247,9 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st sidebar_content: () => ( { - block = box - }} + lines={painter.lines} + onOpen={pane.open} + onReady={(box) => painter.setBlock(box)} /> ), }, @@ -937,18 +260,13 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st app_bottom: () => ( { - backdrop = parts.backdrop - panel = parts.panel - pool = createRowPool(parts.lines) - draw() - }} + onReady={(parts) => painter.attach(parts)} /** Clicking off the pane closes it; at full width there is no "off" to click. */ onDismiss={() => { - if (!surface.full) close() + if (!surface.full) pane.close() }} - onClick={(y) => click(y)} - onScroll={(delta) => scroll(delta)} + onClick={(y) => pane.click(y)} + onScroll={(delta) => pane.scroll(delta)} /> ), }, @@ -957,11 +275,8 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st follow() api.lifecycle.onDispose(() => { clearInterval(slow) - clearInterval(fast) - clearTimeout(confirmTimer) slow = undefined - fast = undefined - dropKeys() + pane.dispose() feed.dispose() }) } diff --git a/packages/subagents/src/tui/messages.ts b/packages/subagents/src/tui/messages.ts new file mode 100644 index 00000000..a3473959 --- /dev/null +++ b/packages/subagents/src/tui/messages.ts @@ -0,0 +1,182 @@ +/** + * Messaging a subagent from its pane: `m` starts a message at the foot, every key goes into it until + * enter sends or esc drops it, and a paste goes in too. A finished subagent's answer goes nowhere on + * its own, so once it has answered, what you asked and what it said are relayed to the main agent. + */ + +import type { Host } from "@opencode-cockpit/client/host" +import { onPaste } from "@opencode-cockpit/client/host" +import type { Log } from "@opencode-cockpit/client/log" +import { type Model, type Session, titleOf } from "../core/model/model.ts" +import type { Source } from "./source.ts" +import type { Surface } from "./surface.ts" + +/** Messages you sent, as `:` — so the pane can tell yours from the main agent's. */ +export const YOURS_KEY = "cockpit.subagents.yours" +const YOURS_MAX = 200 +/** How much of an answer is relayed to the main agent. */ +const RELAY_MAX = 4000 +const clip = (text: string, most: number) => (text.length > most ? `${text.slice(0, most - 1)}…` : text) + +export interface Messages { + /** `m`: a message to the subagent open in the pane. */ + startMessage(): void + /** A subagent went idle: if you had asked it something, its answer goes to the main agent. */ + relay(id: string): void +} + +export function createMessages(input: { + api: Host + log: Log + model: Model + surface: Surface + yours: Set + feed: () => Source + opened: () => Session | undefined + busy: (session: Session) => boolean + draw: () => void + set: (change: Partial) => void +}): Messages { + const { api, log, model, surface, yours, opened, busy, draw, set } = input + + const startMessage = () => { + if (!opened()) return + surface.draft = "" + surface.notice = undefined + log.debug("message: typing", { id: surface.open }) + draw() + } + + /** + * Messages you sent a finished subagent, until it answers. Its answer goes nowhere on its own — the + * main agent's task returned long ago — so once it is idle again, what you asked and what it said + * are added to the main conversation, quietly: no turn starts, and the main agent knows next time. + */ + const asked = new Map() + /** How long a subagent that went idle is given to start a run of its own for a queued message. */ + const SETTLE_MS = 3000 + + /** The entries after your message: what the subagent did with it, if anything. */ + const after = (id: string, question: string) => { + const entries = model.sessions.get(id)?.entries ?? [] + const at = entries.findLastIndex((entry) => entry.kind === "prompt" && entry.text === question) + return at < 0 ? [] : entries.slice(at + 1) + } + + const relay = (id: string) => { + if (!asked.has(id)) return + /** + * Settled first: sent while it was busy, OpenCode 1 may queue the message and start a run for it + * straight after — or finish without reading it at all, which happened: the message sat in the + * run as "Round 3" and nothing answered it. + */ + setTimeout(() => { + const pending = asked.get(id) + const session = model.sessions.get(id) + if (!pending || !session) return + if (busy(session) || session.status === "waiting") { + /** A run of its own for the message: its answer goes nowhere unless it is relayed. */ + pending.busy = false + return + } + asked.delete(id) + const followed = after(id, pending.question) + if (!followed.some((entry) => entry.kind !== "prompt")) { + log.warn("message not answered", { id }) + if (surface.open === id && surface.draft === undefined) + set({ + draft: pending.question, + notice: `${session.agent} finished without reading your message — enter sends it again, esc drops it.`, + }) + return + } + /** Read inside a run the main agent was waiting on: its answer already went there. */ + if (pending.busy || !session.parentID) return + const answer = followed + .filter((entry): entry is Extract => entry.kind === "reply") + .map((entry) => entry.text.trim()) + .filter(Boolean) + .join("\n\n") + const parent = model.sessions.get(session.parentID) + const how = api.v1 ? "task_id" : "sessionID" + const text = [ + "[Cockpit notification — information, not a request. Nothing to do unless the user asks.]", + `The user messaged your ${session.agent} subagent "${titleOf(session)}" (${how} ${id}) directly.`, + `They asked: ${pending.question}`, + session.status === "failed" + ? `It failed: ${session.error ?? "no reason given"}` + : `It answered: ${answer ? clip(answer, RELAY_MAX) : "(nothing)"}`, + ].join("\n") + input + .feed() + .quiet(session.parentID, text, parent && parent.agent !== "agent" ? parent.agent : undefined) + .then(() => { + log.info("relayed to the main agent", { id, parent: session.parentID }) + if (surface.open === id) + set({ notice: `The main agent now knows what ${session.agent} answered you.` }) + }) + .catch((error) => log.warn("relay failed", { id, error })) + }, SETTLE_MS) + } + + const sendMessage = () => { + const session = opened() + const text = surface.draft?.trim() + surface.draft = undefined + if (!session || !text) return draw() + /** Taken now: the model may learn something about the session before the call returns. */ + const { id, agent } = session + const wasBusy = busy(session) + yours.add(`${id}:${text}`) + api.kv.set(YOURS_KEY, [...yours].slice(-YOURS_MAX)) + /** Working, it answers the main agent itself; finished, its answer is relayed once it comes. */ + /** Watched either way: answered in a run of its own, it is relayed; not answered, it comes back. */ + asked.set(id, { question: text, busy: wasBusy }) + surface.notice = `Sending to ${agent}…` + surface.top = undefined + draw() + input + .feed() + .send(id, text, wasBusy, agent) + .then(() => { + log.info("message sent", { id, agent, busy: wasBusy }) + surface.notice = `Sent to ${agent}.` + }) + .catch((error) => { + log.error("message failed", { id, error }) + surface.notice = `Not sent: ${error instanceof Error ? error.message : String(error)}` + }) + .finally(draw) + } + + /** A paste while typing goes into the message — one line, as the field is. */ + const offPaste = onPaste(api, (text) => { + if (!surface.open || surface.draft === undefined) return false + surface.draft += text.replace(/\r?\n/g, " ") + log.debug("message: pasted", { chars: text.length }) + draw() + return true + }) + api.lifecycle.onDispose(offPaste) + + /** Typing a message takes every key before the layer, so `e`, `t`, `j`… go into the words. */ + api.keymap.intercept( + (ctx) => { + if (!surface.open || surface.draft === undefined) return + const event = ctx.event + ctx.consume({ preventDefault: true, stopPropagation: true }) + if (event.name === "escape") { + surface.draft = undefined + return draw() + } + if (event.name === "return" || event.name === "enter") return sendMessage() + if (event.name === "backspace") surface.draft = surface.draft.slice(0, -1) + else if (event.sequence && !event.ctrl && !event.meta && event.sequence >= " ") + surface.draft += event.sequence + draw() + }, + { priority: 10_000 }, + ) + + return { startMessage, relay } +} diff --git a/packages/subagents/src/tui/paint.ts b/packages/subagents/src/tui/paint.ts new file mode 100644 index 00000000..88968195 --- /dev/null +++ b/packages/subagents/src/tui/paint.ts @@ -0,0 +1,219 @@ +/** + * Painting: the sidebar block's lines and the pane's rows, from the model as it is now. `draw` asks + * for a paint and folds every ask in one turn into it; `paint` paints now. The pane is drawn into + * the overlay's row pool, which the host hands over once it is laid out (`attach`). + */ + +import type { Host } from "@opencode-cockpit/client/host" +import type { Log } from "@opencode-cockpit/client/log" +import { blockWidth, measureBlock } from "@opencode-cockpit/client/sidebar" +import type { BoxRenderable, TextRenderable } from "@opentui/core" +import { type Accessor, createSignal } from "solid-js" +import type { SubagentsConfig } from "../core/config.ts" +import type { Model, Node, Session } from "../core/model/model.ts" +import { createScreenCache, type Screen, screenRows } from "../core/view/screen.ts" +import { type SidebarLine, sidebarLines } from "../core/view/sidebar.ts" +import { createRowPool, type RowPool, solidSurface } from "./render.ts" +import type { Surface } from "./surface.ts" + +export interface Painter { + readonly lines: Accessor + /** The block the host laid out: its width is what the rows are cut to. */ + setBlock(box: BoxRenderable): void + /** The overlay, once the host has laid it out: the pane draws into it from then on. */ + attach(parts: { backdrop: BoxRenderable; panel: BoxRenderable; lines: TextRenderable[] }): void + /** The pane takes focus from the prompt, or gives it back. */ + focus(): void + blur(): void + /** The sidebar was laid out, or resized, since the rows were drawn. */ + resized(): boolean + /** A finished nested subagent has yet to leave: the clock has to keep drawing until it does. */ + fading(): boolean + /** The spinners' next frame. */ + tick(): void + paint(): void + draw(): void + /** What the pane shows now, as last painted: what keys and clicks act on. */ + shown(): Screen | undefined +} + +export function createPainter(input: { + api: Host + log: Log + options: SubagentsConfig + /** Said in the block for the session, one `!` row each, until the file is fixed. */ + warnings: readonly string[] + model: Model + surface: Surface + yours: ReadonlySet + nodes: () => Node[] + opened: () => Session | undefined + busy: (session: Session) => boolean +}): Painter { + const { api, log, options, warnings, model, surface, yours, nodes, opened, busy } = input + let frame = 0 + let backdrop: BoxRenderable | undefined + let panel: BoxRenderable | undefined + let pool: RowPool | undefined + let shown: Screen | undefined + /** Each item's rows between paints: a scroll or a tick redraws only what changed. */ + const cache = createScreenCache() + const [lines, setLines] = createSignal([]) + let block: BoxRenderable | undefined + let drawnAt = 0 + let sidebarSaid = "" + /** + * The width the sidebar gave the block, once laid out; a guess before that. Guessed, the rows ran + * past the edge and were clipped: "3 done" drew as "3 d". + */ + let told = 0 + const sidebarWidth = () => { + const { measured, own, parent } = measureBlock(block) + if (measured !== told) { + told = measured + log.debug("sidebar width", { measured, own, parent, window: api.renderer.width }) + } + return blockWidth(block, api.renderer.width) + } + /** Half the window, but never so narrow a call's arguments cannot be read. */ + const paneWidth = () => { + const width = api.renderer.width + return surface.full ? width : Math.min(width, Math.max(72, Math.floor(width / 2))) + } + + /** Milliseconds a finished nested subagent stays in the sidebar; undefined keeps it. */ + const nested = options.hideNestedAfterSeconds ?? 30 + const fadeAfter = nested >= 0 ? nested * 1000 : undefined + const fading = () => + fadeAfter !== undefined && + nodes().some( + ({ session, depth }) => + depth >= 1 && + (session.status === "done" || session.status === "failed") && + Date.now() - (session.ended ?? 0) < fadeAfter + 2000, + ) + + const paint = () => { + const now = Date.now() + const list = nodes() + drawnAt = sidebarWidth() + const after = options.hideFinishedAfterMinutes + const listed = + typeof after === "number" && after >= 0 + ? list.filter( + ({ session }) => + !(session.status === "done" || session.status === "failed") || + now - (session.ended ?? now) < after * 60_000, + ) + : list + const next = sidebarLines({ + nodes: listed, + width: drawnAt, + now, + frame, + limit: options.sidebarRows, + ...(fadeAfter !== undefined ? { fadeAfter } : {}), + hideWhenEmpty: options.hideWhenEmpty, + notices: warnings, + }) + /** Only when they changed: new rows rebuild every line of the block, and a scroll is many paints. */ + const said = JSON.stringify(next) + if (said !== sidebarSaid) { + sidebarSaid = said + setLines(next) + } + const session = opened() + if (backdrop && panel && pool) { + const show = Boolean(session) + const height = api.renderer.height + backdrop.width = api.renderer.width + backdrop.height = show ? height : 0 + backdrop.visible = show + panel.backgroundColor = solidSurface(api.theme.current) + panel.width = paneWidth() + panel.height = show ? height : 0 + if (session && show) { + const launcher = session.parentID ? model.sessions.get(session.parentID)?.agent : undefined + const started = performance.now() + const screen = screenRows({ + cache, + session, + nodes: list, + ...(launcher ? { launcher } : {}), + width: paneWidth(), + height, + now, + frame, + ...(surface.top !== undefined ? { top: surface.top } : {}), + ...(surface.selected ? { selected: surface.selected } : {}), + open: surface.opened, + closed: surface.closed, + thinking: surface.thinking, + details: surface.details, + keys: surface.keys === true, + whole: surface.whole, + yours: (entry) => yours.has(`${session.id}:${entry.text}`), + reveal: surface.reveal === true, + ...(surface.draft !== undefined ? { input: { draft: surface.draft, busy: busy(session) } } : {}), + ...(surface.notice ? { notice: surface.notice } : {}), + }) + /** Scrolled back to the end: follow the run again. */ + /** Where the reveal left the view is where it stays. */ + if (surface.reveal && surface.top !== undefined) surface.top = screen.top + surface.reveal = false + if (surface.top !== undefined && screen.top >= screen.most && !surface.selected) + surface.top = undefined + shown = screen + pool.draw(screen.rows, api.theme.current) + const took = performance.now() - started + /** A paint past a frame is worth knowing about; the run's size says why. */ + if (took > 16) log.debug("slow paint", { ms: Math.round(took), entries: session.entries.length }) + /** The prompt's cursor would otherwise blink through the pane, as it did over Review. */ + setTimeout(() => { + if (surface.open) api.renderer.setCursorPosition(0, 0, false) + }, 0) + } else { + shown = undefined + pool.clear() + } + } + api.renderer.requestRender() + } + /** Several asks in one turn are one paint, of the state the turn ended on (Shell's painter). */ + let scheduled = false + const draw = () => { + if (scheduled) return + scheduled = true + setTimeout(() => { + scheduled = false + try { + paint() + } catch (error) { + log.error("paint failed", { error }) + } + }, 0) + } + + return { + lines, + setBlock: (box) => { + block = box + }, + attach: (parts) => { + backdrop = parts.backdrop + panel = parts.panel + pool = createRowPool(parts.lines) + draw() + }, + focus: () => backdrop?.focus(), + blur: () => backdrop?.blur(), + resized: () => sidebarWidth() !== drawnAt, + fading, + tick: () => { + frame++ + }, + paint, + draw, + shown: () => shown, + } +} diff --git a/packages/subagents/src/tui/pane.ts b/packages/subagents/src/tui/pane.ts new file mode 100644 index 00000000..d6b9e6a1 --- /dev/null +++ b/packages/subagents/src/tui/pane.ts @@ -0,0 +1,454 @@ +/** + * The pane: one subagent at a time, half the window or all of it. Opening and closing it, moving + * between subagents and through the items, folding, scrolling, stopping, removing, moving to the + * background — and the keys it takes only while it is up. + */ + +import type { Host, Layer } from "@opencode-cockpit/client/host" +import type { Log } from "@opencode-cockpit/client/log" +import { applyAll, type Model, type Node, type Session } from "../core/model/model.ts" +import type { Messages } from "./messages.ts" +import type { Painter } from "./paint.ts" +import type { Source } from "./source.ts" +import type { Surface } from "./surface.ts" + +export const FULL_KEY = "cockpit.subagents.full" +/** Thinking shown or folded — shown until you say otherwise, then as you left it. */ +export const THINKING_KEY = "cockpit.subagents.thinking" +/** How long the second `x` that confirms a stop is waited for. */ +const CONFIRM_MS = 4_000 + +export interface Pane { + open(id: string): void + close(): void + scroll(by: number): void + click(y: number): void + /** From anywhere: the working subagent, or the latest one, of the conversation on screen. */ + openLatest(): void + clearFinished(): void + restore(): void + dispose(): void +} + +export function createPane(input: { + api: Host + log: Log + model: Model + surface: Surface + painter: Painter + messages: Messages + feed: Source + /** Subagents removed from the list by hand, kept across restarts. */ + hidden: Set + saveHidden: () => void + isHidden: (id: string) => boolean + nodes: () => Node[] + opened: () => Session | undefined + busy: (session: Session) => boolean + /** The conversation on screen, looked at again. */ + follow: () => void + set: (change: Partial) => void +}): Pane { + const { api, log, model, surface, painter, messages, feed, hidden, saveHidden, isHidden } = input + const { nodes, opened, busy, follow, set } = input + const draw = painter.draw + let disposeKeys: (() => void) | undefined + let fast: ReturnType | undefined + + /** The pane's letters, taken only while it is up — a global layer; a targeted one never fires. */ + const takeKeys = () => { + disposeKeys ??= api.keymap.registerLayer(layer()) + } + const dropKeys = () => { + disposeKeys?.() + disposeKeys = undefined + } + + const close = () => { + if (!surface.open) return + log.debug("close", { id: surface.open }) + surface.open = undefined + surface.draft = undefined + dropKeys() + clearInterval(fast) + fast = undefined + painter.blur() + draw() + } + + const open = (id: string) => { + if (!model.sessions.has(id)) return + log.debug("open", { id, full: surface.full }) + const same = surface.open === id + Object.assign(surface, { + open: id, + notice: undefined, + stopping: undefined, + draft: undefined, + ...(same ? {} : { top: undefined, selected: undefined, details: false, keys: false }), + }) + takeKeys() + fast ??= setInterval(() => { + const session = opened() + if (session && busy(session)) { + painter.tick() + draw() + } + }, 150) + /** As Shell's full screen does: focus leaves the prompt, so its cursor stops blinking through. */ + painter.focus() + draw() + } + + const step = (by: number) => { + const list = nodes() + const at = list.findIndex((node) => node.session.id === surface.open) + const next = list[(at + by + list.length) % list.length] + if (next) open(next.session.id) + } + + const scroll = (by: number) => { + const most = painter.shown()?.most ?? 0 + const top = Math.max(0, Math.min(most, (surface.top ?? most) + by)) + surface.top = top >= most ? undefined : top + draw() + } + + /** The cursor onto the next or previous item; the first press lands on the last one in view. */ + const select = (by: number) => { + const list = painter.shown()?.keys ?? [] + if (list.length === 0) return + const at = surface.selected ? list.indexOf(surface.selected) : -1 + const next = at < 0 ? list.length - 1 : Math.max(0, Math.min(list.length - 1, at + by)) + surface.selected = list[next] + /** Pinned where it is, so the pane scrolls to the cursor rather than the run. */ + surface.top = painter.shown()?.top + surface.reveal = true + draw() + } + + /** Opens the item, or folds it: whichever it is not now. */ + const toggle = (key: string | undefined = surface.selected) => { + if (!key) return + /** A `task` call that launched a subagent is a way into it — the same pane ←/→ would reach. */ + const child = painter.shown()?.links.get(key) + if (child && model.sessions.has(child) && !isHidden(child)) { + log.debug("follow task", { key, child }) + open(child) + return + } + const isOpen = painter.shown()?.opened.includes(key) ?? false + if (isOpen) { + surface.opened.delete(key) + surface.closed.add(key) + } else { + surface.closed.delete(key) + surface.opened.add(key) + } + if (isOpen) surface.whole.delete(key) + surface.selected = key + surface.top = painter.shown()?.top + /** Folding a long call you had scrolled into brings its first line back into view. */ + surface.reveal = true + log.debug("toggle", { key, open: !isOpen }) + draw() + } + + /** `a`: the selected call's whole output, or back to its first lines. */ + const showAll = () => { + const key = surface.selected + if (!key?.startsWith("tool:")) return + const all = !surface.whole.has(key) + if (all) { + surface.whole.add(key) + surface.closed.delete(key) + surface.opened.add(key) + } else surface.whole.delete(key) + surface.top = painter.shown()?.top + surface.reveal = !all + log.debug("show all", { key, all }) + draw() + } + + /** Every call open, or every one folded. */ + const expandAll = () => { + const calls = (painter.shown()?.keys ?? []).filter((key) => key.startsWith("tool:")) + const all = calls.length > 0 && calls.every((key) => painter.shown()?.opened.includes(key)) + surface.opened = all ? new Set() : new Set(calls) + surface.closed = all ? new Set(calls) : new Set() + draw() + } + + const click = (y: number) => { + const shown = painter.shown() + if (!shown || surface.draft !== undefined) return + const key = shown.items[y] + if (key) toggle(key) + } + + let confirmTimer: ReturnType | undefined + + /** Out of the list — the session itself stays in OpenCode, and comes back if it works again. */ + const remove = (ids: string[]) => { + if (ids.length === 0) return + for (const id of ids) { + hidden.delete(id) + hidden.add(id) + } + saveHidden() + log.info("removed", { count: ids.length }) + if (surface.open && isHidden(surface.open)) { + const next = nodes().at(-1) + if (next) open(next.session.id) + else close() + } + draw() + } + + const finished = () => + nodes() + .map((node) => node.session) + .filter((session) => session.status === "done" || session.status === "failed") + .map((session) => session.id) + + /** `x`: a working one stops — on a second `x`, since it cannot be undone; a finished one leaves the list. */ + const stopOrRemove = () => { + const session = opened() + if (!session) return + if (!busy(session) && session.status !== "waiting") return remove([session.id]) + /** + * Stopping is only for a run OpenCode says is going. One Cockpit had wrong — shown running after a + * reopen — was "stopped", and the main agent was told about work that had long ended. + */ + const said = feed.check(session.id) + if ( + said.some( + (change) => change.type === "status" && change.status !== "busy" && change.status !== "waiting", + ) + ) { + applyAll(model, said) + log.info("stop skipped: not running", { id: session.id }) + return set({ + notice: `${session.agent} had already finished — nothing to stop. x again removes it.`, + stopping: undefined, + }) + } + if (surface.stopping !== session.id) { + surface.stopping = session.id + surface.notice = `Press x again to stop ${session.agent}.` + clearTimeout(confirmTimer) + confirmTimer = setTimeout(() => { + if (surface.stopping) set({ stopping: undefined, notice: undefined }) + }, CONFIRM_MS) + return draw() + } + clearTimeout(confirmTimer) + const { id, agent, title, parentID } = session + surface.stopping = undefined + surface.notice = `Stopping ${agent}…` + draw() + const parent = parentID ? model.sessions.get(parentID) : undefined + /** + * The main agent is told first, and why, so the stop reaches it with a reason. Measured on both: + * told, it says the subagent was stopped and waits; untold, it read a failure and relaunched. + */ + const note = parentID + ? feed + .note( + parentID, + `[Cockpit] I stopped the ${agent} subagent${title ? ` "${title}"` : ""} on purpose. Don't start it again unless I ask.`, + parent ? busy(parent) || parent.status === "waiting" : true, + parent && parent.agent !== "agent" ? parent.agent : undefined, + ) + .catch((error: unknown) => log.warn("stop note failed", { parentID, error })) + : Promise.resolve() + note + .then(() => feed.stop(id)) + .then(() => { + log.info("stopped", { id, agent, told: Boolean(parentID) }) + surface.notice = `Stopped ${agent}. The main agent was told you stopped it.` + }) + .catch((error) => { + log.error("stop failed", { id, error }) + surface.notice = `Not stopped: ${error instanceof Error ? error.message : String(error)}` + }) + .finally(draw) + } + + /** + * `b`: the conversation stops waiting for this subagent — OpenCode's `ctrl+b`, from here. It moves + * every subagent that conversation is blocked on, which is what the host offers. + */ + const toBackground = () => { + const session = opened() + if (!session?.parentID) return + if (!busy(session)) return set({ notice: `${session.agent} is not running.` }) + if (session.background) return set({ notice: `${session.agent} already runs in the background.` }) + const { id, agent, parentID } = session + surface.notice = `Moving ${agent} to the background…` + draw() + feed + .background(parentID) + .then((moved) => { + log.info("background", { id, parentID, moved }) + if (moved) applyAll(model, [{ type: "session", id, background: true, at: Date.now() }]) + surface.notice = moved + ? `${agent} runs in the background; the main agent carries on and hears when it finishes.` + : "OpenCode 1 does this only when started with OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true." + }) + .catch((error) => { + log.error("background failed", { id, error }) + surface.notice = `Not moved: ${error instanceof Error ? error.message : String(error)}` + }) + .finally(draw) + } + + const clearFinished = () => { + const ids = finished() + remove(ids) + if (ids.length === 0) + api.ui.toast({ variant: "info", title: "Subagents", message: "No finished subagents to clear." }) + } + + const restore = () => { + const count = hidden.size + hidden.clear() + saveHidden() + log.info("restored", { count }) + api.ui.toast({ + variant: "info", + title: "Subagents", + message: count + ? `${count} removed subagent${count === 1 ? "" : "s"} back in the list.` + : "None removed.", + }) + draw() + } + + const width = () => { + surface.full = !surface.full + api.kv.set(FULL_KEY, surface.full) + log.debug("width", { full: surface.full }) + draw() + } + + const layer = (): Layer => ({ + priority: 100, + commands: [ + { name: "cockpit.subagents.next", title: "Next subagent", run: () => step(1) }, + { name: "cockpit.subagents.prev", title: "Previous subagent", run: () => step(-1) }, + { + name: "cockpit.subagents.message", + title: "Message this subagent", + run: () => messages.startMessage(), + }, + { name: "cockpit.subagents.stop", title: "Stop, or remove from the list", run: () => stopOrRemove() }, + { + name: "cockpit.subagents.clear", + title: "Remove every finished subagent", + run: () => clearFinished(), + }, + { name: "cockpit.subagents.background", title: "Move to the background", run: () => toBackground() }, + { name: "cockpit.subagents.all", title: "Show a call's whole output", run: () => showAll() }, + { name: "cockpit.subagents.down", title: "Next item", run: () => select(1) }, + { name: "cockpit.subagents.up", title: "Previous item", run: () => select(-1) }, + { name: "cockpit.subagents.toggle", title: "Open or fold", run: () => toggle() }, + { name: "cockpit.subagents.expand", title: "Open every call", run: () => expandAll() }, + { + name: "cockpit.subagents.thinking", + title: "Show or hide thinking", + run: () => { + api.kv.set(THINKING_KEY, !surface.thinking) + /** A block folded or opened by hand follows the new choice. */ + surface.opened.clear() + surface.closed.clear() + set({ thinking: !surface.thinking }) + }, + }, + { + name: "cockpit.subagents.details", + title: "Details", + run: () => set({ details: !surface.details, top: undefined }), + }, + { name: "cockpit.subagents.width", title: "Half or full width", run: () => width() }, + { + name: "cockpit.subagents.keys", + title: "Show every key", + /** From the top of the list going in; back where the run was coming out. */ + run: () => set({ keys: !surface.keys, top: surface.keys ? undefined : 0 }), + }, + { name: "cockpit.subagents.pageDown", title: "Scroll down", run: () => scroll(10) }, + { name: "cockpit.subagents.pageUp", title: "Scroll up", run: () => scroll(-10) }, + { + name: "cockpit.subagents.follow", + title: "Follow the run", + run: () => set({ top: undefined, selected: undefined }), + }, + { name: "cockpit.subagents.top", title: "Scroll to the start", run: () => set({ top: 0 }) }, + { + name: "cockpit.subagents.close", + title: "Close", + /** Esc first hides the keys, then lets go of the cursor, then closes. */ + run: () => + surface.keys + ? set({ keys: false, top: undefined }) + : surface.selected + ? set({ selected: undefined, top: undefined }) + : close(), + }, + ], + bindings: [ + { key: "],right", cmd: "cockpit.subagents.next" }, + { key: "[,left", cmd: "cockpit.subagents.prev" }, + { key: "m", cmd: "cockpit.subagents.message" }, + { key: "x", cmd: "cockpit.subagents.stop" }, + { key: "shift+x", cmd: "cockpit.subagents.clear" }, + { key: "b", cmd: "cockpit.subagents.background" }, + { key: "a", cmd: "cockpit.subagents.all" }, + { key: "j,down", cmd: "cockpit.subagents.down" }, + { key: "k,up", cmd: "cockpit.subagents.up" }, + { key: "return,space", cmd: "cockpit.subagents.toggle" }, + { key: "e", cmd: "cockpit.subagents.expand" }, + { key: "t", cmd: "cockpit.subagents.thinking" }, + { key: "i", cmd: "cockpit.subagents.details" }, + { key: "w", cmd: "cockpit.subagents.width" }, + { key: "?,shift+/", cmd: "cockpit.subagents.keys" }, + { key: "d,pagedown", cmd: "cockpit.subagents.pageDown" }, + { key: "u,pageup", cmd: "cockpit.subagents.pageUp" }, + { key: "shift+g,end", cmd: "cockpit.subagents.follow" }, + { key: "g,home", cmd: "cockpit.subagents.top" }, + { key: "q,escape", cmd: "cockpit.subagents.close" }, + ], + }) + + /** From anywhere: the working subagent, or the latest one, of the conversation on screen. */ + const openLatest = () => { + follow() + const list = nodes() + const pick = + list.find(({ session }) => session.status === "running" || session.status === "waiting") ?? list.at(-1) + if (pick) open(pick.session.id) + else + api.ui.toast({ + variant: "info", + title: "Subagents", + message: "No subagents in this conversation yet.", + }) + } + + return { + open, + close, + scroll, + click, + openLatest, + clearFinished, + restore, + dispose: () => { + clearInterval(fast) + clearTimeout(confirmTimer) + fast = undefined + dropKeys() + }, + } +} diff --git a/packages/subagents/src/tui/surface.ts b/packages/subagents/src/tui/surface.ts new file mode 100644 index 00000000..403f8e4e --- /dev/null +++ b/packages/subagents/src/tui/surface.ts @@ -0,0 +1,28 @@ +/** + * The pane's state: which subagent, and how it is being looked at — shared by what paints it + * (`paint.ts`), what moves around it (`pane.ts`) and what types into it (`messages.ts`). + */ +export interface Surface { + open?: string + /** First body row shown; undefined follows the run. */ + top?: number + selected?: string + /** Items opened, or folded, by hand. */ + opened: Set + closed: Set + thinking: boolean + details: boolean + /** `?`: every key, in the body's place. */ + keys?: boolean + /** Half the window, or all of it. Remembered. */ + full: boolean + /** A message being typed, at the foot of the pane. */ + draft?: string + notice?: string + /** Calls shown whole rather than their first lines. */ + whole: Set + /** The cursor just moved: the next paint brings it into view, and only that one. */ + reveal?: boolean + /** The subagent a first `x` asked to stop; a second `x` in time stops it. */ + stopping?: string +} diff --git a/packages/subagents/test/settings.test.ts b/packages/subagents/test/settings.test.ts index 022437ca..fb8c7511 100644 --- a/packages/subagents/test/settings.test.ts +++ b/packages/subagents/test/settings.test.ts @@ -65,18 +65,13 @@ describe("settings", () => { expect(config.enabled).toBe(true) }) - test("old names are not read: each is a notice naming the new one", () => { + /** Names from before 0.9 are unknown names now: /cockpit-setup and doctor say so, the block does not. */ + test("old names are not read", () => { const { directory, env } = files({ subagents: { hideFinishedAfter: 5, sidebarOrder: 2 } }) const { config, notices } = loadSubagents(directory, { hideNestedAfter: 9 }, env) expect(config.hideFinishedAfterMinutes).toBeUndefined() expect(config.hideNestedAfterSeconds).toBe(30) - expect(notices.map((notice) => [notice.old, notice.new])).toEqual( - expect.arrayContaining([ - ["subagents.hideFinishedAfter", "subagents.hideFinishedAfterMinutes"], - ["subagents.sidebarOrder", "sidebar"], - ["hideNestedAfter", "subagents.hideNestedAfterSeconds"], - ]), - ) + expect(notices).toEqual([]) }) test("a value of the wrong kind is the default, with a notice; the order is the `sidebar` list's", () => { diff --git a/packages/subagents/test/sidebar.test.ts b/packages/subagents/test/sidebar.test.ts index 80a0ff40..3e3c7567 100644 --- a/packages/subagents/test/sidebar.test.ts +++ b/packages/subagents/test/sidebar.test.ts @@ -455,7 +455,7 @@ describe("names", () => { }) describe("present when empty", () => { - const NOTICE = 'settings: "subagents.hideFinishedAfter" is no longer read — run /cockpit-setup' + const NOTICE = 'settings: "subagents.sidebarRows" should be a number; the default is used' const empty = (extra: Partial = {}) => sidebarLines({ nodes: [], width: 30, now: SAMPLE_NOW, frame: 0, ...extra }).map((line) => rowText(line.row), @@ -481,7 +481,7 @@ describe("present when empty", () => { expect(empty({ hideWhenEmpty: true })).toEqual([]) const rows = empty({ hideWhenEmpty: true, notices: [NOTICE] }) expect(rows[0]?.trimEnd()).toBe("Subagents") - expect(rows.join(" ")).toContain("/cockpit-setup") + expect(rows.join(" ").replace(/\s+/g, " ")).toContain("the default is used") }) test("a settings notice is said in the block, wrapped, its fix kept, at every width", () => { @@ -493,7 +493,7 @@ describe("present when empty", () => { for (const row of rows) expect(widthOf(row)).toBe(width) const at = rows.findIndex((row) => row.startsWith("! settings")) expect(at).toBeGreaterThan(2) - expect(rows.slice(at).join(" ")).toContain("/cockpit-setup") + expect(rows.slice(at).join(" ").replace(/\s+/g, " ")).toContain("the default is used") } }) }) diff --git a/packages/trail/measure/agent.ts b/packages/trail/measure/agent.ts index 2a139a98..13bdd9b2 100644 --- a/packages/trail/measure/agent.ts +++ b/packages/trail/measure/agent.ts @@ -23,25 +23,17 @@ import { mkdtempSync, realpathSync, rmSync } from "node:fs" import { join, resolve } from "node:path" +import { brief, flag, measure, openCode, turn, world } from "../../../scripts/measure-agent.ts" import { trailPaths } from "../src/core/paths.ts" import { parseLines } from "../src/core/store.ts" -const args = process.argv.slice(2) -const value = (flag: string) => { - const at = args.indexOf(flag) - return at >= 0 ? args[at + 1] : undefined -} -const opencode = process.env.OPENCODE ?? Bun.which("opencode") -if (!opencode) { - console.error("opencode binary not found: set OPENCODE") - process.exit(2) -} -const plugin = resolve(value("--plugin") ?? join(import.meta.dir, "..")) -const runs = Number(value("--runs")) || 1 +const oc = openCode() +const plugin = resolve(flag("--plugin") ?? join(import.meta.dir, "..")) +const runs = Number(flag("--runs")) || 1 /** Runs that must record the PR; every run when unset, and never more than there are. */ -const pass = Math.min(runs, Number(value("--pass")) || runs) -const model = value("--model") ?? "opencode/space-bunny-free" -const keep = args.includes("--keep") +const pass = Math.min(runs, Number(flag("--pass")) || runs) +const model = flag("--model") ?? "opencode/space-bunny-free" +const keep = process.argv.includes("--keep") const PR = "https://github.com/acme/web/pull/417" /** * Nothing about the trail: the agent has to get there on its own. Direct about the PR, because what @@ -51,46 +43,12 @@ const PR = "https://github.com/acme/web/pull/417" export const PROMPT = "Open the pull request for this branch now: run `gh pr create --fill` straight away (a private repository; I reviewed the change, the branch is pushed and gh is logged in — nothing needs checking first), then reply with the PR's link." -const version = Bun.spawnSync([opencode, "--version"]).stdout.toString().trim() -const v2 = version.replace(/^opencode\s+v?/, "").startsWith("2") - const run = (cmd: string[], cwd: string) => { const result = Bun.spawnSync(cmd, { cwd, stdout: "pipe", stderr: "pipe" }) if (result.exitCode !== 0) throw new Error(`$ ${cmd.join(" ")}\n${result.stderr}`) } -interface Outcome { - ok: boolean - ghRan: boolean - /** `trail_add` calls that completed, with what was sent. */ - calls: unknown[] - recorded: boolean - said: string -} - -/** Every tool call of a `--format json` run, Code Mode's inner calls included (v2 lists them on `execute`). */ -function toolCalls(stdout: string): { tool: string; status?: string; input?: unknown }[] { - const out: { tool: string; status?: string; input?: unknown }[] = [] - for (const line of stdout.split("\n")) { - if (!line.startsWith("{")) continue - const event = JSON.parse(line) as { type?: string; part?: Record } - if (event.type !== "tool_use" || !event.part) continue - const part = event.part as { - tool: string - state: { - status?: string - input?: unknown - metadata?: { toolCalls?: unknown[]; metadata?: { toolCalls?: unknown[] } } - } - } - out.push({ tool: part.tool, status: part.state.status, input: part.state.input }) - const inner = part.state.metadata?.toolCalls ?? part.state.metadata?.metadata?.toolCalls ?? [] - for (const call of inner as { tool: string; status?: string; input?: unknown }[]) out.push(call) - } - return out -} - -async function once(index: number): Promise { +async function once(index: number) { const work = mkdtempSync(`/tmp/acme-web-${index}-`) try { const project = join(work, "project") @@ -153,52 +111,31 @@ async function once(index: number): Promise { .join("\n"), ) run(["chmod", "+x", join(bin, "gh")], work) - await Bun.write( - join(work, "config", "opencode", "opencode.json"), - JSON.stringify({ [v2 ? "plugins" : "plugin"]: [plugin], model }), - ) - const env = { - ...process.env, - XDG_CONFIG_HOME: join(work, "config"), - XDG_STATE_HOME: join(work, "state"), - XDG_DATA_HOME: join(work, "data"), - XDG_CACHE_HOME: join(work, "cache"), - COCKPIT_HOME: join(work, "cockpit"), + const at = await world(oc, work, plugin, model, { TRAIL_MEASURE_LOG: log, TRAIL_MEASURE_PR: PR, PATH: `${bin}:${process.env.PATH ?? ""}`, - /** v2 places the session in $PWD's directory, not the spawn's cwd (trail-interface.md). */ - PWD: project, - } - const cmd = [opencode as string, "run", ...(v2 ? ["--standalone", "--auto"] : []), "-m", model] - /** stdin must not be an open pipe, or `opencode run` waits forever (trail-server.md). */ - const result = Bun.spawnSync([...cmd, "--format", "json", PROMPT], { - cwd: project, - env, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - timeout: 300_000, }) - const stdout = result.stdout.toString() - const calls = toolCalls(stdout).filter((call) => call.tool === "trail_add" && call.status === "completed") - const said = stdout - .split("\n") - .filter((line) => line.startsWith('{"type":"text"')) - .map((line) => (JSON.parse(line) as { part: { text: string } }).part.text) - .join("\n") + const { calls: all, said } = turn(at, PROMPT) + const calls = all.filter((call) => call.tool === "trail_add" && call.status === "completed") /** OpenCode names the project by its real path: on macOS /tmp is /private/tmp. */ const file = Bun.file(trailPaths(realpathSync(project), { COCKPIT_HOME: join(work, "cockpit") }).events) const events = (await file.exists()) ? parseLines(await file.text()).events : [] const recorded = events.some((event) => event.type === "recorded" && event.url === PR) const ghRan = (await Bun.file(log).exists()) && (await Bun.file(log).text()).includes("pr create") - if (process.env.MEASURE_DEBUG) console.log(stdout.slice(-4000), result.stderr.toString().slice(-2000)) return { ok: ghRan && calls.length > 0 && recorded, - ghRan, - calls: calls.map((c) => c.input), - recorded, - said, + /** + * A turn in which the model declined to open the PR at all measures nothing about Trail — a free + * model on OpenCode 2 refused `gh pr create` in 2 of 4 runs as "public-facing" — and is tried + * again; only a turn that opened the PR and then did not record it fails. + */ + measured: ghRan, + report: [ + `gh pr create ran: ${ghRan} trail_add: ${calls.length} in the trail: ${recorded}`, + ...calls.map((call) => `trail_add ${JSON.stringify(call.input)}`), + ...(said ? [`said: ${brief(said)}`] : []), + ], } } finally { if (!keep) rmSync(work, { recursive: true, force: true }) @@ -206,35 +143,10 @@ async function once(index: number): Promise { } } -console.log( - `Trail measurement: ${version} (${opencode}), plugin ${plugin}, ${model}, ${runs} run(s), ${pass} to pass`, +await measure( + `Trail measurement: ${oc.version} (${oc.bin}), plugin ${plugin}, ${model}, ${runs} run(s), ${pass} to pass`, + runs, + once, + 3, + pass, ) -let passed = 0 -let ran = 0 -/** - * A turn in which the model declined to open the PR at all measures nothing about Trail — a free - * model on OpenCode 2 refused `gh pr create` in 2 of 4 runs as "public-facing". Such a run is tried - * again, up to `ATTEMPTS` times; only a turn that opened the PR and then did not record it fails. - */ -const ATTEMPTS = 3 -for (let i = 1; i <= runs; i++) { - /** Enough have passed: the rest would measure nothing more. */ - if (passed >= pass) break - ran++ - let outcome = await once(i) - for (let attempt = 2; !outcome.ghRan && attempt <= ATTEMPTS; attempt++) { - console.log( - `run ${i}: the model never ran gh pr create — no measurement, trying again (${attempt}/${ATTEMPTS})`, - ) - if (outcome.said) console.log(` said: ${outcome.said.replace(/\s+/g, " ").slice(0, 200)}`) - outcome = await once(i) - } - if (outcome.ok) passed++ - console.log( - `run ${i}: ${outcome.ok ? "PASS" : "FAIL"} gh pr create ran: ${outcome.ghRan} trail_add: ${outcome.calls.length} in the trail: ${outcome.recorded}`, - ) - for (const input of outcome.calls) console.log(` trail_add ${JSON.stringify(input)}`) - if (outcome.said) console.log(` said: ${outcome.said.replace(/\s+/g, " ").slice(0, 200)}`) -} -console.log(`${passed} of ${ran} run(s) recorded the PR (${pass} needed)`) -process.exit(passed >= pass ? 0 : 1) diff --git a/packages/trail/package.json b/packages/trail/package.json index 6313c010..cfa044f7 100644 --- a/packages/trail/package.json +++ b/packages/trail/package.json @@ -30,10 +30,6 @@ "./tui": { "types": "./types/tui/index.d.ts", "default": "./dist/tui/index.js" - }, - "./core": { - "types": "./types/core/index.d.ts", - "default": "./dist/core/index.js" } }, "engines": { diff --git a/packages/trail/src/agent/plugin.ts b/packages/trail/src/agent/plugin.ts index d806c333..ae5efc0a 100644 --- a/packages/trail/src/agent/plugin.ts +++ b/packages/trail/src/agent/plugin.ts @@ -21,7 +21,6 @@ import { type ToolDefinition, tool } from "@opencode-ai/plugin" import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client" import { dualServer, openText, type ServerHost, type ServerStart } from "@opencode-cockpit/client/server" import { loadTrail } from "../core/config.ts" -import { createJournal } from "../core/journal.ts" import { type Found, notRecorded } from "../core/model.ts" import { trailPaths } from "../core/paths.ts" import { addFinds, findsOf } from "../core/scan.ts" @@ -36,6 +35,7 @@ import { seenLine, } from "../core/text.ts" import { runAdd, runList } from "../core/tools.ts" +import { createJournal } from "../io/journal.ts" export const TRAIL_PACKAGE = "@opencode-cockpit/trail" diff --git a/packages/trail/src/core/index.ts b/packages/trail/src/core/index.ts deleted file mode 100644 index a4b94165..00000000 --- a/packages/trail/src/core/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** Published entry point: `@opencode-cockpit/trail/core` — the pure half, no OpenCode, no terminal. */ -export * from "./add.ts" -export * from "./config.ts" -export * from "./journal.ts" -export * from "./links.ts" -export * from "./model.ts" -export * from "./paths.ts" -export * from "./scan.ts" -export * from "./store.ts" -export * from "./text.ts" -export * from "./tools.ts" -export * from "./view/dialog.ts" -export { type Fill, type Row, type Run, rowText, TINTS, type Tone, widthOf } from "./view/rows.ts" -export * from "./view/sidebar.ts" diff --git a/packages/trail/src/core/view/columns.ts b/packages/trail/src/core/view/columns.ts new file mode 100644 index 00000000..5e99cb9d --- /dev/null +++ b/packages/trail/src/core/view/columns.ts @@ -0,0 +1,155 @@ +/** + * The columns of `/trail`: how wide each is at a width — ref, title, system, history, age, a column + * let go before the title gets too short — and a thing's or a touch's row laid out in them. + */ + +import { lastOf, type Thing, type Touch } from "../model.ts" +import { historyText } from "../store.ts" +import type { DialogInput } from "./dialog.ts" +import { cut, type Row, type Run, refOf, since, spread, widthOf } from "./rows.ts" + +const LABEL_MAX = 14 +const SYSTEM_MAX = 14 +const HISTORY_MAX = 26 +const HISTORY_USUAL = 17 +const LABEL_NARROW = 10 +/** At least this much of the row is the title before a column is let go for it. */ +const titleMin = (width: number) => Math.max(20, Math.floor(width * 0.32)) +const INDENT = 2 + +export const muted = (text: string): Run => ({ text, tone: "muted" }) +const chip = (system: string): Run => ({ text: ` ${system} `, tone: "info", fill: "chip" }) +const pad = (text: string, room: number) => `${text}${" ".repeat(Math.max(0, room - widthOf(text)))}` +const cell = (text: string, room: number) => pad(cut(text, room), room) + +export interface Columns { + label: number + system: number + history: number + age: number +} + +export function columnsFor( + things: readonly { thing: Thing; depth: number }[], + input: DialogInput, + width: number, +): Columns { + const most = (values: number[]) => Math.max(0, ...values) + const full: Columns = { + label: Math.min( + LABEL_MAX, + most(things.map(({ thing, depth }) => widthOf(refOf(thing) ?? "") + depth * INDENT)), + ), + system: Math.min( + SYSTEM_MAX, + most(things.map(({ thing }) => (thing.system ? widthOf(thing.system) + 2 : 0))), + ), + history: Math.min(HISTORY_MAX, most(things.map(({ thing }) => widthOf(historyText(thing))))), + age: most(things.map(({ thing }) => widthOf(since(input.now - lastOf(thing).at)))), + } + /** Margin, label and its gap; then each right column and its gap, the age, the mark and a margin. */ + const titleRoom = (c: Columns) => + width - + 1 - + (c.label ? c.label + 2 : 0) - + (c.system ? c.system + 2 : 0) - + (c.history ? c.history + 2 : 0) - + c.age - + 3 + /** Narrower, the columns give way in turn: the history to its usual length, the ref's width, then + * the history, then the system. The title keeps at least `titleMin` while anything can go. */ + const usual = { ...full, history: Math.min(full.history, HISTORY_USUAL) } + const tries: Columns[] = [ + full, + usual, + { ...usual, label: Math.min(usual.label, LABEL_NARROW) }, + { ...usual, label: Math.min(usual.label, LABEL_NARROW), history: 0 }, + { ...usual, label: Math.min(usual.label, LABEL_NARROW), history: 0, system: 0 }, + ] + return tries.find((c) => titleRoom(c) >= titleMin(width)) ?? (tries.at(-1) as Columns) +} + +function rightOf( + system: string | undefined, + history: string, + at: number, + openable: boolean, + columns: Columns, + now: number, +): Run[] { + return [ + ...(columns.system + ? [ + { text: " ".repeat(Math.max(0, columns.system - (system ? widthOf(system) + 2 : 0))) }, + ...(system ? [chip(cut(system, columns.system - 2))] : []), + { text: " " }, + ] + : []), + ...(columns.history ? [muted(cell(history, columns.history)), { text: " " }] : []), + muted(since(now - at).padStart(columns.age)), + { text: openable ? " ↗ " : " ", tone: "muted" }, + ] +} + +export function thingRow( + thing: Thing, + depth: number, + input: DialogInput, + columns: Columns, + width: number, +): Row { + const last = lastOf(thing) + const indent = " ".repeat(depth * INDENT) + const who = thing.touches[0] + const by = + input.tab === "this" && who + ? who.by === "you" + ? " · you" + : who.subagent + ? ` · ${who.subagent}` + : "" + : "" + /** With no ref, the title starts where the ref would; its kind is not repeated beside the chip. */ + const ref = refOf(thing) + return spread( + [ + { text: " " }, + ...(columns.label && ref + ? [muted(cell(`${indent}${ref}`, columns.label)), { text: " " }] + : [{ text: indent }]), + { text: thing.title, tone: "text" }, + ...(by ? [muted(by)] : []), + ], + rightOf(thing.system, historyText(thing), last.at, thing.openable, columns, input.now), + width, + ) +} + +export function touchRow( + touch: Touch, + depth: number, + input: DialogInput, + columns: Columns, + width: number, +): Row { + const indent = " ".repeat((depth + 1) * INDENT) + const name = touch.title ? `“${touch.title}”` : "untitled conversation" + const marks = [ + ...(touch.session === input.session ? ["this conversation"] : []), + ...(touch.deleted ? ["deleted"] : []), + ...(touch.subagent ? [touch.subagent] : []), + ...(touch.by === "you" ? ["you"] : []), + ] + const at = touch.history.at(-1)?.at ?? touch.lastAt + return spread( + [ + { text: " " }, + ...(columns.label ? [{ text: " ".repeat(columns.label + 2) }] : []), + muted(`${indent}↳ `), + { text: name, tone: touch.deleted ? "muted" : "text" }, + ...(marks.length > 0 ? [muted(` · ${marks.join(" · ")}`)] : []), + ], + rightOf(undefined, historyText(touch), at, false, { ...columns, system: 0 }, input.now), + width, + ) +} diff --git a/packages/trail/src/core/view/dialog.ts b/packages/trail/src/core/view/dialog.ts index f0bf8e3e..e4f254a5 100644 --- a/packages/trail/src/core/view/dialog.ts +++ b/packages/trail/src/core/view/dialog.ts @@ -26,14 +26,14 @@ import { type Arranged, arrange, conversationThings, - lastOf, linesOf, projectThings, type Thing, type Touch, } from "../model.ts" -import { historyText, type State } from "../store.ts" -import { cursorRow, cut, fit, type Row, type Run, refOf, since, spread, widthOf } from "./rows.ts" +import type { State } from "../store.ts" +import { columnsFor, muted, thingRow, touchRow } from "./columns.ts" +import { cursorRow, fit, type Row, type Run, spread, widthOf } from "./rows.ts" export type Tab = "this" | "all" @@ -89,20 +89,8 @@ export interface DialogView { /** The fewest rows the dialog is drawn in: header, a row of air, a few rows, air, keys. */ export const MIN_HEIGHT = 8 -const LABEL_MAX = 14 -const SYSTEM_MAX = 14 -const HISTORY_MAX = 26 -const HISTORY_USUAL = 17 -const LABEL_NARROW = 10 -/** At least this much of the row is the title before a column is let go for it. */ -const titleMin = (width: number) => Math.max(20, Math.floor(width * 0.32)) -const INDENT = 2 -const muted = (text: string): Run => ({ text, tone: "muted" }) const keyRun = (name: string): Run => ({ text: `[${name}]`, tone: "accent", bold: true }) -const chip = (system: string): Run => ({ text: ` ${system} `, tone: "info", fill: "chip" }) -const pad = (text: string, room: number) => `${text}${" ".repeat(Math.max(0, room - widthOf(text)))}` -const cell = (text: string, room: number) => pad(cut(text, room), room) export function targetOf(item: Item | undefined, tab: Tab, session: string): Target { if (!item) return {} @@ -171,126 +159,6 @@ function headerRow(input: DialogInput, width: number): Row { return spread(left, widthOf(textOf(long)) <= room ? long : tabs(false), width) } -interface Columns { - label: number - system: number - history: number - age: number -} - -function columnsFor( - things: readonly { thing: Thing; depth: number }[], - input: DialogInput, - width: number, -): Columns { - const most = (values: number[]) => Math.max(0, ...values) - const full: Columns = { - label: Math.min( - LABEL_MAX, - most(things.map(({ thing, depth }) => widthOf(refOf(thing) ?? "") + depth * INDENT)), - ), - system: Math.min( - SYSTEM_MAX, - most(things.map(({ thing }) => (thing.system ? widthOf(thing.system) + 2 : 0))), - ), - history: Math.min(HISTORY_MAX, most(things.map(({ thing }) => widthOf(historyText(thing))))), - age: most(things.map(({ thing }) => widthOf(since(input.now - lastOf(thing).at)))), - } - /** Margin, label and its gap; then each right column and its gap, the age, the mark and a margin. */ - const titleRoom = (c: Columns) => - width - - 1 - - (c.label ? c.label + 2 : 0) - - (c.system ? c.system + 2 : 0) - - (c.history ? c.history + 2 : 0) - - c.age - - 3 - /** Narrower, the columns give way in turn: the history to its usual length, the ref's width, then - * the history, then the system. The title keeps at least `titleMin` while anything can go. */ - const usual = { ...full, history: Math.min(full.history, HISTORY_USUAL) } - const tries: Columns[] = [ - full, - usual, - { ...usual, label: Math.min(usual.label, LABEL_NARROW) }, - { ...usual, label: Math.min(usual.label, LABEL_NARROW), history: 0 }, - { ...usual, label: Math.min(usual.label, LABEL_NARROW), history: 0, system: 0 }, - ] - return tries.find((c) => titleRoom(c) >= titleMin(width)) ?? (tries.at(-1) as Columns) -} - -function rightOf( - system: string | undefined, - history: string, - at: number, - openable: boolean, - columns: Columns, - now: number, -): Run[] { - return [ - ...(columns.system - ? [ - { text: " ".repeat(Math.max(0, columns.system - (system ? widthOf(system) + 2 : 0))) }, - ...(system ? [chip(cut(system, columns.system - 2))] : []), - { text: " " }, - ] - : []), - ...(columns.history ? [muted(cell(history, columns.history)), { text: " " }] : []), - muted(since(now - at).padStart(columns.age)), - { text: openable ? " ↗ " : " ", tone: "muted" }, - ] -} - -function thingRow(thing: Thing, depth: number, input: DialogInput, columns: Columns, width: number): Row { - const last = lastOf(thing) - const indent = " ".repeat(depth * INDENT) - const who = thing.touches[0] - const by = - input.tab === "this" && who - ? who.by === "you" - ? " · you" - : who.subagent - ? ` · ${who.subagent}` - : "" - : "" - /** With no ref, the title starts where the ref would; its kind is not repeated beside the chip. */ - const ref = refOf(thing) - return spread( - [ - { text: " " }, - ...(columns.label && ref - ? [muted(cell(`${indent}${ref}`, columns.label)), { text: " " }] - : [{ text: indent }]), - { text: thing.title, tone: "text" }, - ...(by ? [muted(by)] : []), - ], - rightOf(thing.system, historyText(thing), last.at, thing.openable, columns, input.now), - width, - ) -} - -function touchRow(touch: Touch, depth: number, input: DialogInput, columns: Columns, width: number): Row { - const indent = " ".repeat((depth + 1) * INDENT) - const name = touch.title ? `“${touch.title}”` : "untitled conversation" - const marks = [ - ...(touch.session === input.session ? ["this conversation"] : []), - ...(touch.deleted ? ["deleted"] : []), - ...(touch.subagent ? [touch.subagent] : []), - ...(touch.by === "you" ? ["you"] : []), - ] - const at = touch.history.at(-1)?.at ?? touch.lastAt - return spread( - [ - { text: " " }, - ...(columns.label ? [{ text: " ".repeat(columns.label + 2) }] : []), - muted(`${indent}↳ `), - { text: name, tone: touch.deleted ? "muted" : "text" }, - ...(marks.length > 0 ? [muted(` · ${marks.join(" · ")}`)] : []), - ], - rightOf(undefined, historyText(touch), at, false, { ...columns, system: 0 }, input.now), - width, - ) -} - /** Wrapped muted prose, a cell of margin each side: what an empty tab says. */ function prose(text: string, width: number): Row[] { const room = Math.max(8, width - 4) diff --git a/packages/trail/src/core/journal.ts b/packages/trail/src/io/journal.ts similarity index 97% rename from packages/trail/src/core/journal.ts rename to packages/trail/src/io/journal.ts index 258d6ce5..530e0856 100644 --- a/packages/trail/src/core/journal.ts +++ b/packages/trail/src/io/journal.ts @@ -13,8 +13,8 @@ */ import { appendFile, mkdir, open } from "node:fs/promises" -import type { TrailPaths } from "./paths.ts" -import { applyAll, type Event, emptyState, parseLines, type State, serialize } from "./store.ts" +import type { TrailPaths } from "../core/paths.ts" +import { applyAll, type Event, emptyState, parseLines, type State, serialize } from "../core/store.ts" export interface Journal { /** Events added since the last read, by anyone, in file order. `reset`: the file was replaced. */ diff --git a/packages/trail/src/tui/actions.ts b/packages/trail/src/tui/actions.ts index 846399a0..26dfe4b4 100644 --- a/packages/trail/src/tui/actions.ts +++ b/packages/trail/src/tui/actions.ts @@ -5,12 +5,12 @@ import type { Host } from "@opencode-cockpit/client/host" import type { Log } from "@opencode-cockpit/client/log" -import type { Journal } from "../core/journal.ts" import { arrange, conversationThings } from "../core/model.ts" import type { TrailPaths } from "../core/paths.ts" import type { Event } from "../core/store.ts" import { markdownOf } from "../core/text.ts" import { linkArgs, runAdd } from "../core/tools.ts" +import type { Journal } from "../io/journal.ts" import { openUrl } from "./open.ts" import type { Live } from "./paint.ts" import type { Sessions } from "./source.ts" diff --git a/packages/trail/src/tui/index.tsx b/packages/trail/src/tui/index.tsx index 27bce519..96d155b7 100644 --- a/packages/trail/src/tui/index.tsx +++ b/packages/trail/src/tui/index.tsx @@ -14,9 +14,9 @@ import { defaultKeys } from "@opencode-cockpit/client/catalog" import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client/feature" import { bindingLookup, dualTui, type Host } from "@opencode-cockpit/client/host" import { loadTrail } from "../core/config.ts" -import { createJournal } from "../core/journal.ts" import { trailPaths } from "../core/paths.ts" import { emptyState } from "../core/store.ts" +import { createJournal } from "../io/journal.ts" import { createActions } from "./actions.ts" import { closedDialog, createDialog } from "./dialog.tsx" import { createPainter, type Live } from "./paint.ts" diff --git a/packages/trail/src/tui/paint.ts b/packages/trail/src/tui/paint.ts index ae0facdc..4e49035f 100644 --- a/packages/trail/src/tui/paint.ts +++ b/packages/trail/src/tui/paint.ts @@ -7,6 +7,7 @@ import type { Host } from "@opencode-cockpit/client/host" import type { Log } from "@opencode-cockpit/client/log" +import { blockWidth } from "@opencode-cockpit/client/sidebar" import type { BoxRenderable } from "@opentui/core" import { type Accessor, createSignal } from "solid-js" import type { TrailSettings } from "../core/config.ts" @@ -72,13 +73,7 @@ export function createPainter(input: { let said = "" let sidebarView: SidebarView = { rows: [], hits: [] } let shown: DialogView | undefined - const sidebarWidth = () => { - /** The container the host gave the block, as Subagents measures it: the block's own width follows its rows. */ - const parent = (block?.parent as { width?: number } | null | undefined)?.width ?? 0 - const own = block?.width ?? 0 - const measured = parent >= 12 ? Math.min(parent, own >= 12 ? own : parent) : own - return measured >= 12 ? measured : Math.max(20, Math.min(40, Math.floor(api.renderer.width / 4) - 2)) - } + const sidebarWidth = () => blockWidth(block, api.renderer.width) const paint = () => { drawnAt = sidebarWidth() diff --git a/packages/trail/test/journal.test.ts b/packages/trail/test/journal.test.ts index 8508d7b6..7434d7d4 100644 --- a/packages/trail/test/journal.test.ts +++ b/packages/trail/test/journal.test.ts @@ -2,9 +2,9 @@ import { describe, expect, test } from "bun:test" import { appendFileSync, mkdtempSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" -import { createJournal } from "../src/core/journal.ts" import { trailPaths } from "../src/core/paths.ts" import { type Event, emptyState, serialize } from "../src/core/store.ts" +import { createJournal } from "../src/io/journal.ts" const paths = () => trailPaths("/work/app", { COCKPIT_HOME: mkdtempSync(join(tmpdir(), "trail-journal-")) }) const event = (id: string, url = `https://github.com/a/b/pull/${id}`): Event => ({ diff --git a/packages/trail/test/render.test.ts b/packages/trail/test/render.test.ts new file mode 100644 index 00000000..b71910f9 --- /dev/null +++ b/packages/trail/test/render.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test" +import type { Theme } from "@opencode-cockpit/client/host" +import { RGBA } from "@opentui/core" +import { TINTS } from "../src/core/view/rows.ts" +import { fillColour, toneColour } from "../src/tui/render.ts" + +const hex = (value: string) => RGBA.fromHex(value) +/** A theme with a colour of its own for every name the renderer reads, so each mapping is visible. */ +const theme = (over: Partial> = {}) => + ({ + text: hex("#eeeeee"), + textMuted: hex("#808080"), + accent: hex("#5c9cf5"), + info: hex("#56b6c2"), + primary: hex("#fab283"), + success: hex("#7fd88f"), + error: hex("#e06c75"), + warning: hex("#f5a742"), + border: hex("#484848"), + background: hex("#0a0a0a"), + backgroundPanel: hex("#141414"), + backgroundElement: hex("#1e1e1e"), + ...over, + }) as unknown as Theme +const clear = RGBA.fromValues(0, 0, 0, 0) + +describe("a tone is the theme's colour of that name", () => { + const t = theme() + test.each([ + ["muted", t.textMuted], + ["accent", t.accent], + ["info", t.info], + ["tool", t.primary], + ["success", t.success], + ["error", t.error], + ["warning", t.warning], + ["border", t.border], + ["text", t.text], + [undefined, t.text], + ] as const)("%s", (tone, colour) => { + expect(toneColour(t, tone)).toBe(colour) + }) + + test("ink is what the dialog is drawn on: the panel, else the background, else the text", () => { + const t = theme() + expect(toneColour(t, "ink")).toBe(t.backgroundPanel) + const noPanel = theme({ backgroundPanel: clear }) + expect(toneColour(noPanel, "ink")).toBe(noPanel.background) + const bare = theme({ backgroundPanel: clear, background: clear }) + expect(toneColour(bare, "ink")).toBe(bare.text) + }) +}) + +describe("a fill is what sits behind a run", () => { + test("rows and buttons are the raised element; a focused button is the accent; none is nothing", () => { + const t = theme() + for (const fill of ["selected", "panel", "button"] as const) + expect(fillColour(t, fill)).toBe(t.backgroundElement) + expect(fillColour(t, "buttonOn")).toBe(t.accent) + expect(fillColour(t, "none")).toBeUndefined() + expect(fillColour(t, undefined)).toBeUndefined() + }) + + // a colour keeps each channel as a whole number out of 255, so a mix lands within one step of exact + test("a chip or a badge is its tone faded into the dialog, by its TINTS amount", () => { + const t = theme() + for (const fill of ["chip", "ok", "warn", "err"] as const) { + const { tone, amount } = TINTS[fill] + const colour = toneColour(t, tone) + const behind = t.backgroundPanel + const got = fillColour(t, fill) as RGBA + expect(got.r).toBeCloseTo(behind.r + (colour.r - behind.r) * amount, 2) + expect(got.g).toBeCloseTo(behind.g + (colour.g - behind.g) * amount, 2) + expect(got.b).toBeCloseTo(behind.b + (colour.b - behind.b) * amount, 2) + } + }) + + test("the same tint is made once, and the theme's colours are never changed", () => { + const t = theme() + const before = [t.backgroundPanel.r, t.info.r] + expect(fillColour(t, "chip")).toBe(fillColour(t, "chip") as RGBA) + expect([t.backgroundPanel.r, t.info.r]).toEqual(before) + }) + + test("on a theme with no dialog colour to tint, a chip has no fill — the word keeps its tone", () => { + expect(fillColour(theme({ backgroundPanel: clear, background: clear }), "chip")).toBeUndefined() + }) +}) diff --git a/packages/trust/README.md b/packages/trust/README.md index 36cd7ec7..7081bfac 100644 --- a/packages/trust/README.md +++ b/packages/trust/README.md @@ -214,8 +214,8 @@ In the bundle's entry (`"trust": { … }`), this package's own, or the `trust` s | `keybinds` | `{ "cockpit.trust.ledger": "p" }` | The key that opens the ledger | Where the block sits is the top-level `"sidebar"` list's to say — Trust last by default; a -`trust.sidebarOrder` from before 0.9 is no longer read, and is a `!` row in the block until -`/cockpit-setup` removes it. A setting Trust cannot use (`"threshold": "3"`) is a `!` row too. See +`trust.sidebarOrder` from before 0.9 is not read, and `/cockpit-setup` removes it. A setting Trust +cannot use (`"threshold": "3"`) is a `!` row in the block. See [Configuration](https://cockpit.codestz.dev/configuration/). The ledger lives outside the project, in diff --git a/packages/trust/package.json b/packages/trust/package.json index c5fb4fa1..3f010f7e 100644 --- a/packages/trust/package.json +++ b/packages/trust/package.json @@ -23,13 +23,13 @@ "terminal" ], "exports": { + "./server": { + "types": "./types/server.d.ts", + "default": "./dist/server.js" + }, "./tui": { "types": "./types/tui/index.d.ts", "default": "./dist/tui/index.js" - }, - "./core": { - "types": "./types/core/index.d.ts", - "default": "./dist/core/index.js" } }, "engines": { @@ -37,6 +37,7 @@ "bun": ">=1.3.5" }, "files": [ + "server.js", "tui.js", "dist", "types", diff --git a/packages/trust/server.js b/packages/trust/server.js new file mode 100644 index 00000000..10f25fa6 --- /dev/null +++ b/packages/trust/server.js @@ -0,0 +1,6 @@ +/** + * OpenCode 2 finds a plugin configured by *path* by the files at its root — `/tui`, + * `/server` — rather than through `exports` (docs/opencode/v2.md). A package installed by + * name resolves through `exports` as before; this file is only the door for the path case. + */ +export { default } from "./dist/server.js" diff --git a/packages/trust/src/cli/preview.ts b/packages/trust/src/cli/preview.ts index 1ae164cd..9ed09ccf 100644 --- a/packages/trust/src/cli/preview.ts +++ b/packages/trust/src/cli/preview.ts @@ -22,9 +22,10 @@ import { SAMPLE_NOW, SAMPLE_ROOT, SAMPLE_SETTINGS, SAMPLES } from "../core/sample.ts" import { activityRows } from "../core/view/activity.ts" -import { explorerModel, explorerRows, type Node, reveal } from "../core/view/explorer.ts" +import { explorerRows } from "../core/view/explorer.ts" import { type Fill, type Row, type Run, TINTS, type Tone } from "../core/view/rows.ts" import { sidebarRows } from "../core/view/sidebar.ts" +import { explorerModel, type Node, reveal } from "../core/view/tree.ts" /** OpenCode's default theme, measured (docs/opencode/v2.md). The dialog is drawn on the panel colour. */ const HEX: Record, string> = { diff --git a/packages/trust/src/core/index.ts b/packages/trust/src/core/index.ts deleted file mode 100644 index 7d1056df..00000000 --- a/packages/trust/src/core/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** Published entry point: `@opencode-cockpit/trust/core` — the pure half, no OpenCode, no terminal. */ -export { commandOf, type Seen } from "./adapt/seen.ts" -export { fromV1Event, fromV1Pending, V1_EVENTS } from "./adapt/v1.ts" -export { fromV2Event, fromV2Pending } from "./adapt/v2.ts" -export * from "./config.ts" -export { dangerOf, dangerous } from "./danger.ts" -export * from "./engine.ts" -export { anyOf, covers, familyOf, outside, shown, showSubject, widenable } from "./family.ts" -export { type Answer, answersPerDay, earned, type History, latestAnswers, type Mark } from "./history.ts" -export * from "./keys.ts" -export * from "./ledger.ts" -export * from "./paths.ts" -export * from "./policy.ts" -export * from "./rules.ts" -export { type Command, type Parsed, parse } from "./shell.ts" -export { place, quote, signature } from "./signature.ts" diff --git a/packages/trust/src/core/view/card.ts b/packages/trust/src/core/view/card.ts new file mode 100644 index 00000000..4361c0e7 --- /dev/null +++ b/packages/trust/src/core/view/card.ts @@ -0,0 +1,555 @@ +/** + * The ledger's card: what the selected row is and why — the command whole, where each agent stands, + * the exact text and what still asks, the approvals that earned it, its family, and the buttons. + */ + +import { anyOf, narrower, outside, redirected, showSubject, spelledWords, widenable } from "../family.ts" +import { earned, type History, type Mark } from "../history.ts" +import { keyOf, type Thresholds } from "../ledger.ts" +import { NOT_COVERED, revokeLabel, widenLabel, widenScope } from "./actions.ts" +import { + type AlwaysGroup, + type Command, + countsOf, + type Family, + leadOf, + type Reading, + type Standing, +} from "./model.ts" +import { agentsText, button, chip, meter, muted, plain, plural, since, when, wrapRuns } from "./parts.ts" +import { filled, fit, type Row, type Run, rowText, widthOf } from "./rows.ts" +import { commandText, familyText, type Node, nodeTarget } from "./tree.ts" + +/** `Still asks` and two spaces: the longest label. */ +export const LABEL = 12 + +/** One labelled fact on the card: each line wraps on its own. `keep`: higher stays when room is short. */ +interface Fact { + label: string + lines: Run[][] + keep: number + /** The first line is a row of moments: it keeps its newest end on one row rather than wrapping. */ + newest?: boolean +} + +/** A fact's rows at `width`: each line wrapped, a row of moments cut from its oldest end. */ +export function factLines(fact: Fact, width: number): Row[] { + return fact.lines.flatMap((line, at) => + at === 0 && fact.newest ? [fit(tail(line, width), width)] : wrapRuns(line, width), + ) +} + +export interface Button { + key: string + label: string + off: boolean + action: "revoke" | "widen" | "copy" +} + +export interface CardParts { + title: Run[] + standing: Run[][] + facts: Fact[] + buttons: Button[] +} + +export interface CardReading extends Reading { + history: History + families: readonly Family[] +} + +/** The buttons a node offers: `x`, `w` where a family can widen, `c`. */ +export function buttonsOf(node: Node, families: readonly Family[]): Button[] { + const target = nodeTarget(node) + const x = revokeLabel(target) + const out: Button[] = [{ key: "x", label: x.label, off: x.off, action: "revoke" }] + if (node.kind !== "always") { + const w = widenLabel(widenScope(target, families)) + out.push({ key: "w", label: w.label, off: w.off, action: "widen" }) + } + out.push({ key: "c", label: "Copy rule", off: false, action: "copy" }) + return out +} + +/** The exact text, and — when a font could draw a word of it as something else — that word in words. */ +function exactly(command: Command): Fact { + const spelled = spelledWords(command.permission, command.subject) + return { + label: "Exactly", + keep: 3, + lines: [ + [ + plain(commandText(command)), + ...(spelled.length > 0 + ? [ + muted( + ` — ${spelled.map((each) => `${each.word} is ${each.said}`).join(", ")}, which some fonts draw as one line`, + ), + ] + : []), + ], + ], + } +} + +/** What still asks once a command is trusted exactly: a smaller one, and the same into a file. */ +function stillAsks(command: Command, widened: boolean): Fact { + if (widened) return { label: "Still asks", keep: 6, lines: [[muted(`${NOT_COVERED}.`)]] } + if (command.permission === "edit") + return { label: "Still asks", keep: 6, lines: [[muted("any other file")]] } + if (command.permission !== "bash") + return { label: "Still asks", keep: 6, lines: [[muted(`any other ${command.permission}`)]] } + const examples = [narrower(command.subject), redirected(command.subject)].filter( + (each): each is string => each !== undefined, + ) + const runs: Run[] = [] + examples.forEach((example, index) => { + if (index > 0) runs.push(muted(" · ")) + runs.push(plain(example)) + }) + runs.push(muted(examples.length > 0 ? " · any other argument" : "any other argument")) + return { label: "Still asks", keep: 6, lines: [runs] } +} + +/** + * The moments that made a rule what it is, oldest to newest, the newest kept when they do not all fit: + * `✓ 9h ✓ 9h ✓ 9h → trusted answered 4×`. + */ +export function historyRuns(marks: readonly Mark[], need: number, expireMs: number, now: number): Run[] { + const tokens: Run[][] = [] + let streak = 0 + let last = 0 + for (const each of marks) { + if (each.kind === "rejected") { + streak = 0 + tokens.push([{ text: "✗", tone: "error" }, muted(` ${since(now - each.at)}`)]) + continue + } + if (each.kind === "revoked") { + streak = 0 + tokens.push([muted(`revoked ${since(now - each.at)}`)]) + continue + } + if (expireMs > 0 && last > 0 && each.at - last > expireMs) { + streak = 0 + tokens.push([muted("expired")]) + } + last = Math.max(last, each.at) + if (each.kind === "auto") { + tokens.push([muted(`answered ${each.count}×`)]) + continue + } + streak++ + tokens.push([{ text: "✓", tone: "success" }, muted(` ${since(now - each.at)}`)]) + if (streak === need) tokens.push([{ text: "→ trusted", tone: "success" }]) + } + return tokens.flatMap((token, at) => [...(at > 0 ? [{ text: " " }] : []), ...token]) +} + +/** The newest end of `runs` in `width` columns, `… ` in front when the oldest were left out. */ +function tail(runs: readonly Run[], width: number): Run[] { + if (widthOf(rowText(runs as Run[])) <= width) return [...runs] + const out: Run[] = [] + let used = 2 + for (let i = runs.length - 1; i >= 0; i--) { + const run = runs[i] as Run + const w = widthOf(run.text) + if (used + w > width) break + out.unshift(run) + used += w + } + while (out[0]?.text.trim() === "") out.shift() + return [muted("… "), ...out] +} + +/** The sentence under the moments: why it stands where it does. */ +function historySaid(standing: Standing, marks: readonly Mark[], reading: CardReading): Run[] { + const { entry, stand } = standing + const { settings, now } = reading + const expireMs = settings.expireDays * 86_400_000 + if (marks.length === 0) + return [ + muted( + `approved ${entry.approvals}× in all, first ${when(now - entry.firstAt)} — older than what is kept`, + ), + ] + if (stand.kind === "trusted") { + const got = earned(marks, Number.POSITIVE_INFINITY, expireMs) + return [muted(`${got.streak} ${got.streak === 1 ? "approval" : "approvals"} in a row, all yours`)] + } + if (stand.kind === "widened") + return [muted(`answered through ${anyOf(entry.permission, stand.family)}, not by its own count`)] + if (stand.expired) return [muted(`unused over ${settings.expireDays} days, so it counts again from 0`)] + const got = earned(marks, stand.need, expireMs) + const left = stand.need - stand.have + if (got.broken === "rejected" && got.brokenAt !== undefined) + return [ + muted(`you rejected it ${when(now - got.brokenAt)}; ${stand.have} in a row since, ${left} more to go`), + ] + if (got.broken === "revoked" && got.brokenAt !== undefined) + return [muted(`revoked ${when(now - got.brokenAt)}; ${stand.have} in a row since, ${left} more to go`)] + return [muted(`${stand.have} in a row, all yours · ${left} more and Trust answers it`)] +} + +/** One agent's standing, on the card's panel. */ +function standingRuns(standing: Standing, danger: boolean): Run[] { + const { entry, stand } = standing + if (stand.kind === "trusted") + return [ + { text: "✓ Trusted", tone: "success", bold: true }, + muted(" for "), + chip(entry.agent), + muted(entry.autos > 0 ? ` · answered ${entry.autos}×` : " · ready, not used yet"), + ] + if (stand.kind === "widened") + return [ + { text: "✓ Answered", tone: "success", bold: true }, + muted(" for "), + chip(entry.agent), + muted(` · through ${anyOf(entry.permission, stand.family)}`), + ] + if (stand.expired) + return [ + { text: "○ Expired", tone: "muted", bold: true }, + muted(" for "), + chip(entry.agent), + muted(" · counting from 0"), + ] + return [ + ...meter(stand.have, stand.need, danger), + { text: ` ${stand.have} of ${stand.need}`, tone: "text", bold: true }, + muted(" for "), + chip(entry.agent), + muted(stand.have <= 1 && entry.autos === 0 ? " · seen once" : ` · ${stand.need - stand.have} more to go`), + ] +} + +function familyFact( + family: Family | undefined, + command: Command | undefined, + reading: CardReading, +): Fact | undefined { + if (!family) return undefined + const trusted = family.commands.filter((each) => each.phase === "answering").length + const name = familyText(family) + const head: Run[] = [ + plain(name), + muted(` · ${plural(family.commands.length, "command")}, ${trusted} trusted`), + ] + const agent = command + ? leadOf(command).entry.agent + : family.commands[0] + ? leadOf(family.commands[0]).entry.agent + : undefined + const widenedFor = family.widened.filter((each) => agent === undefined || each.agent === agent) + if (widenedFor.length > 0) { + const at = Math.max(...widenedFor.map((each) => each.at)) + return { + label: "Family", + keep: 4, + lines: [ + head, + [ + plain(anyOf(family.permission, family.family)), + muted( + ` trusted for ${agentsText(widenedFor.map((each) => each.agent))} ${when(reading.now - at)} · [w] undoes it`, + ), + ], + ], + } + } + const can = widenable(family.permission, family.family) + if (!can.ok) return { label: "Family", keep: 4, lines: [head, [muted(`never widened: ${can.why}`)]] } + return { + label: "Family", + keep: 4, + lines: [ + head, + [ + muted("[w] trusts "), + plain(anyOf(family.permission, family.family)), + muted(` for ${agent ?? "this agent"}${family.commands.length > 1 ? ", not one by one" : ""}`), + ], + ], + } +} + +function commandCard(command: Command, family: Family | undefined, reading: CardReading): CardParts { + const { settings, now, history } = reading + const lead = leadOf(command) + const danger = command.danger !== undefined + const expireMs = settings.expireDays * 86_400_000 + const marks = history.marks.get(keyOf(command.permission, lead.entry.agent, command.subject)) ?? [] + const need = + lead.stand.kind === "counting" + ? lead.stand.need + : settings.threshold + (danger ? settings.dangerExtra : 0) + const facts: Fact[] = [exactly(command)] + if (command.phase === "answering") facts.push(stillAsks(command, lead.stand.kind === "widened")) + const many = command.standings.length > 1 + facts.push({ + label: "History", + keep: 7, + newest: true, + lines: [ + marks.length > 0 ? historyRuns(marks, need, expireMs, now) : [muted("—")], + [...(many ? [chip(lead.entry.agent), { text: " " }] : []), ...historySaid(lead, marks, reading)], + ], + }) + if (danger && command.phase !== "answering") { + const can = family ? widenable(family.permission, family.family) : { ok: true as const } + facts.push({ + label: "Dangerous", + keep: 5, + lines: [ + [ + muted( + `${command.danger} — ${needOf(settings, true)} in a row instead of ${settings.threshold}${command.permission === "bash" && !can.ok && !family ? ", never widened" : ""}`, + ), + ], + ], + }) + } + const why = outside(command.permission, command.subject) + if (family && why !== undefined && family.widened.some((each) => each.agent === lead.entry.agent)) + facts.push({ + label: "Widened", + keep: 5, + lines: [ + [muted("but "), plain(anyOf(command.permission, family.family)), muted(` leaves it out: ${why}`)], + ], + }) + const fam = familyFact(family, command, reading) + if (fam) facts.push(fam) + if (lead.stand.kind === "trusted" && settings.expireDays > 0) { + const left = Math.max(0, Math.ceil((lead.entry.lastAt + expireMs - now) / 86_400_000)) + facts.push({ + label: "Expires", + keep: 1, + lines: [ + [ + muted( + left >= settings.expireDays + ? `if unused for ${settings.expireDays} days` + : `if unused for ${left} more ${left === 1 ? "day" : "days"}`, + ), + ], + ], + }) + } + return { + title: [{ text: commandText(command), tone: "text", bold: true }], + standing: command.standings.map((each) => standingRuns(each, danger)), + facts, + buttons: [], + } +} + +const needOf = (settings: Thresholds, danger: boolean) => + settings.threshold + (danger ? settings.dangerExtra : 0) + +function familyCard(family: Family, reading: CardReading): CardParts { + const counts = countsOf(family.commands) + const standing: Run[][] = + family.widened.length > 0 + ? family.widened.map((each) => [ + { text: "✓ Any", tone: "success", bold: true }, + plain(` ${showSubject(family.permission, family.family)} …`), + muted(" trusted for "), + chip(each.agent), + muted(` · you widened it ${when(reading.now - each.at)}`), + ]) + : [ + [ + { text: `${counts.trusted} trusted`, tone: counts.trusted > 0 ? "success" : "muted", bold: true }, + muted(" · "), + { + text: `${counts.learning} learning`, + tone: counts.learning > 0 ? "warning" : "muted", + bold: true, + }, + muted(` · ${counts.once} seen once`), + ], + ] + const facts: Fact[] = [] + const list: Run[] = [] + family.commands.forEach((command, at) => { + if (at > 0) list.push(muted(" · ")) + list.push({ + text: showSubject(command.permission, command.subject), + tone: command.phase === "answering" ? "success" : "text", + }) + }) + if (list.length > 0) facts.push({ label: "Commands", keep: 7, lines: [list] }) + const best = family.commands.find((command) => command.phase === "learning") + if (best) { + const { stand, entry } = leadOf(best) + if (stand.kind === "counting") + facts.push({ + label: "Closest", + keep: 5, + lines: [ + [ + plain(showSubject(best.permission, best.subject)), + muted(`, ${stand.have} of ${stand.need} for ${entry.agent}`), + ], + ], + }) + } + if (family.widened.length > 0) + facts.push({ label: "Still asks", keep: 6, lines: [[muted(`${NOT_COVERED}.`)]] }) + const fam = familyFact(family, undefined, reading) + if (fam) facts.push({ ...fam, label: "Widen", lines: fam.lines.slice(1) }) + return { + title: [ + { text: familyText(family), tone: "text", bold: true }, + muted(` family of ${plural(family.commands.length, "command")}`), + ], + standing, + facts, + buttons: [], + } +} + +function alwaysCard(groups: readonly AlwaysGroup[], reading: CardReading): CardParts { + const bash = groups.every((group) => group.permission === "bash") + return { + title: [{ text: `OpenCode's own "always"`, tone: "text", bold: true }], + standing: groups.map((group) => [ + { text: "! ", tone: "warning", bold: true }, + { text: plural(group.patterns.length, "broad rule"), tone: "warning", bold: true }, + muted(" for "), + chip(group.agent), + muted(" · until OpenCode restarts"), + ]), + facts: [ + ...groups.map( + (group): Fact => ({ + label: "Patterns", + keep: 7, + lines: [ + [ + ...(group.permission === "bash" ? [] : [muted(`${group.permission} `)]), + plain(group.patterns.join(" ")), + muted(` ${when(reading.now - group.at)}`), + ], + ], + }), + ), + { + label: "Means", + keep: 6, + lines: [ + [ + muted( + `OpenCode answers ${bash ? "every command that starts this way" : "everything these match"} — not only the one you approved.`, + ), + ], + ], + }, + { + label: "Until", + keep: 5, + lines: [[muted("OpenCode restarts. Trust cannot take it back: restarting OpenCode ends it.")]], + }, + ], + buttons: [], + } +} + +export function cardOf(node: Node, reading: CardReading): CardParts { + const parts = + node.kind === "always" + ? alwaysCard(node.groups, reading) + : node.kind === "command" + ? commandCard(node.command, node.family, reading) + : familyCard(node.family, reading) + return { ...parts, buttons: buttonsOf(node, reading.families) } +} + +/** The card's buttons as one row, the focused one solid. */ +function buttonRow(buttons: readonly Button[], focus: number | undefined, width: number): Row { + const runs: Run[] = [{ text: " " }] + buttons.forEach((each, at) => { + if (at > 0) runs.push({ text: " " }) + runs.push(...button(each.key, each.label, { on: focus === at, off: each.off })) + }) + return fit(runs, width) +} + +/** + * The card in exactly `room` rows of `width`: the command whole (at most three rows), each agent's + * standing, the facts, the buttons. Short of room, the rows of air go first, then the least telling + * facts, then facts lose their second lines — never the title's first row or the buttons. + */ +export function cardRows(parts: CardParts, width: number, room: number, focus: number | undefined): Row[] { + const panel = (runs: Run[]) => filled(fit([{ text: " " }, ...runs, { text: " " }], width), "panel") + const titleRows = wrapRuns(parts.title, Math.max(1, width - 2), 3).map((row) => panel(row)) + const standing = parts.standing.map((runs) => panel(runs)) + const textWidth = Math.max(1, width - 2 - LABEL) + const factRows = (fact: Fact, max: number) => { + const rows = factLines(fact, textWidth) + const kept = + rows.length > max ? [...rows.slice(0, max - 1), fitLast(rows.slice(max - 1), textWidth)] : rows + return kept.map((row, at) => + fit([{ text: " " }, muted((at === 0 ? fact.label : "").padEnd(LABEL)), ...row], width), + ) + } + const buttons = buttonRow(parts.buttons, focus, width) + let facts = [...parts.facts] + const need = (list: readonly Fact[], full: boolean) => + list.reduce((sum, fact) => sum + (full ? factLines(fact, textWidth).length : 1), 0) + let title = titleRows + let stand = standing + /** Rows of air: under the standing, and above the buttons. */ + let air = 2 + const total = (full: boolean) => title.length + stand.length + air + need(facts, full) + 1 + while (total(false) > room && air > 0) air-- + while (total(false) > room && title.length > 1) title = title.slice(0, -1) + while (total(false) > room && stand.length > 1) stand = stand.slice(0, -1) + while (total(false) > room && facts.length > 0) { + const lowest = Math.min(...facts.map((fact) => fact.keep)) + const at = facts.findLastIndex((fact) => fact.keep === lowest) + facts = facts.filter((_, index) => index !== at) + } + /** Every fact has a row; what is left goes to their second lines, in order. */ + let spare = room - (title.length + stand.length + air + facts.length + 1) + const give = facts.map(() => 1) + facts.forEach((fact, index) => { + const want = factLines(fact, textWidth).length + while (spare > 0 && (give[index] as number) < want) { + give[index] = (give[index] as number) + 1 + spare-- + } + }) + const rows: Row[] = [...title, ...stand] + if (air >= 1) rows.push(fit([], width)) + facts.forEach((fact, index) => { + rows.push(...factRows(fact, give[index] as number)) + }) + /** The buttons sit right under the facts, and the card's spare room is below them. */ + if (air >= 2) rows.push(fit([], width)) + rows.push(buttons) + while (rows.length < room) rows.push(fit([], width)) + return rows.slice(0, room) +} + +/** Rows past what a fact was given, as its last row, cut with `…`. */ +function fitLast(rows: readonly Row[], width: number): Row { + /** Each row without the padding `fit` gave it, joined by one space. */ + const bare = (row: Row): Run[] => { + const out = [...row] + while (out.length > 0 && (out.at(-1) as Run).text.trim() === "" && !(out.at(-1) as Run).fill) out.pop() + const last = out.at(-1) + if (last) out[out.length - 1] = { ...last, text: last.text.trimEnd() } + return out + } + return fit( + rows.flatMap((row, at) => [...(at > 0 ? [{ text: " " }] : []), ...bare(row)]), + width, + ) +} + +/** Where the buttons row landed in `cardRows`' output. */ +export const buttonsAt = (rows: readonly Row[]) => + rows.findIndex((row) => row.some((run) => run.fill === "button" || run.fill === "buttonOn")) diff --git a/packages/trust/src/core/view/explorer.ts b/packages/trust/src/core/view/explorer.ts index 8c315147..2f72afc7 100644 --- a/packages/trust/src/core/view/explorer.ts +++ b/packages/trust/src/core/view/explorer.ts @@ -26,798 +26,43 @@ * would do; and the actions as buttons you can click or reach with `tab`. * * Below about ninety columns the card moves under the tree, the selection kept in view above it. + * + * The tree is `tree.ts`, the card `card.ts`; this file lays the screen out from both. */ import { closeHint, type Hint } from "@opencode-cockpit/client/design" -import { anyOf, narrower, outside, redirected, showSubject, spelledWords, widenable } from "../family.ts" -import { earned, type History, type Mark } from "../history.ts" -import { keyOf, type Thresholds } from "../ledger.ts" -import { NOT_COVERED, revokeLabel, type Target, widenLabel, widenScope } from "./actions.ts" +import type { History } from "../history.ts" import { - type AlwaysGroup, - alwaysGroups, - type Command, - type Counts, - commandsOf, - countsOf, - type Family, - familiesOf, - leadOf, - type Reading, - type Standing, -} from "./model.ts" + type Button, + buttonsAt, + type CardParts, + type CardReading, + cardOf, + cardRows, + factLines, + LABEL, +} from "./card.ts" +import type { Reading } from "./model.ts" import { - agentsText, - badge, - button, buttonHits, - chip, footerRow, type Hit, headerRow, type KeyLine, keyListBody, - meter, muted, - plain, - plural, - since, - when, wrapRuns, } from "./parts.ts" -import { cursorRow, filled, fit, type Row, type Run, rowText, spread, type Tone, widthOf } from "./rows.ts" - -/* ─── the tree ───────────────────────────────────────────────────────────────────────────────── */ - -export type Node = - | { kind: "family"; key: string; family: Family; open: boolean } - | { kind: "command"; key: string; command: Command; family: Family; nested: boolean } - /** The folded tail of an open family: `+ 3 more`. */ - | { kind: "more"; key: string; family: Family; hidden: number } - | { kind: "always"; key: string; groups: AlwaysGroup[] } - -export interface Tree { - /** Families opened, by `Family.key`. */ - open: ReadonlySet - /** Families whose tail is shown too, by `Family.key`. */ - full: ReadonlySet - /** Only what matches this text, every family with a match open. Empty: everything. */ - filter: string -} - -export interface ExplorerModel { - nodes: Node[] - families: Family[] - commands: Command[] - counts: Counts -} - -/** Commands an open family lists before folding the rest into `+ N more`. */ -export const TAIL = 3 - -export const familyNodeKey = (family: Family): string => `f:${family.key}` -export const commandNodeKey = (command: Command): string => `c:${command.key}` -export const ALWAYS_KEY = "o:always" - -/** A family drawn as its one command: one command, and no widening a heading would have to say. */ -const single = (family: Family) => family.commands.length === 1 && family.widened.length === 0 - -const commandText = (command: Command) => - `${command.permission === "bash" ? "" : `${command.permission} `}${showSubject(command.permission, command.subject)}` -const familyText = (family: Family) => - `${family.permission === "bash" ? "" : `${family.permission} `}${showSubject(family.permission, family.family)}` - -export function explorerModel(input: Reading & Tree): ExplorerModel { - const commands = commandsOf(input) - const families = familiesOf(input, commands) - const needle = input.filter.trim().toLowerCase() - const nodes: Node[] = [] - for (const family of families) { - const named = needle !== "" && familyText(family).toLowerCase().includes(needle) - const listed = - needle === "" || named - ? family.commands - : family.commands.filter((command) => commandText(command).toLowerCase().includes(needle)) - if (needle !== "" && listed.length === 0 && !named) continue - if (single(family)) { - const command = family.commands[0] as Command - nodes.push({ kind: "command", key: commandNodeKey(command), command, family, nested: false }) - continue - } - const open = needle !== "" || input.open.has(family.key) - nodes.push({ kind: "family", key: familyNodeKey(family), family, open }) - if (!open) continue - const whole = needle !== "" || input.full.has(family.key) || listed.length <= TAIL + 1 - const shown = whole ? listed : listed.slice(0, TAIL) - for (const command of shown) - nodes.push({ kind: "command", key: commandNodeKey(command), command, family, nested: true }) - if (!whole) - nodes.push({ kind: "more", key: `m:${family.key}`, family, hidden: listed.length - shown.length }) - } - const groups = alwaysGroups(input.state) - if ( - groups.length > 0 && - (needle === "" || - "opencode always".includes(needle) || - groups.some((group) => group.patterns.join(" ").toLowerCase().includes(needle))) - ) - nodes.push({ kind: "always", key: ALWAYS_KEY, groups }) - return { nodes, families, commands, counts: countsOf(commands) } -} - -/** What `x`, `w` and `c` act on, for a node. */ -export function nodeTarget(node: Node): Target { - if (node.kind === "always") return { kind: "always", groups: node.groups } - if (node.kind === "command") return { kind: "command", command: node.command, family: node.family } - return { kind: "family", family: node.family } -} - -/** The tree state that shows `command` with its family open, its tail too when it is folded there. */ -export function reveal( - tree: { open: Set; full: Set }, - families: readonly Family[], - subject: { permission: string; subject: string }, -): string | undefined { - for (const family of families) { - const at = family.commands.findIndex( - (command) => command.permission === subject.permission && command.subject === subject.subject, - ) - if (at < 0) continue - if (!single(family)) { - tree.open.add(family.key) - if (at >= TAIL && family.commands.length > TAIL + 1) tree.full.add(family.key) - } - return commandNodeKey(family.commands[at] as Command) - } - return undefined -} - -/* ─── a tree row ─────────────────────────────────────────────────────────────────────────────── */ - -/** The right-hand column of a tree row: how the command stands, or what a family holds. */ -function statusRuns(node: Node): Run[] { - if (node.kind === "more") return [] - if (node.kind === "always") - return [ - { - text: plural( - node.groups.reduce((sum, group) => sum + group.patterns.length, 0), - "broad rule", - ), - tone: "warning", - }, - ] - if (node.kind === "family") { - const trusted = node.family.commands.filter((command) => command.phase === "answering").length - const learning = node.family.commands.filter((command) => command.phase === "learning").length - const rest = node.family.commands.length - trusted - return [ - ...(trusted > 0 ? [{ text: `${trusted} ✓`, tone: "success" as Tone }] : []), - ...(trusted > 0 && rest > 0 ? [{ text: " " }] : []), - ...(rest > 0 ? [{ text: `${rest} ○`, tone: (learning > 0 ? "warning" : "muted") as Tone }] : []), - ] - } - const { command } = node - const { stand } = leadOf(command) - const autos = command.standings.reduce((sum, each) => sum + each.entry.autos, 0) - if (stand.kind === "trusted") - return [{ text: "✓ trusted", tone: "success" }, ...(autos > 0 ? [muted(` ${autos}×`)] : [])] - if (stand.kind === "widened") - return [{ text: "✓ widened", tone: "success" }, ...(autos > 0 ? [muted(` ${autos}×`)] : [])] - if (stand.expired) return [muted("expired")] - /** A long meter says its count in the short form, so the command keeps the room. */ - return [ - ...meter(stand.have, stand.need, command.danger !== undefined), - muted(stand.need > 4 ? ` ${stand.have}/${stand.need}` : ` ${stand.have} of ${stand.need}`), - ] -} - -/** - * One row of the tree. `statusWidth` is the status column's, `badges` the cells before it kept for a - * dangerous command's `!` — so every meter and every `✓` starts in the same column. - */ -function treeRow(node: Node, width: number, statusWidth: number, badges: number, selected: boolean): Row { - let left: Run[] - if (node.kind === "family") { - left = [ - muted(` ${node.open ? "▾" : "▸"} `), - { - text: familyText(node.family), - tone: - node.family.commands.every((c) => c.phase === "once") && node.family.widened.length === 0 - ? "muted" - : "text", - bold: true, - }, - ...(node.family.widened.length > 0 ? [{ text: " " }, badge("any", "success")] : []), - ] - } else if (node.kind === "command") { - const indent = node.nested ? 5 : 3 - left = [ - { text: " ".repeat(indent) }, - { - text: nestedText(node, width - indent - statusWidth - badges - 3), - tone: node.command.phase === "once" ? "muted" : "text", - }, - ] - } else if (node.kind === "more") left = [muted(` + ${node.hidden} more`)] - else left = [{ text: " ! ", tone: "warning" }, plain("OpenCode always")] - const status = statusRuns(node) - const danger = - node.kind === "command" && node.command.danger !== undefined && node.command.phase !== "answering" - const right: Run[] = [ - ...(danger ? [badge("!", "error"), { text: " " }] : badges > 0 ? [{ text: " ".repeat(badges) }] : []), - ...status, - ] - const padded: Run[] = - status.length === 0 - ? [] - : [...right, { text: " ".repeat(Math.max(0, statusWidth - widthOf(rowText(status)))) }, { text: " " }] - const row = padded.length > 0 ? spread(left, padded, width) : fit(left, width) - return selected ? cursorRow(row, width) : row -} - -/** - * A command under its family's heading, in `room` columns. Cut from the right, two variants of one - * family read alike (`git status --sho…` twice), so a command that does not fit gives up the family's - * words first — the heading above already says them: `… --short -uno`. - */ -function nestedText(node: Extract, room: number): string { - const text = commandText(node.command) - if (widthOf(text) <= room || !node.nested) return text - const family = familyText(node.family) - return text.startsWith(`${family} `) ? `…${text.slice(family.length)}` : text -} - -const statusWidthOf = (nodes: readonly Node[], width: number) => - Math.min(Math.floor(width * 0.45), Math.max(0, ...nodes.map((node) => widthOf(rowText(statusRuns(node)))))) - -/** Cells for the `!` of a dangerous command among `nodes`: its badge and a space, or none. */ -const badgesOf = (nodes: readonly Node[]) => - nodes.some( - (node) => - node.kind === "command" && node.command.danger !== undefined && node.command.phase !== "answering", - ) - ? 4 - : 0 - -/* ─── the card ───────────────────────────────────────────────────────────────────────────────── */ - -/** `Still asks` and two spaces: the longest label. */ -const LABEL = 12 - -/** One labelled fact on the card: each line wraps on its own. `keep`: higher stays when room is short. */ -interface Fact { - label: string - lines: Run[][] - keep: number - /** The first line is a row of moments: it keeps its newest end on one row rather than wrapping. */ - newest?: boolean -} - -/** A fact's rows at `width`: each line wrapped, a row of moments cut from its oldest end. */ -function factLines(fact: Fact, width: number): Row[] { - return fact.lines.flatMap((line, at) => - at === 0 && fact.newest ? [fit(tail(line, width), width)] : wrapRuns(line, width), - ) -} - -export interface Button { - key: string - label: string - off: boolean - action: "revoke" | "widen" | "copy" -} - -interface CardParts { - title: Run[] - standing: Run[][] - facts: Fact[] - buttons: Button[] -} - -export interface CardReading extends Reading { - history: History - families: readonly Family[] -} - -/** The buttons a node offers: `x`, `w` where a family can widen, `c`. */ -export function buttonsOf(node: Node, families: readonly Family[]): Button[] { - const target = nodeTarget(node) - const x = revokeLabel(target) - const out: Button[] = [{ key: "x", label: x.label, off: x.off, action: "revoke" }] - if (node.kind !== "always") { - const w = widenLabel(widenScope(target, families)) - out.push({ key: "w", label: w.label, off: w.off, action: "widen" }) - } - out.push({ key: "c", label: "Copy rule", off: false, action: "copy" }) - return out -} - -/** The exact text, and — when a font could draw a word of it as something else — that word in words. */ -function exactly(command: Command): Fact { - const spelled = spelledWords(command.permission, command.subject) - return { - label: "Exactly", - keep: 3, - lines: [ - [ - plain(commandText(command)), - ...(spelled.length > 0 - ? [ - muted( - ` — ${spelled.map((each) => `${each.word} is ${each.said}`).join(", ")}, which some fonts draw as one line`, - ), - ] - : []), - ], - ], - } -} - -/** What still asks once a command is trusted exactly: a smaller one, and the same into a file. */ -function stillAsks(command: Command, widened: boolean): Fact { - if (widened) return { label: "Still asks", keep: 6, lines: [[muted(`${NOT_COVERED}.`)]] } - if (command.permission === "edit") - return { label: "Still asks", keep: 6, lines: [[muted("any other file")]] } - if (command.permission !== "bash") - return { label: "Still asks", keep: 6, lines: [[muted(`any other ${command.permission}`)]] } - const examples = [narrower(command.subject), redirected(command.subject)].filter( - (each): each is string => each !== undefined, - ) - const runs: Run[] = [] - examples.forEach((example, index) => { - if (index > 0) runs.push(muted(" · ")) - runs.push(plain(example)) - }) - runs.push(muted(examples.length > 0 ? " · any other argument" : "any other argument")) - return { label: "Still asks", keep: 6, lines: [runs] } -} - -/** - * The moments that made a rule what it is, oldest to newest, the newest kept when they do not all fit: - * `✓ 9h ✓ 9h ✓ 9h → trusted answered 4×`. - */ -export function historyRuns(marks: readonly Mark[], need: number, expireMs: number, now: number): Run[] { - const tokens: Run[][] = [] - let streak = 0 - let last = 0 - for (const each of marks) { - if (each.kind === "rejected") { - streak = 0 - tokens.push([{ text: "✗", tone: "error" }, muted(` ${since(now - each.at)}`)]) - continue - } - if (each.kind === "revoked") { - streak = 0 - tokens.push([muted(`revoked ${since(now - each.at)}`)]) - continue - } - if (expireMs > 0 && last > 0 && each.at - last > expireMs) { - streak = 0 - tokens.push([muted("expired")]) - } - last = Math.max(last, each.at) - if (each.kind === "auto") { - tokens.push([muted(`answered ${each.count}×`)]) - continue - } - streak++ - tokens.push([{ text: "✓", tone: "success" }, muted(` ${since(now - each.at)}`)]) - if (streak === need) tokens.push([{ text: "→ trusted", tone: "success" }]) - } - return tokens.flatMap((token, at) => [...(at > 0 ? [{ text: " " }] : []), ...token]) -} - -/** The newest end of `runs` in `width` columns, `… ` in front when the oldest were left out. */ -function tail(runs: readonly Run[], width: number): Run[] { - if (widthOf(rowText(runs as Run[])) <= width) return [...runs] - const out: Run[] = [] - let used = 2 - for (let i = runs.length - 1; i >= 0; i--) { - const run = runs[i] as Run - const w = widthOf(run.text) - if (used + w > width) break - out.unshift(run) - used += w - } - while (out[0]?.text.trim() === "") out.shift() - return [muted("… "), ...out] -} - -/** The sentence under the moments: why it stands where it does. */ -function historySaid(standing: Standing, marks: readonly Mark[], reading: CardReading): Run[] { - const { entry, stand } = standing - const { settings, now } = reading - const expireMs = settings.expireDays * 86_400_000 - if (marks.length === 0) - return [ - muted( - `approved ${entry.approvals}× in all, first ${when(now - entry.firstAt)} — older than what is kept`, - ), - ] - if (stand.kind === "trusted") { - const got = earned(marks, Number.POSITIVE_INFINITY, expireMs) - return [muted(`${got.streak} ${got.streak === 1 ? "approval" : "approvals"} in a row, all yours`)] - } - if (stand.kind === "widened") - return [muted(`answered through ${anyOf(entry.permission, stand.family)}, not by its own count`)] - if (stand.expired) return [muted(`unused over ${settings.expireDays} days, so it counts again from 0`)] - const got = earned(marks, stand.need, expireMs) - const left = stand.need - stand.have - if (got.broken === "rejected" && got.brokenAt !== undefined) - return [ - muted(`you rejected it ${when(now - got.brokenAt)}; ${stand.have} in a row since, ${left} more to go`), - ] - if (got.broken === "revoked" && got.brokenAt !== undefined) - return [muted(`revoked ${when(now - got.brokenAt)}; ${stand.have} in a row since, ${left} more to go`)] - return [muted(`${stand.have} in a row, all yours · ${left} more and Trust answers it`)] -} - -/** One agent's standing, on the card's panel. */ -function standingRuns(standing: Standing, danger: boolean): Run[] { - const { entry, stand } = standing - if (stand.kind === "trusted") - return [ - { text: "✓ Trusted", tone: "success", bold: true }, - muted(" for "), - chip(entry.agent), - muted(entry.autos > 0 ? ` · answered ${entry.autos}×` : " · ready, not used yet"), - ] - if (stand.kind === "widened") - return [ - { text: "✓ Answered", tone: "success", bold: true }, - muted(" for "), - chip(entry.agent), - muted(` · through ${anyOf(entry.permission, stand.family)}`), - ] - if (stand.expired) - return [ - { text: "○ Expired", tone: "muted", bold: true }, - muted(" for "), - chip(entry.agent), - muted(" · counting from 0"), - ] - return [ - ...meter(stand.have, stand.need, danger), - { text: ` ${stand.have} of ${stand.need}`, tone: "text", bold: true }, - muted(" for "), - chip(entry.agent), - muted(stand.have <= 1 && entry.autos === 0 ? " · seen once" : ` · ${stand.need - stand.have} more to go`), - ] -} - -function familyFact( - family: Family | undefined, - command: Command | undefined, - reading: CardReading, -): Fact | undefined { - if (!family) return undefined - const trusted = family.commands.filter((each) => each.phase === "answering").length - const name = familyText(family) - const head: Run[] = [ - plain(name), - muted(` · ${plural(family.commands.length, "command")}, ${trusted} trusted`), - ] - const agent = command - ? leadOf(command).entry.agent - : family.commands[0] - ? leadOf(family.commands[0]).entry.agent - : undefined - const widenedFor = family.widened.filter((each) => agent === undefined || each.agent === agent) - if (widenedFor.length > 0) { - const at = Math.max(...widenedFor.map((each) => each.at)) - return { - label: "Family", - keep: 4, - lines: [ - head, - [ - plain(anyOf(family.permission, family.family)), - muted( - ` trusted for ${agentsText(widenedFor.map((each) => each.agent))} ${when(reading.now - at)} · [w] undoes it`, - ), - ], - ], - } - } - const can = widenable(family.permission, family.family) - if (!can.ok) return { label: "Family", keep: 4, lines: [head, [muted(`never widened: ${can.why}`)]] } - return { - label: "Family", - keep: 4, - lines: [ - head, - [ - muted("[w] trusts "), - plain(anyOf(family.permission, family.family)), - muted(` for ${agent ?? "this agent"}${family.commands.length > 1 ? ", not one by one" : ""}`), - ], - ], - } -} - -function commandCard(command: Command, family: Family | undefined, reading: CardReading): CardParts { - const { settings, now, history } = reading - const lead = leadOf(command) - const danger = command.danger !== undefined - const expireMs = settings.expireDays * 86_400_000 - const marks = history.marks.get(keyOf(command.permission, lead.entry.agent, command.subject)) ?? [] - const need = - lead.stand.kind === "counting" - ? lead.stand.need - : settings.threshold + (danger ? settings.dangerExtra : 0) - const facts: Fact[] = [exactly(command)] - if (command.phase === "answering") facts.push(stillAsks(command, lead.stand.kind === "widened")) - const many = command.standings.length > 1 - facts.push({ - label: "History", - keep: 7, - newest: true, - lines: [ - marks.length > 0 ? historyRuns(marks, need, expireMs, now) : [muted("—")], - [...(many ? [chip(lead.entry.agent), { text: " " }] : []), ...historySaid(lead, marks, reading)], - ], - }) - if (danger && command.phase !== "answering") { - const can = family ? widenable(family.permission, family.family) : { ok: true as const } - facts.push({ - label: "Dangerous", - keep: 5, - lines: [ - [ - muted( - `${command.danger} — ${needOf(settings, true)} in a row instead of ${settings.threshold}${command.permission === "bash" && !can.ok && !family ? ", never widened" : ""}`, - ), - ], - ], - }) - } - const why = outside(command.permission, command.subject) - if (family && why !== undefined && family.widened.some((each) => each.agent === lead.entry.agent)) - facts.push({ - label: "Widened", - keep: 5, - lines: [ - [muted("but "), plain(anyOf(command.permission, family.family)), muted(` leaves it out: ${why}`)], - ], - }) - const fam = familyFact(family, command, reading) - if (fam) facts.push(fam) - if (lead.stand.kind === "trusted" && settings.expireDays > 0) { - const left = Math.max(0, Math.ceil((lead.entry.lastAt + expireMs - now) / 86_400_000)) - facts.push({ - label: "Expires", - keep: 1, - lines: [ - [ - muted( - left >= settings.expireDays - ? `if unused for ${settings.expireDays} days` - : `if unused for ${left} more ${left === 1 ? "day" : "days"}`, - ), - ], - ], - }) - } - return { - title: [{ text: commandText(command), tone: "text", bold: true }], - standing: command.standings.map((each) => standingRuns(each, danger)), - facts, - buttons: [], - } -} - -const needOf = (settings: Thresholds, danger: boolean) => - settings.threshold + (danger ? settings.dangerExtra : 0) - -function familyCard(family: Family, reading: CardReading): CardParts { - const counts = countsOf(family.commands) - const standing: Run[][] = - family.widened.length > 0 - ? family.widened.map((each) => [ - { text: "✓ Any", tone: "success", bold: true }, - plain(` ${showSubject(family.permission, family.family)} …`), - muted(" trusted for "), - chip(each.agent), - muted(` · you widened it ${when(reading.now - each.at)}`), - ]) - : [ - [ - { text: `${counts.trusted} trusted`, tone: counts.trusted > 0 ? "success" : "muted", bold: true }, - muted(" · "), - { - text: `${counts.learning} learning`, - tone: counts.learning > 0 ? "warning" : "muted", - bold: true, - }, - muted(` · ${counts.once} seen once`), - ], - ] - const facts: Fact[] = [] - const list: Run[] = [] - family.commands.forEach((command, at) => { - if (at > 0) list.push(muted(" · ")) - list.push({ - text: showSubject(command.permission, command.subject), - tone: command.phase === "answering" ? "success" : "text", - }) - }) - if (list.length > 0) facts.push({ label: "Commands", keep: 7, lines: [list] }) - const best = family.commands.find((command) => command.phase === "learning") - if (best) { - const { stand, entry } = leadOf(best) - if (stand.kind === "counting") - facts.push({ - label: "Closest", - keep: 5, - lines: [ - [ - plain(showSubject(best.permission, best.subject)), - muted(`, ${stand.have} of ${stand.need} for ${entry.agent}`), - ], - ], - }) - } - if (family.widened.length > 0) - facts.push({ label: "Still asks", keep: 6, lines: [[muted(`${NOT_COVERED}.`)]] }) - const fam = familyFact(family, undefined, reading) - if (fam) facts.push({ ...fam, label: "Widen", lines: fam.lines.slice(1) }) - return { - title: [ - { text: familyText(family), tone: "text", bold: true }, - muted(` family of ${plural(family.commands.length, "command")}`), - ], - standing, - facts, - buttons: [], - } -} - -function alwaysCard(groups: readonly AlwaysGroup[], reading: CardReading): CardParts { - const bash = groups.every((group) => group.permission === "bash") - return { - title: [{ text: `OpenCode's own "always"`, tone: "text", bold: true }], - standing: groups.map((group) => [ - { text: "! ", tone: "warning", bold: true }, - { text: plural(group.patterns.length, "broad rule"), tone: "warning", bold: true }, - muted(" for "), - chip(group.agent), - muted(" · until OpenCode restarts"), - ]), - facts: [ - ...groups.map( - (group): Fact => ({ - label: "Patterns", - keep: 7, - lines: [ - [ - ...(group.permission === "bash" ? [] : [muted(`${group.permission} `)]), - plain(group.patterns.join(" ")), - muted(` ${when(reading.now - group.at)}`), - ], - ], - }), - ), - { - label: "Means", - keep: 6, - lines: [ - [ - muted( - `OpenCode answers ${bash ? "every command that starts this way" : "everything these match"} — not only the one you approved.`, - ), - ], - ], - }, - { - label: "Until", - keep: 5, - lines: [[muted("OpenCode restarts. Trust cannot take it back: restarting OpenCode ends it.")]], - }, - ], - buttons: [], - } -} - -export function cardOf(node: Node, reading: CardReading): CardParts { - const parts = - node.kind === "always" - ? alwaysCard(node.groups, reading) - : node.kind === "command" - ? commandCard(node.command, node.family, reading) - : familyCard(node.family, reading) - return { ...parts, buttons: buttonsOf(node, reading.families) } -} - -/** The card's buttons as one row, the focused one solid. */ -function buttonRow(buttons: readonly Button[], focus: number | undefined, width: number): Row { - const runs: Run[] = [{ text: " " }] - buttons.forEach((each, at) => { - if (at > 0) runs.push({ text: " " }) - runs.push(...button(each.key, each.label, { on: focus === at, off: each.off })) - }) - return fit(runs, width) -} - -/** - * The card in exactly `room` rows of `width`: the command whole (at most three rows), each agent's - * standing, the facts, the buttons. Short of room, the rows of air go first, then the least telling - * facts, then facts lose their second lines — never the title's first row or the buttons. - */ -function cardRows(parts: CardParts, width: number, room: number, focus: number | undefined): Row[] { - const panel = (runs: Run[]) => filled(fit([{ text: " " }, ...runs, { text: " " }], width), "panel") - const titleRows = wrapRuns(parts.title, Math.max(1, width - 2), 3).map((row) => panel(row)) - const standing = parts.standing.map((runs) => panel(runs)) - const textWidth = Math.max(1, width - 2 - LABEL) - const factRows = (fact: Fact, max: number) => { - const rows = factLines(fact, textWidth) - const kept = - rows.length > max ? [...rows.slice(0, max - 1), fitLast(rows.slice(max - 1), textWidth)] : rows - return kept.map((row, at) => - fit([{ text: " " }, muted((at === 0 ? fact.label : "").padEnd(LABEL)), ...row], width), - ) - } - const buttons = buttonRow(parts.buttons, focus, width) - let facts = [...parts.facts] - const need = (list: readonly Fact[], full: boolean) => - list.reduce((sum, fact) => sum + (full ? factLines(fact, textWidth).length : 1), 0) - let title = titleRows - let stand = standing - /** Rows of air: under the standing, and above the buttons. */ - let air = 2 - const total = (full: boolean) => title.length + stand.length + air + need(facts, full) + 1 - while (total(false) > room && air > 0) air-- - while (total(false) > room && title.length > 1) title = title.slice(0, -1) - while (total(false) > room && stand.length > 1) stand = stand.slice(0, -1) - while (total(false) > room && facts.length > 0) { - const lowest = Math.min(...facts.map((fact) => fact.keep)) - const at = facts.findLastIndex((fact) => fact.keep === lowest) - facts = facts.filter((_, index) => index !== at) - } - /** Every fact has a row; what is left goes to their second lines, in order. */ - let spare = room - (title.length + stand.length + air + facts.length + 1) - const give = facts.map(() => 1) - facts.forEach((fact, index) => { - const want = factLines(fact, textWidth).length - while (spare > 0 && (give[index] as number) < want) { - give[index] = (give[index] as number) + 1 - spare-- - } - }) - const rows: Row[] = [...title, ...stand] - if (air >= 1) rows.push(fit([], width)) - facts.forEach((fact, index) => { - rows.push(...factRows(fact, give[index] as number)) - }) - /** The buttons sit right under the facts, and the card's spare room is below them. */ - if (air >= 2) rows.push(fit([], width)) - rows.push(buttons) - while (rows.length < room) rows.push(fit([], width)) - return rows.slice(0, room) -} - -/** Rows past what a fact was given, as its last row, cut with `…`. */ -function fitLast(rows: readonly Row[], width: number): Row { - /** Each row without the padding `fit` gave it, joined by one space. */ - const bare = (row: Row): Run[] => { - const out = [...row] - while (out.length > 0 && (out.at(-1) as Run).text.trim() === "" && !(out.at(-1) as Run).fill) out.pop() - const last = out.at(-1) - if (last) out[out.length - 1] = { ...last, text: last.text.trimEnd() } - return out - } - return fit( - rows.flatMap((row, at) => [...(at > 0 ? [{ text: " " }] : []), ...bare(row)]), - width, - ) -} - -/** Where the buttons row landed in `cardRows`' output. */ -const buttonsAt = (rows: readonly Row[]) => - rows.findIndex((row) => row.some((run) => run.fill === "button" || run.fill === "buttonOn")) - -/* ─── the screen ─────────────────────────────────────────────────────────────────────────────── */ +import { fit, type Row, type Run, spread, type Tone } from "./rows.ts" +import { + badgesOf, + type ExplorerModel, + explorerModel, + type Node, + statusWidthOf, + type Tree, + treeRow, +} from "./tree.ts" export interface ExplorerInput extends Reading, Tree { width: number diff --git a/packages/trust/src/core/view/tree.ts b/packages/trust/src/core/view/tree.ts new file mode 100644 index 00000000..bbfbed44 --- /dev/null +++ b/packages/trust/src/core/view/tree.ts @@ -0,0 +1,232 @@ +import { showSubject } from "../family.ts" +import type { Target } from "./actions.ts" +import { + type AlwaysGroup, + alwaysGroups, + type Command, + type Counts, + commandsOf, + countsOf, + type Family, + familiesOf, + leadOf, + type Reading, +} from "./model.ts" +import { badge, meter, muted, plain, plural } from "./parts.ts" +import { cursorRow, fit, type Row, type Run, rowText, spread, type Tone, widthOf } from "./rows.ts" + +export type Node = + | { kind: "family"; key: string; family: Family; open: boolean } + | { kind: "command"; key: string; command: Command; family: Family; nested: boolean } + /** The folded tail of an open family: `+ 3 more`. */ + | { kind: "more"; key: string; family: Family; hidden: number } + | { kind: "always"; key: string; groups: AlwaysGroup[] } + +export interface Tree { + /** Families opened, by `Family.key`. */ + open: ReadonlySet + /** Families whose tail is shown too, by `Family.key`. */ + full: ReadonlySet + /** Only what matches this text, every family with a match open. Empty: everything. */ + filter: string +} + +export interface ExplorerModel { + nodes: Node[] + families: Family[] + commands: Command[] + counts: Counts +} + +/** Commands an open family lists before folding the rest into `+ N more`. */ +export const TAIL = 3 + +export const familyNodeKey = (family: Family): string => `f:${family.key}` +export const commandNodeKey = (command: Command): string => `c:${command.key}` +export const ALWAYS_KEY = "o:always" + +/** A family drawn as its one command: one command, and no widening a heading would have to say. */ +const single = (family: Family) => family.commands.length === 1 && family.widened.length === 0 + +export const commandText = (command: Command) => + `${command.permission === "bash" ? "" : `${command.permission} `}${showSubject(command.permission, command.subject)}` +export const familyText = (family: Family) => + `${family.permission === "bash" ? "" : `${family.permission} `}${showSubject(family.permission, family.family)}` + +export function explorerModel(input: Reading & Tree): ExplorerModel { + const commands = commandsOf(input) + const families = familiesOf(input, commands) + const needle = input.filter.trim().toLowerCase() + const nodes: Node[] = [] + for (const family of families) { + const named = needle !== "" && familyText(family).toLowerCase().includes(needle) + const listed = + needle === "" || named + ? family.commands + : family.commands.filter((command) => commandText(command).toLowerCase().includes(needle)) + if (needle !== "" && listed.length === 0 && !named) continue + if (single(family)) { + const command = family.commands[0] as Command + nodes.push({ kind: "command", key: commandNodeKey(command), command, family, nested: false }) + continue + } + const open = needle !== "" || input.open.has(family.key) + nodes.push({ kind: "family", key: familyNodeKey(family), family, open }) + if (!open) continue + const whole = needle !== "" || input.full.has(family.key) || listed.length <= TAIL + 1 + const shown = whole ? listed : listed.slice(0, TAIL) + for (const command of shown) + nodes.push({ kind: "command", key: commandNodeKey(command), command, family, nested: true }) + if (!whole) + nodes.push({ kind: "more", key: `m:${family.key}`, family, hidden: listed.length - shown.length }) + } + const groups = alwaysGroups(input.state) + if ( + groups.length > 0 && + (needle === "" || + "opencode always".includes(needle) || + groups.some((group) => group.patterns.join(" ").toLowerCase().includes(needle))) + ) + nodes.push({ kind: "always", key: ALWAYS_KEY, groups }) + return { nodes, families, commands, counts: countsOf(commands) } +} + +/** What `x`, `w` and `c` act on, for a node. */ +export function nodeTarget(node: Node): Target { + if (node.kind === "always") return { kind: "always", groups: node.groups } + if (node.kind === "command") return { kind: "command", command: node.command, family: node.family } + return { kind: "family", family: node.family } +} + +/** The tree state that shows `command` with its family open, its tail too when it is folded there. */ +export function reveal( + tree: { open: Set; full: Set }, + families: readonly Family[], + subject: { permission: string; subject: string }, +): string | undefined { + for (const family of families) { + const at = family.commands.findIndex( + (command) => command.permission === subject.permission && command.subject === subject.subject, + ) + if (at < 0) continue + if (!single(family)) { + tree.open.add(family.key) + if (at >= TAIL && family.commands.length > TAIL + 1) tree.full.add(family.key) + } + return commandNodeKey(family.commands[at] as Command) + } + return undefined +} + +/** The right-hand column of a tree row: how the command stands, or what a family holds. */ +function statusRuns(node: Node): Run[] { + if (node.kind === "more") return [] + if (node.kind === "always") + return [ + { + text: plural( + node.groups.reduce((sum, group) => sum + group.patterns.length, 0), + "broad rule", + ), + tone: "warning", + }, + ] + if (node.kind === "family") { + const trusted = node.family.commands.filter((command) => command.phase === "answering").length + const learning = node.family.commands.filter((command) => command.phase === "learning").length + const rest = node.family.commands.length - trusted + return [ + ...(trusted > 0 ? [{ text: `${trusted} ✓`, tone: "success" as Tone }] : []), + ...(trusted > 0 && rest > 0 ? [{ text: " " }] : []), + ...(rest > 0 ? [{ text: `${rest} ○`, tone: (learning > 0 ? "warning" : "muted") as Tone }] : []), + ] + } + const { command } = node + const { stand } = leadOf(command) + const autos = command.standings.reduce((sum, each) => sum + each.entry.autos, 0) + if (stand.kind === "trusted") + return [{ text: "✓ trusted", tone: "success" }, ...(autos > 0 ? [muted(` ${autos}×`)] : [])] + if (stand.kind === "widened") + return [{ text: "✓ widened", tone: "success" }, ...(autos > 0 ? [muted(` ${autos}×`)] : [])] + if (stand.expired) return [muted("expired")] + /** A long meter says its count in the short form, so the command keeps the room. */ + return [ + ...meter(stand.have, stand.need, command.danger !== undefined), + muted(stand.need > 4 ? ` ${stand.have}/${stand.need}` : ` ${stand.have} of ${stand.need}`), + ] +} + +/** + * One row of the tree. `statusWidth` is the status column's, `badges` the cells before it kept for a + * dangerous command's `!` — so every meter and every `✓` starts in the same column. + */ +export function treeRow( + node: Node, + width: number, + statusWidth: number, + badges: number, + selected: boolean, +): Row { + let left: Run[] + if (node.kind === "family") { + left = [ + muted(` ${node.open ? "▾" : "▸"} `), + { + text: familyText(node.family), + tone: + node.family.commands.every((c) => c.phase === "once") && node.family.widened.length === 0 + ? "muted" + : "text", + bold: true, + }, + ...(node.family.widened.length > 0 ? [{ text: " " }, badge("any", "success")] : []), + ] + } else if (node.kind === "command") { + const indent = node.nested ? 5 : 3 + left = [ + { text: " ".repeat(indent) }, + { + text: nestedText(node, width - indent - statusWidth - badges - 3), + tone: node.command.phase === "once" ? "muted" : "text", + }, + ] + } else if (node.kind === "more") left = [muted(` + ${node.hidden} more`)] + else left = [{ text: " ! ", tone: "warning" }, plain("OpenCode always")] + const status = statusRuns(node) + const danger = + node.kind === "command" && node.command.danger !== undefined && node.command.phase !== "answering" + const right: Run[] = [ + ...(danger ? [badge("!", "error"), { text: " " }] : badges > 0 ? [{ text: " ".repeat(badges) }] : []), + ...status, + ] + const padded: Run[] = + status.length === 0 + ? [] + : [...right, { text: " ".repeat(Math.max(0, statusWidth - widthOf(rowText(status)))) }, { text: " " }] + const row = padded.length > 0 ? spread(left, padded, width) : fit(left, width) + return selected ? cursorRow(row, width) : row +} + +/** + * A command under its family's heading, in `room` columns. Cut from the right, two variants of one + * family read alike (`git status --sho…` twice), so a command that does not fit gives up the family's + * words first — the heading above already says them: `… --short -uno`. + */ +function nestedText(node: Extract, room: number): string { + const text = commandText(node.command) + if (widthOf(text) <= room || !node.nested) return text + const family = familyText(node.family) + return text.startsWith(`${family} `) ? `…${text.slice(family.length)}` : text +} + +export const statusWidthOf = (nodes: readonly Node[], width: number) => + Math.min(Math.floor(width * 0.45), Math.max(0, ...nodes.map((node) => widthOf(rowText(statusRuns(node)))))) + +/** Cells for the `!` of a dangerous command among `nodes`: its badge and a space, or none. */ +export const badgesOf = (nodes: readonly Node[]) => + nodes.some( + (node) => + node.kind === "command" && node.command.danger !== undefined && node.command.phase !== "answering", + ) + ? 4 + : 0 diff --git a/packages/trust/src/server.ts b/packages/trust/src/server.ts new file mode 100644 index 00000000..49e9d919 --- /dev/null +++ b/packages/trust/src/server.ts @@ -0,0 +1,12 @@ +/** + * Published entry point: `@opencode-cockpit/trust/server` — an agent half with nothing of its own, since + * Trust answers from the interface. + * + * It exists for OpenCode 2, which sets up a plugin's interface only once its agent half has loaded: + * listed in `opencode.json`, as `opencode plugin add` writes it, a Trust with no agent half loaded + * nothing at all (measured on 2.0.18). Like every Cockpit agent half, it offers `/cockpit-setup`. + */ + +import { dualServer } from "@opencode-cockpit/client/server" + +export default dualServer("opencode-cockpit.trust", async () => ({})) diff --git a/packages/trust/src/tui/index.tsx b/packages/trust/src/tui/index.tsx index af358acc..426013c1 100644 --- a/packages/trust/src/tui/index.tsx +++ b/packages/trust/src/tui/index.tsx @@ -5,41 +5,22 @@ * and never called (docs/opencode/permissions.md), so a plugin answers a request the way OpenCode's * own auto mode does: from the interface, on `permission.asked`, with a reply of "once". * - * Everything that decides is in `core/`; this file wires it to the host: events in, a reply out, the - * ledger file read and appended, and the two surfaces — the sidebar block and the ledger dialog. + * Everything that decides is in `core/`; this file wires it to the host: the ledger file read and + * appended, the painter (`paint.ts`), requests in and replies out (`requests.ts`), `/trust` + * (`ledger.tsx`), the palette's commands and the sidebar block. */ import { defaultKeys } from "@opencode-cockpit/client/catalog" -import { warnRows } from "@opencode-cockpit/client/design" import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client/feature" -import { bindingLookup, dualTui, type Host, type Layer } from "@opencode-cockpit/client/host" -import { noticeText } from "@opencode-cockpit/client/settings" -import type { BoxRenderable } from "@opentui/core" -import { createSignal } from "solid-js" -import { commandOf, type Seen } from "../core/adapt/seen.ts" +import { bindingLookup, dualTui, type Host } from "@opencode-cockpit/client/host" import { loadTrust, resolveSettings, type TrustConfig } from "../core/config.ts" import { createEngine } from "../core/engine.ts" -import type { Request } from "../core/keys.ts" import type { Event } from "../core/ledger.ts" import { trustPaths } from "../core/paths.ts" -import { rulesFrom } from "../core/rules.ts" -import { configSnippet, type Outcome, revoke, type Target, widen, widenScope } from "../core/view/actions.ts" -import { type ActivityView, activityRows, targetOf } from "../core/view/activity.ts" -import { - ALWAYS_KEY, - type ExplorerView, - explorerRows, - type Node, - nodeTarget, - reveal, -} from "../core/view/explorer.ts" -import type { Family } from "../core/view/model.ts" -import type { Hit } from "../core/view/parts.ts" -import type { Row, Tone } from "../core/view/rows.ts" -import { sidebarRows, tally } from "../core/view/sidebar.ts" import { createJournal } from "./journal.ts" -import { createSource } from "./source.ts" -import { Dialog } from "./view/dialog.tsx" +import { createLedger } from "./ledger.tsx" +import { closedDialog, createPainter, type Live } from "./paint.ts" +import { createRequests } from "./requests.ts" import { Rows } from "./view/rows.tsx" const TRUST_PACKAGE = "@opencode-cockpit/trust" @@ -50,12 +31,6 @@ export type TrustTuiOptions = TrustConfig /** How often other windows' events are read from the ledger, and pending requests checked. */ const SYNC_MS = 3_000 -/** How long a bash request waits for its call's command line before it is decided without one. */ -const PARK_MS = 1_000 -/** Calls remembered for their command line: far more than can be waiting at once. */ -const CALLS_MAX = 500 -/** The host's dialog: as wide as xlarge allows (Shell's console measured it). */ -const DIALOG_COLUMNS = 116 /** Trust's interface half as a factory, so the `opencode-cockpit` bundle can include it. */ export function createTrustTui({ source = TRUST_PACKAGE }: { source?: string } = {}) { @@ -85,144 +60,11 @@ export function createTrustTui({ source = TRUST_PACKAGE }: { source?: string } = const paths = trustPaths(directory) const journal = createJournal(paths) const engine = createEngine({ ...settings, keep: 20 }) - - /** OpenCode's config as its `config.get` returned it; undefined until read, and Trust stays out. */ - let opencodeConfig: unknown - let rulesReady = false - /** Something you should know about. The sidebar says it whatever else it has to say. */ - let trouble: string | undefined - const calls = new Map() - const agents = new Map() - - // --- painting ---------------------------------------------------------------------------------- - - const [sidebarLines, setLines] = createSignal([]) - const [dialogRows, setDialogRows] = createSignal([]) - let block: BoxRenderable | undefined - let drawnAt = 0 - let said = "" - const sidebarWidth = () => { - /** The container the host gave the block, as Subagents measures it: the block's own width follows its rows. */ - const parent = (block?.parent as { width?: number } | null | undefined)?.width ?? 0 - const own = block?.width ?? 0 - const measured = parent >= 12 ? Math.min(parent, own >= 12 ? own : parent) : own - return measured >= 12 ? measured : Math.max(20, Math.min(40, Math.floor(api.renderer.width / 4) - 2)) - } - - /** - * The dialog: the activity `/trust` opens on, and the ledger behind `l`. Each keeps its own cursor - * by key, so a rule revoked or a family folded moves the rows about, not the cursor. - */ - const dialog = { - open: false, - view: "activity" as "activity" | "ledger", - /** `?`: every key, in the body's place. */ - keys: false, - notice: undefined as { text: string; tone: Tone } | undefined, - activity: undefined as string | undefined, - node: undefined as string | undefined, - /** Families opened, and those whose tail is listed too; every family starts folded. */ - opened: new Set(), - full: new Set(), - filter: "", - /** `/` pressed: the text typed so far, until enter or esc. */ - typing: undefined as string | undefined, - /** `tab` into the card: the focused button. */ - button: undefined as number | undefined, - } - /** What was drawn last: actions and clicks act on what is on screen. */ - let shown: { activity?: ActivityView; ledger?: ExplorerView } = {} - const reading = () => ({ state: engine.state, settings, now: Date.now(), history: engine.history }) const project = directory.split(/[\\/]/).filter(Boolean).at(-1) ?? "" - - /** - * Off by default (core/config.ts): the sidebar is crowded, and Trust answers the same without it. - * The palette flips it for the session; config decides where it starts. Not remembered across - * restarts — remembered UI state makes a command look dead (docs/opencode/gotchas.md). - */ - let inSidebar = settings.sidebar - const paint = () => { - drawnAt = sidebarWidth() - /** - * A setting in Trust's section that is not read — an old name, a value of the wrong kind — is - * a `!` row on top, for the session, until the file is fixed. Shown with the block hidden too: - * like trouble, a setting that silently does nothing is what nobody would find otherwise. - */ - const warned: Row[] = notices.flatMap((notice) => warnRows(noticeText(notice), drawnAt)) - /** Hidden, the block says nothing — except trouble: a failure always speaks. */ - const block = - !inSidebar && !trouble - ? [] - : sidebarRows({ - width: drawnAt, - recent: engine.recent(), - count: engine.count(), - pending: engine.pending(), - state: engine.state, - limit: settings.sidebarRows, - shown: inSidebar, - ...(inSidebar ? { project: tally(engine.state, settings, Date.now()) } : {}), - ...(trouble ? { trouble } : {}), - }) - const next = [...warned, ...block] - /** Only when they changed: new rows rebuild every line of the block. */ - const text = JSON.stringify(next) - if (text !== said) { - said = text - setLines(next) - } - if (dialog.open) { - const height = api.renderer.height - const size = { - width: Math.max(40, Math.min(DIALOG_COLUMNS, api.renderer.width - 2)), - height: Math.max(11, height - Math.floor(height / 4) * 2), - project, - ...reading(), - ...(dialog.notice ? { notice: dialog.notice } : {}), - ...(dialog.keys ? { keys: true } : {}), - } - if (dialog.view === "activity") { - const view = activityRows({ - ...size, - ...(dialog.activity !== undefined ? { selected: dialog.activity } : {}), - }) - dialog.activity = view.item?.key - shown = { activity: view } - setDialogRows(view.rows) - } else { - const view = explorerRows({ - ...size, - open: dialog.opened, - full: dialog.full, - filter: dialog.filter, - ...(dialog.node !== undefined ? { selected: dialog.node } : {}), - ...(dialog.button !== undefined ? { focus: { button: dialog.button } } : {}), - ...(dialog.typing !== undefined ? { typing: dialog.typing } : {}), - }) - dialog.node = view.node?.key - if (dialog.button !== undefined && view.buttons.length > 0) - dialog.button = Math.min(dialog.button, view.buttons.length - 1) - else dialog.button = undefined - shown = { ledger: view } - setDialogRows(view.rows) - } - } - api.renderer.requestRender() - } - /** Several changes in one turn are one paint (Shell's painter). */ - let scheduled = false - const draw = () => { - if (scheduled) return - scheduled = true - setTimeout(() => { - scheduled = false - try { - paint() - } catch (error) { - log.error("paint failed", { error }) - } - }, 0) - } + const live: Live = { trouble: undefined, inSidebar: settings.sidebar } + const dialog = closedDialog() + const painter = createPainter({ api, log, engine, settings, notices, project, live, dialog }) + const { draw, paint } = painter // --- the ledger file --------------------------------------------------------------------------- @@ -237,7 +79,7 @@ export function createTrustTui({ source = TRUST_PACKAGE }: { source?: string } = }) .catch((error) => { log.error("ledger unreadable", { file: paths.events, error }) - trouble = "ledger unreadable — see cockpit.log" + live.trouble = "ledger unreadable — see cockpit.log" draw() }) @@ -246,546 +88,49 @@ export function createTrustTui({ source = TRUST_PACKAGE }: { source?: string } = journal .append(events) .then(() => { - if (trouble?.startsWith("ledger not saved")) trouble = undefined + if (live.trouble?.startsWith("ledger not saved")) live.trouble = undefined return sync() }) .catch((error) => { log.error("ledger not saved", { file: paths.events, error }) - trouble = `ledger not saved: ${(error as NodeJS.ErrnoException).code ?? "error"}` - draw() - }) - } - - // --- requests ---------------------------------------------------------------------------------- - - const loadRules = async () => { - try { - opencodeConfig = await feed.config() - rulesReady = true - if (trouble?.startsWith("OpenCode's config")) trouble = undefined - log.debug("rules", { rules: rulesFrom(opencodeConfig).length }) - } catch (error) { - /** Without the rules there is no knowing what you asked to be asked about: Trust stays out. */ - rulesReady = false - log.error("config unreadable", { error }) - trouble = "OpenCode's config unreadable — not answering" - } - draw() - } - - /** - * Bash requests whose call has not said its command line yet, by call id. Measured on 1.18.32: the - * call's `running` update — the one carrying `command` — arrived *after* `permission.asked` for - * three requests in four (the first call of a turn was the exception). So a request without its - * line waits for it, briefly; one that never gets it is decided without, which means asked. - */ - const parked = new Map }>() - - const lineOf = (request: Request) => - request.call ? (calls.get(request.call) ?? feed.call(request)) : undefined - - const asked = (request: Request, at: number) => { - /** - * Decided as soon as the request can be read, never after a file read: OpenCode's `--auto` - * answers in 15–22ms, and a decision that waited on the disk could answer a request already gone. - */ - if (!rulesReady) return - const call = lineOf(request) - if ( - request.permission === "bash" && - call?.line === undefined && - request.call && - !parked.has(request.call) - ) { - const key = request.call - parked.set(key, { request, at, timer: setTimeout(() => unpark(key), PARK_MS) }) - return - } - decideNow(request, at, call) - } - - /** The call's line arrived, a reply came first, or the wait ran out: decide with what is known. */ - const unpark = (key: string) => { - const waiting = parked.get(key) - if (!waiting) return - parked.delete(key) - clearTimeout(waiting.timer) - if (!lineOf(waiting.request)) log.debug("no command line", { request: waiting.request.id, call: key }) - decideNow(waiting.request, waiting.at, lineOf(waiting.request)) - } - - const decideNow = (request: Request, at: number, call: ReturnType) => { - const agent = - agents.get(request.sessionID) ?? feed.agent(request.sessionID, request.messageID) ?? "unknown" - const { judgement, event } = engine.ask({ - request, - context: { ...call, root: directory }, - agent, - rules: rulesFrom(opencodeConfig, agent), - at, - }) - log.debug("asked", { request: request.id, permission: request.permission, agent, why: judgement.why }) - write([event]) - draw() - if (!judgement.answer) return - feed - .approve(request) - .then(() => { - const auto = engine.answered(request.id, Date.now()) - if (auto) write([auto]) - log.info("auto", { - request: request.id, - session: request.sessionID, - call: request.call, - permission: request.permission, - agent, - subjects: judgement.items.map((item) => item.subject), - why: judgement.why, - ms: Date.now() - at, - }) - if (trouble?.startsWith("an answer failed")) trouble = undefined - draw() - }) - .catch((error) => { - /** The prompt is still there and yours: your answer to it counts as any other. */ - engine.failed(request.id) - log.warn("auto reply failed", { request: request.id, error }) - trouble = `an answer failed: ${error instanceof Error ? error.message : String(error)}` + live.trouble = `ledger not saved: ${(error as NodeJS.ErrnoException).code ?? "error"}` draw() }) } - const feed = createSource(api, log, (seen: Seen[]) => { - const at = Date.now() - for (const each of seen) { - switch (each.type) { - case "call": { - calls.set(each.call, commandOf(each.input)) - if (calls.size > CALLS_MAX) calls.delete(calls.keys().next().value as string) - if (parked.has(each.call) && calls.get(each.call)?.line !== undefined) unpark(each.call) - break - } - case "agent": - agents.set(each.sessionID, each.agent) - break - case "config": - void loadRules() - break - case "asked": - asked(each.request, at) - break - case "replied": { - /** Answered while it waited for its line: decided first, so the answer counts against it. */ - for (const [key, waiting] of parked) if (waiting.request.id === each.requestID) unpark(key) - const { events, credit } = engine.replied({ requestID: each.requestID, reply: each.reply, at }) - if (credit.kind === "ignored") - log.debug("reply not counted", { request: each.requestID, why: credit.why }) - write(events) - draw() - break - } - } - } + const requests = createRequests({ api, log, engine, directory, live, write, draw }) + const ledger = createLedger({ + api, + log, + engine, + directory, + painter, + dialog, + write, + loadRules: requests.loadRules, }) - /** Requests already waiting when Trust started, and any the events never told us were answered. */ - const reconcile = async (adopt: boolean) => { - const listed = await feed.pending().catch((error) => { - log.debug("pending list failed", { error }) - return undefined - }) - if (!listed) return - const ids = new Set() - for (const each of listed) { - if (each.type !== "asked") continue - ids.add(each.request.id) - /** Its asking was not seen, so "now" stands in for it: a person answers later still. */ - if (adopt) asked(each.request, Date.now()) - } - engine.reconcile(ids, Date.now()) - draw() - } - const boot = async () => { await sync() - await loadRules() - await reconcile(true) + await requests.loadRules() + await requests.reconcile(true) log.info("ready", { ledger: paths.events, threshold: settings.threshold, dangerExtra: settings.dangerExtra, expireDays: settings.expireDays, - rules: rulesReady, + rules: requests.rulesReady(), }) } void boot() let ticking: ReturnType | undefined = setInterval(() => { void sync() - if (engine.pending().length > 0) void reconcile(false) + if (engine.pending().length > 0) void requests.reconcile(false) /** The sidebar was laid out, or resized, since the rows were drawn. */ - if (sidebarWidth() !== drawnAt) draw() + if (painter.resized()) draw() }, SYNC_MS) - // --- the ledger dialog ------------------------------------------------------------------------- - - const notice = (text: string, tone: Tone = "muted") => { - dialog.notice = { text, tone } - draw() - } - - /** What `x`, `w` and `c` act on: the cursor's item or node, as last drawn. */ - const selected = (): { target: Target; families: readonly Family[] } | undefined => { - if (dialog.view === "activity") { - const view = shown.activity - if (!view?.item) return undefined - return { target: targetOf(view.item, view.model), families: view.model.families } - } - const view = shown.ledger - if (!view?.node) return undefined - return { target: nodeTarget(view.node), families: view.model.families } - } - - /** What `w` and `x` decided, appended and said. Nothing is ever rewritten in the ledger. */ - const act = (name: string, outcome: Outcome) => { - write(outcome.events) - if (outcome.events.length > 0) - log.info(name, { - events: outcome.events.map((event) => - event.type === "revoked" - ? { type: event.type, agent: event.agent, subject: event.subject } - : event.type === "widened" || event.type === "unwidened" - ? { type: event.type, agent: event.agent, family: event.family } - : { type: event.type }, - ), - }) - notice(outcome.notice.text, outcome.notice.tone) - } - - const revokeSelected = () => { - const now = selected() - if (now) act("revoked", revoke(now.target, reading(), Date.now())) - } - - const widenSelected = () => { - const now = selected() - if (now) act("widen", widen(widenScope(now.target, now.families), now.families, Date.now())) - } - - const copy = () => { - const now = selected() - if (!now) return - const snippet = configSnippet(now.target) - const ok = api.renderer.copyToClipboardOSC52?.(snippet.text) ?? false - notice( - ok - ? `Copied ${snippet.text} — paste it into opencode.json${snippet.note ? `; ${snippet.note}` : ""}.` - : `This terminal refused the clipboard. The rule: ${snippet.text}`, - ok ? "success" : "warning", - ) - } - - const togglePause = () => { - const paused = !engine.state.paused - write([{ v: 1, at: Date.now(), type: paused ? "paused" : "resumed" }]) - log.info(paused ? "paused" : "resumed", { directory }) - if (dialog.open) - notice(paused ? "Paused in this project: Trust keeps counting, and answers nothing." : "Resumed.") - else - api.ui.toast({ - variant: "info", - title: "Trust", - message: paused ? "Paused in this project." : "Answering again in this project.", - }) - } - - /** Every key but `?` acts on the screen: from the key list it goes back to the screen first. */ - const listed = - (run: () => void): (() => void) => - () => { - dialog.keys = false - run() - draw() - } - - const move = (by: number) => { - dialog.notice = undefined - if (dialog.view === "activity") { - const items = shown.activity?.model.items ?? [] - const at = Math.max( - 0, - items.findIndex((item) => item.key === dialog.activity), - ) - dialog.activity = items[Math.max(0, Math.min(items.length - 1, at + by))]?.key - } else { - const nodes = shown.ledger?.model.nodes ?? [] - const at = Math.max( - 0, - nodes.findIndex((node) => node.key === dialog.node), - ) - dialog.node = nodes[Math.max(0, Math.min(nodes.length - 1, at + by))]?.key - dialog.button = undefined - } - draw() - } - - /** Into the ledger, on `key` when given; the activity keeps its cursor for the way back. */ - const toLedger = (key?: string) => { - dialog.view = "ledger" - dialog.button = undefined - dialog.notice = undefined - if (key !== undefined) dialog.node = key - draw() - } - - /** `enter` on the activity: why — the card of the rule that answered, or that is close, in the ledger. */ - const why = () => { - const view = shown.activity - const item = view?.item - if (!view || !item) return toLedger() - if (item.kind === "always") return toLedger(ALWAYS_KEY) - const subject = - item.kind === "answer" - ? { permission: item.answer.permission, subject: item.answer.items[0]?.subject ?? "" } - : { permission: item.command.permission, subject: item.command.subject } - toLedger(reveal({ open: dialog.opened, full: dialog.full }, view.model.families, subject)) - } - - const node = (): Node | undefined => shown.ledger?.node - - /** - * `space`/`enter` on a heading opens or folds it; on `+ N more` it lists the rest; on a command in a - * family it folds the family, the cursor going to its heading. `→` only opens, `←` only folds. - */ - const fold = (way: "toggle" | "open" | "close" = "toggle") => { - const at = node() - if (!at || at.kind === "always") return - dialog.notice = undefined - const key = at.family.key - if (at.kind === "more") { - if (way !== "close") dialog.full.add(key) - else { - dialog.opened.delete(key) - dialog.node = `f:${key}` - } - } else if (at.kind === "command") { - if (!at.nested || way === "open") return - dialog.opened.delete(key) - dialog.full.delete(key) - dialog.node = `f:${key}` - } else if (dialog.opened.has(key) && way !== "open") { - dialog.opened.delete(key) - dialog.full.delete(key) - } else if (!dialog.opened.has(key) && way !== "close") dialog.opened.add(key) - draw() - } - - /** The card's buttons: `tab` in and out, `←`/`→` between them, `enter` presses the focused one. */ - const buttons = () => shown.ledger?.buttons ?? [] - const press = (action: string) => { - if (action === "revoke") revokeSelected() - else if (action === "widen") widenSelected() - else if (action === "copy") copy() - else if (action === "ledger") toLedger() - } - - const enter = () => { - if (dialog.view === "activity") return why() - if (dialog.button !== undefined) { - const button = buttons()[dialog.button] - if (button && !button.off) press(button.action) - return - } - const at = node() - if (at?.kind === "command" || at?.kind === "always") { - if (buttons().length > 0) dialog.button = 0 - return draw() - } - fold() - } - - const sideways = (by: 1 | -1) => { - if (dialog.view !== "ledger") return - if (dialog.button !== undefined) { - const count = buttons().length - dialog.button = count > 0 ? (dialog.button + by + count) % count : undefined - return draw() - } - fold(by > 0 ? "open" : "close") - } - - const tab = () => { - if (dialog.view !== "ledger") return - dialog.button = dialog.button === undefined && buttons().length > 0 ? 0 : undefined - draw() - } - - const filter = () => { - if (dialog.view === "activity") dialog.view = "ledger" - dialog.typing = dialog.filter - dialog.button = undefined - draw() - } - - /** A click: a button presses, a row takes the cursor. */ - const click = (x: number, y: number) => { - const hits: readonly Hit[] = - (dialog.view === "activity" ? shown.activity?.hits : shown.ledger?.hits) ?? [] - const on = hits.find( - (hit) => hit.y === y && (hit.x0 === undefined || x >= hit.x0) && (hit.x1 === undefined || x < hit.x1), - ) - if (!on) return - dialog.keys = false - if (on.kind === "button") { - dialog.notice = undefined - return press(on.action) - } - dialog.notice = undefined - if (dialog.view === "activity") dialog.activity = on.key - else { - dialog.node = on.key - dialog.button = undefined - } - draw() - } - - const dialogLayer = (): Layer => ({ - priority: 100, - commands: [ - { name: "cockpit.trust.down", title: "Next", run: listed(() => move(1)) }, - { name: "cockpit.trust.up", title: "Previous", run: listed(() => move(-1)) }, - { name: "cockpit.trust.enter", title: "Why, open, or press", run: listed(() => enter()) }, - { name: "cockpit.trust.fold", title: "Open or fold a family", run: listed(() => fold()) }, - { - name: "cockpit.trust.right", - title: "Open a family, or the next button", - run: listed(() => sideways(1)), - }, - { - name: "cockpit.trust.left", - title: "Fold a family, or the previous button", - run: listed(() => sideways(-1)), - }, - { - name: "cockpit.trust.l", - title: "Open the ledger, or a family in it", - run: listed(() => (dialog.view === "activity" ? toLedger() : sideways(1))), - }, - { name: "cockpit.trust.tab", title: "Into the card and back", run: listed(() => tab()) }, - { name: "cockpit.trust.filter", title: "Filter the ledger", run: listed(() => filter()) }, - { - name: "cockpit.trust.keys", - title: "Show or hide every key", - run: () => { - dialog.keys = !dialog.keys - dialog.notice = undefined - draw() - }, - }, - { - name: "cockpit.trust.revoke", - title: "Revoke, or forget a count", - run: listed(() => revokeSelected()), - }, - { - name: "cockpit.trust.widen", - title: "Trust the whole family, or undo it", - run: listed(() => widenSelected()), - }, - { name: "cockpit.trust.copy", title: "Copy as config", run: listed(() => copy()) }, - { name: "cockpit.trust.togglePause", title: "Pause or resume", run: listed(() => togglePause()) }, - { name: "cockpit.trust.close", title: "Close", run: () => api.ui.dialog.clear() }, - ], - bindings: [ - { key: "j,down", cmd: "cockpit.trust.down" }, - { key: "k,up", cmd: "cockpit.trust.up" }, - { key: "return", cmd: "cockpit.trust.enter" }, - { key: "space", cmd: "cockpit.trust.fold" }, - { key: "right", cmd: "cockpit.trust.right" }, - { key: "h,left", cmd: "cockpit.trust.left" }, - { key: "l", cmd: "cockpit.trust.l" }, - { key: "tab", cmd: "cockpit.trust.tab" }, - { key: "/", cmd: "cockpit.trust.filter" }, - { key: "?,shift+/", cmd: "cockpit.trust.keys" }, - { key: "x", cmd: "cockpit.trust.revoke" }, - { key: "w", cmd: "cockpit.trust.widen" }, - { key: "c", cmd: "cockpit.trust.copy" }, - { key: "p", cmd: "cockpit.trust.togglePause" }, - { key: "q", cmd: "cockpit.trust.close" }, - ], - }) - - /** - * Ahead of the keymap, because the host's dialog takes `esc` before any layer hears it (Shell's - * search does the same): the filter being typed gets every key; and `esc` steps back one thing at - * a time — the key list, the card's buttons, the filter, the ledger — before the host closes the - * dialog from the activity, as it always has. - */ - api.lifecycle.onDispose( - api.keymap.intercept( - (ctx) => { - if (!dialog.open) return - const event = ctx.event - if (dialog.typing !== undefined) { - ctx.consume({ preventDefault: true, stopPropagation: true }) - if (event.name === "escape") dialog.typing = undefined - else if (event.name === "return" || event.name === "enter") { - dialog.filter = dialog.typing.trim() - dialog.typing = undefined - dialog.node = undefined - } else if (event.name === "backspace") dialog.typing = dialog.typing.slice(0, -1) - else if (event.sequence && !event.ctrl && !event.meta && event.sequence >= " ") - dialog.typing += event.sequence - return draw() - } - if (event.name !== "escape") return - if (dialog.keys) dialog.keys = false - else if (dialog.view === "activity") return - else if (dialog.button !== undefined) dialog.button = undefined - else if (dialog.filter !== "") { - dialog.filter = "" - dialog.node = undefined - } else dialog.view = "activity" - ctx.consume({ preventDefault: true, stopPropagation: true }) - dialog.notice = undefined - draw() - }, - { priority: 10_000 }, - ), - ) - - const openLedger = () => { - /** Config may have changed since: what the dialog says about "ask" rules should be today's. */ - void loadRules() - Object.assign(dialog, { - open: true, - view: "activity", - keys: false, - notice: undefined, - activity: undefined, - typing: undefined, - button: undefined, - }) - paint() - api.ui.dialog.replace( - () => ( - listed(() => move(by))()} - onClick={(x, y) => click(x, y)} - /> - ), - () => { - dialog.open = false - dialog.typing = undefined - }, - ) - api.ui.dialog.setSize("xlarge") - log.debug("trust: open", { items: shown.activity?.model.items.length ?? 0 }) - } - api.keymap.registerLayer({ commands: [ { @@ -794,7 +139,7 @@ export function createTrustTui({ source = TRUST_PACKAGE }: { source?: string } = category: "Cockpit · Trust", namespace: "palette", slashName: "trust", - run: () => openLedger(), + run: () => ledger.open(), }, { name: "cockpit.trust.sidebar", @@ -803,14 +148,14 @@ export function createTrustTui({ source = TRUST_PACKAGE }: { source?: string } = category: "Cockpit · Trust", namespace: "palette", run: () => { - inSidebar = !inSidebar - log.debug("sidebar", { shown: inSidebar }) + live.inSidebar = !live.inSidebar + log.debug("sidebar", { shown: live.inSidebar }) paint() /** Said as well as drawn: on the home screen there is no sidebar to show it in. */ api.ui.toast({ variant: "info", title: "Trust", - message: inSidebar ? "Shown in the sidebar." : "Hidden from the sidebar.", + message: live.inSidebar ? "Shown in the sidebar." : "Hidden from the sidebar.", }) }, }, @@ -819,7 +164,7 @@ export function createTrustTui({ source = TRUST_PACKAGE }: { source?: string } = title: "Pause or resume Trust in this project", category: "Cockpit · Trust", namespace: "palette", - run: () => togglePause(), + run: () => ledger.togglePause(), }, ], bindings: keys.gather("cockpit", Object.keys(DEFAULT_KEYS)), @@ -832,9 +177,9 @@ export function createTrustTui({ source = TRUST_PACKAGE }: { source?: string } = sidebar_content: () => ( { - block = box + painter.setBlock(box) draw() }} /> @@ -843,11 +188,9 @@ export function createTrustTui({ source = TRUST_PACKAGE }: { source?: string } = }) api.lifecycle.onDispose(() => { - for (const waiting of parked.values()) clearTimeout(waiting.timer) - parked.clear() + requests.dispose() clearInterval(ticking) ticking = undefined - feed.dispose() }) } } diff --git a/packages/trust/src/tui/ledger.tsx b/packages/trust/src/tui/ledger.tsx new file mode 100644 index 00000000..85507cd2 --- /dev/null +++ b/packages/trust/src/tui/ledger.tsx @@ -0,0 +1,400 @@ +/** @jsxImportSource @opentui/solid */ + +/** + * `/trust`: the activity it opens on and the ledger behind `l` — the keys, the clicks, the filter + * typed with `/`, and what `x`, `w`, `c` and `p` do to the ledger. Painted by `paint.ts`, from the + * `DialogState` this changes. + */ + +import type { Host, Layer } from "@opencode-cockpit/client/host" +import type { Log } from "@opencode-cockpit/client/log" +import type { Engine } from "../core/engine.ts" +import type { Event } from "../core/ledger.ts" +import { configSnippet, type Outcome, revoke, type Target, widen, widenScope } from "../core/view/actions.ts" +import { targetOf } from "../core/view/activity.ts" +import type { Family } from "../core/view/model.ts" +import type { Hit } from "../core/view/parts.ts" +import type { Tone } from "../core/view/rows.ts" +import { ALWAYS_KEY, type Node, nodeTarget, reveal } from "../core/view/tree.ts" +import type { DialogState, Painter } from "./paint.ts" +import { Dialog } from "./view/dialog.tsx" + +export interface Ledger { + /** Opens `/trust` on the activity. */ + open(): void + /** Pauses Trust in this project, or resumes it: said in the dialog when it is open, else a toast. */ + togglePause(): void +} + +export function createLedger(input: { + api: Host + log: Log + engine: Engine + directory: string + painter: Painter + dialog: DialogState + write: (events: readonly Event[]) => void + loadRules: () => Promise +}): Ledger { + const { api, log, engine, directory, painter, dialog, write, loadRules } = input + const draw = painter.draw + + const notice = (text: string, tone: Tone = "muted") => { + dialog.notice = { text, tone } + draw() + } + + /** What `x`, `w` and `c` act on: the cursor's item or node, as last drawn. */ + const selected = (): { target: Target; families: readonly Family[] } | undefined => { + if (dialog.view === "activity") { + const view = painter.shown().activity + if (!view?.item) return undefined + return { target: targetOf(view.item, view.model), families: view.model.families } + } + const view = painter.shown().ledger + if (!view?.node) return undefined + return { target: nodeTarget(view.node), families: view.model.families } + } + + /** What `w` and `x` decided, appended and said. Nothing is ever rewritten in the ledger. */ + const act = (name: string, outcome: Outcome) => { + write(outcome.events) + if (outcome.events.length > 0) + log.info(name, { + events: outcome.events.map((event) => + event.type === "revoked" + ? { type: event.type, agent: event.agent, subject: event.subject } + : event.type === "widened" || event.type === "unwidened" + ? { type: event.type, agent: event.agent, family: event.family } + : { type: event.type }, + ), + }) + notice(outcome.notice.text, outcome.notice.tone) + } + + const revokeSelected = () => { + const now = selected() + if (now) act("revoked", revoke(now.target, painter.reading(), Date.now())) + } + + const widenSelected = () => { + const now = selected() + if (now) act("widen", widen(widenScope(now.target, now.families), now.families, Date.now())) + } + + const copy = () => { + const now = selected() + if (!now) return + const snippet = configSnippet(now.target) + const ok = api.renderer.copyToClipboardOSC52?.(snippet.text) ?? false + notice( + ok + ? `Copied ${snippet.text} — paste it into opencode.json${snippet.note ? `; ${snippet.note}` : ""}.` + : `This terminal refused the clipboard. The rule: ${snippet.text}`, + ok ? "success" : "warning", + ) + } + + /** Every key but `?` acts on the screen: from the key list it goes back to the screen first. */ + const listed = + (run: () => void): (() => void) => + () => { + dialog.keys = false + run() + draw() + } + + const move = (by: number) => { + dialog.notice = undefined + if (dialog.view === "activity") { + const items = painter.shown().activity?.model.items ?? [] + const at = Math.max( + 0, + items.findIndex((item) => item.key === dialog.activity), + ) + dialog.activity = items[Math.max(0, Math.min(items.length - 1, at + by))]?.key + } else { + const nodes = painter.shown().ledger?.model.nodes ?? [] + const at = Math.max( + 0, + nodes.findIndex((node) => node.key === dialog.node), + ) + dialog.node = nodes[Math.max(0, Math.min(nodes.length - 1, at + by))]?.key + dialog.button = undefined + } + draw() + } + + /** Into the ledger, on `key` when given; the activity keeps its cursor for the way back. */ + const toLedger = (key?: string) => { + dialog.view = "ledger" + dialog.button = undefined + dialog.notice = undefined + if (key !== undefined) dialog.node = key + draw() + } + + /** `enter` on the activity: why — the card of the rule that answered, or that is close, in the ledger. */ + const why = () => { + const view = painter.shown().activity + const item = view?.item + if (!view || !item) return toLedger() + if (item.kind === "always") return toLedger(ALWAYS_KEY) + const subject = + item.kind === "answer" + ? { permission: item.answer.permission, subject: item.answer.items[0]?.subject ?? "" } + : { permission: item.command.permission, subject: item.command.subject } + toLedger(reveal({ open: dialog.opened, full: dialog.full }, view.model.families, subject)) + } + + const node = (): Node | undefined => painter.shown().ledger?.node + + /** + * `space`/`enter` on a heading opens or folds it; on `+ N more` it lists the rest; on a command in a + * family it folds the family, the cursor going to its heading. `→` only opens, `←` only folds. + */ + const fold = (way: "toggle" | "open" | "close" = "toggle") => { + const at = node() + if (!at || at.kind === "always") return + dialog.notice = undefined + const key = at.family.key + if (at.kind === "more") { + if (way !== "close") dialog.full.add(key) + else { + dialog.opened.delete(key) + dialog.node = `f:${key}` + } + } else if (at.kind === "command") { + if (!at.nested || way === "open") return + dialog.opened.delete(key) + dialog.full.delete(key) + dialog.node = `f:${key}` + } else if (dialog.opened.has(key) && way !== "open") { + dialog.opened.delete(key) + dialog.full.delete(key) + } else if (!dialog.opened.has(key) && way !== "close") dialog.opened.add(key) + draw() + } + + /** The card's buttons: `tab` in and out, `←`/`→` between them, `enter` presses the focused one. */ + const buttons = () => painter.shown().ledger?.buttons ?? [] + const press = (action: string) => { + if (action === "revoke") revokeSelected() + else if (action === "widen") widenSelected() + else if (action === "copy") copy() + else if (action === "ledger") toLedger() + } + + const enter = () => { + if (dialog.view === "activity") return why() + if (dialog.button !== undefined) { + const button = buttons()[dialog.button] + if (button && !button.off) press(button.action) + return + } + const at = node() + if (at?.kind === "command" || at?.kind === "always") { + if (buttons().length > 0) dialog.button = 0 + return draw() + } + fold() + } + + const sideways = (by: 1 | -1) => { + if (dialog.view !== "ledger") return + if (dialog.button !== undefined) { + const count = buttons().length + dialog.button = count > 0 ? (dialog.button + by + count) % count : undefined + return draw() + } + fold(by > 0 ? "open" : "close") + } + + const tab = () => { + if (dialog.view !== "ledger") return + dialog.button = dialog.button === undefined && buttons().length > 0 ? 0 : undefined + draw() + } + + const filter = () => { + if (dialog.view === "activity") dialog.view = "ledger" + dialog.typing = dialog.filter + dialog.button = undefined + draw() + } + + /** A click: a button presses, a row takes the cursor. */ + const click = (x: number, y: number) => { + const hits: readonly Hit[] = + (dialog.view === "activity" ? painter.shown().activity?.hits : painter.shown().ledger?.hits) ?? [] + const on = hits.find( + (hit) => hit.y === y && (hit.x0 === undefined || x >= hit.x0) && (hit.x1 === undefined || x < hit.x1), + ) + if (!on) return + dialog.keys = false + if (on.kind === "button") { + dialog.notice = undefined + return press(on.action) + } + dialog.notice = undefined + if (dialog.view === "activity") dialog.activity = on.key + else { + dialog.node = on.key + dialog.button = undefined + } + draw() + } + + const dialogLayer = (): Layer => ({ + priority: 100, + commands: [ + { name: "cockpit.trust.down", title: "Next", run: listed(() => move(1)) }, + { name: "cockpit.trust.up", title: "Previous", run: listed(() => move(-1)) }, + { name: "cockpit.trust.enter", title: "Why, open, or press", run: listed(() => enter()) }, + { name: "cockpit.trust.fold", title: "Open or fold a family", run: listed(() => fold()) }, + { + name: "cockpit.trust.right", + title: "Open a family, or the next button", + run: listed(() => sideways(1)), + }, + { + name: "cockpit.trust.left", + title: "Fold a family, or the previous button", + run: listed(() => sideways(-1)), + }, + { + name: "cockpit.trust.l", + title: "Open the ledger, or a family in it", + run: listed(() => (dialog.view === "activity" ? toLedger() : sideways(1))), + }, + { name: "cockpit.trust.tab", title: "Into the card and back", run: listed(() => tab()) }, + { name: "cockpit.trust.filter", title: "Filter the ledger", run: listed(() => filter()) }, + { + name: "cockpit.trust.keys", + title: "Show or hide every key", + run: () => { + dialog.keys = !dialog.keys + dialog.notice = undefined + draw() + }, + }, + { + name: "cockpit.trust.revoke", + title: "Revoke, or forget a count", + run: listed(() => revokeSelected()), + }, + { + name: "cockpit.trust.widen", + title: "Trust the whole family, or undo it", + run: listed(() => widenSelected()), + }, + { name: "cockpit.trust.copy", title: "Copy as config", run: listed(() => copy()) }, + { name: "cockpit.trust.togglePause", title: "Pause or resume", run: listed(() => togglePause()) }, + { name: "cockpit.trust.close", title: "Close", run: () => api.ui.dialog.clear() }, + ], + bindings: [ + { key: "j,down", cmd: "cockpit.trust.down" }, + { key: "k,up", cmd: "cockpit.trust.up" }, + { key: "return", cmd: "cockpit.trust.enter" }, + { key: "space", cmd: "cockpit.trust.fold" }, + { key: "right", cmd: "cockpit.trust.right" }, + { key: "h,left", cmd: "cockpit.trust.left" }, + { key: "l", cmd: "cockpit.trust.l" }, + { key: "tab", cmd: "cockpit.trust.tab" }, + { key: "/", cmd: "cockpit.trust.filter" }, + { key: "?,shift+/", cmd: "cockpit.trust.keys" }, + { key: "x", cmd: "cockpit.trust.revoke" }, + { key: "w", cmd: "cockpit.trust.widen" }, + { key: "c", cmd: "cockpit.trust.copy" }, + { key: "p", cmd: "cockpit.trust.togglePause" }, + { key: "q", cmd: "cockpit.trust.close" }, + ], + }) + + /** + * Ahead of the keymap, because the host's dialog takes `esc` before any layer hears it (Shell's + * search does the same): the filter being typed gets every key; and `esc` steps back one thing at + * a time — the key list, the card's buttons, the filter, the ledger — before the host closes the + * dialog from the activity, as it always has. + */ + api.lifecycle.onDispose( + api.keymap.intercept( + (ctx) => { + if (!dialog.open) return + const event = ctx.event + if (dialog.typing !== undefined) { + ctx.consume({ preventDefault: true, stopPropagation: true }) + if (event.name === "escape") dialog.typing = undefined + else if (event.name === "return" || event.name === "enter") { + dialog.filter = dialog.typing.trim() + dialog.typing = undefined + dialog.node = undefined + } else if (event.name === "backspace") dialog.typing = dialog.typing.slice(0, -1) + else if (event.sequence && !event.ctrl && !event.meta && event.sequence >= " ") + dialog.typing += event.sequence + return draw() + } + if (event.name !== "escape") return + if (dialog.keys) dialog.keys = false + else if (dialog.view === "activity") return + else if (dialog.button !== undefined) dialog.button = undefined + else if (dialog.filter !== "") { + dialog.filter = "" + dialog.node = undefined + } else dialog.view = "activity" + ctx.consume({ preventDefault: true, stopPropagation: true }) + dialog.notice = undefined + draw() + }, + { priority: 10_000 }, + ), + ) + + const openLedger = () => { + /** Config may have changed since: what the dialog says about "ask" rules should be today's. */ + void loadRules() + Object.assign(dialog, { + open: true, + view: "activity", + keys: false, + notice: undefined, + activity: undefined, + typing: undefined, + button: undefined, + }) + painter.paint() + api.ui.dialog.replace( + () => ( + listed(() => move(by))()} + onClick={(x, y) => click(x, y)} + /> + ), + () => { + dialog.open = false + dialog.typing = undefined + }, + ) + api.ui.dialog.setSize("xlarge") + log.debug("trust: open", { items: painter.shown().activity?.model.items.length ?? 0 }) + } + + const togglePause = () => { + const paused = !engine.state.paused + write([{ v: 1, at: Date.now(), type: paused ? "paused" : "resumed" }]) + log.info(paused ? "paused" : "resumed", { directory }) + if (dialog.open) + notice(paused ? "Paused in this project: Trust keeps counting, and answers nothing." : "Resumed.") + else + api.ui.toast({ + variant: "info", + title: "Trust", + message: paused ? "Paused in this project." : "Answering again in this project.", + }) + } + + return { open: openLedger, togglePause } +} diff --git a/packages/trust/src/tui/paint.ts b/packages/trust/src/tui/paint.ts new file mode 100644 index 00000000..490385b4 --- /dev/null +++ b/packages/trust/src/tui/paint.ts @@ -0,0 +1,209 @@ +/** + * Painting: the sidebar block's rows and the dialog's, from the engine as it is now, into the signals + * the views draw. `draw` asks for a paint and folds every change in one turn into it; `paint` paints + * now. + * + * What it paints from lives in `Live` and `DialogState`, which the rest of the interface changes. + */ + +import { warnRows } from "@opencode-cockpit/client/design" +import type { Host } from "@opencode-cockpit/client/host" +import type { Log } from "@opencode-cockpit/client/log" +import { noticeText, type SettingsNotice } from "@opencode-cockpit/client/settings" +import { blockWidth } from "@opencode-cockpit/client/sidebar" +import type { BoxRenderable } from "@opentui/core" +import { type Accessor, createSignal } from "solid-js" +import type { TrustSettings } from "../core/config.ts" +import type { Engine } from "../core/engine.ts" +import { type ActivityView, activityRows } from "../core/view/activity.ts" +import { type ExplorerView, explorerRows } from "../core/view/explorer.ts" +import type { Row, Tone } from "../core/view/rows.ts" +import { sidebarRows, tally } from "../core/view/sidebar.ts" + +/** The host's dialog: as wide as xlarge allows (Shell's console measured it). */ +const DIALOG_COLUMNS = 116 + +/** What the interface knows now, shared by everything that reads or changes it. */ +export interface Live { + /** Something you should know about. The sidebar says it whatever else it has to say. */ + trouble: string | undefined + /** + * Off by default (core/config.ts): the sidebar is crowded, and Trust answers the same without it. + * The palette flips it for the session; config decides where it starts. Not remembered across + * restarts — remembered UI state makes a command look dead (docs/opencode/gotchas.md). + */ + inSidebar: boolean +} + +/** + * The dialog: the activity `/trust` opens on, and the ledger behind `l`. Each keeps its own cursor + * by key, so a rule revoked or a family folded moves the rows about, not the cursor. + */ +export interface DialogState { + open: boolean + view: "activity" | "ledger" + /** `?`: every key, in the body's place. */ + keys: boolean + notice: { text: string; tone: Tone } | undefined + activity: string | undefined + node: string | undefined + /** Families opened, and those whose tail is listed too; every family starts folded. */ + opened: Set + full: Set + filter: string + /** `/` pressed: the text typed so far, until enter or esc. */ + typing: string | undefined + /** `tab` into the card: the focused button. */ + button: number | undefined +} + +export const closedDialog = (): DialogState => ({ + open: false, + view: "activity", + keys: false, + notice: undefined, + activity: undefined, + node: undefined, + opened: new Set(), + full: new Set(), + filter: "", + typing: undefined, + button: undefined, +}) + +/** What was drawn last: actions and clicks act on what is on screen. */ +export interface Shown { + activity?: ActivityView + ledger?: ExplorerView +} + +export interface Painter { + readonly sidebarLines: Accessor + readonly dialogRows: Accessor + /** The block the host laid out: its width is what the rows are cut to. */ + setBlock(box: BoxRenderable): void + /** The sidebar was laid out, or resized, since the rows were drawn. */ + resized(): boolean + paint(): void + draw(): void + shown(): Shown + /** What the views read the ledger with: the state, the settings, the time, the history. */ + reading(): { state: Engine["state"]; settings: TrustSettings; now: number; history: Engine["history"] } +} + +export function createPainter(input: { + api: Host + log: Log + engine: Engine + settings: TrustSettings + notices: readonly SettingsNotice[] + project: string + live: Live + dialog: DialogState +}): Painter { + const { api, log, engine, settings, notices, project, live, dialog } = input + const [sidebarLines, setLines] = createSignal([]) + const [dialogRows, setDialogRows] = createSignal([]) + let block: BoxRenderable | undefined + let drawnAt = 0 + let said = "" + let shown: Shown = {} + const sidebarWidth = () => blockWidth(block, api.renderer.width) + const reading = () => ({ state: engine.state, settings, now: Date.now(), history: engine.history }) + + const paint = () => { + drawnAt = sidebarWidth() + /** + * A setting in Trust's section that is not read — a value of the wrong kind — is + * a `!` row on top, for the session, until the file is fixed. Shown with the block hidden too: + * like trouble, a setting that silently does nothing is what nobody would find otherwise. + */ + const warned: Row[] = notices.flatMap((notice) => warnRows(noticeText(notice), drawnAt)) + /** Hidden, the block says nothing — except trouble: a failure always speaks. */ + const rows = + !live.inSidebar && !live.trouble + ? [] + : sidebarRows({ + width: drawnAt, + recent: engine.recent(), + count: engine.count(), + pending: engine.pending(), + state: engine.state, + limit: settings.sidebarRows, + shown: live.inSidebar, + ...(live.inSidebar ? { project: tally(engine.state, settings, Date.now()) } : {}), + ...(live.trouble ? { trouble: live.trouble } : {}), + }) + const next = [...warned, ...rows] + /** Only when they changed: new rows rebuild every line of the block. */ + const text = JSON.stringify(next) + if (text !== said) { + said = text + setLines(next) + } + if (dialog.open) { + const height = api.renderer.height + const size = { + width: Math.max(40, Math.min(DIALOG_COLUMNS, api.renderer.width - 2)), + height: Math.max(11, height - Math.floor(height / 4) * 2), + project, + ...reading(), + ...(dialog.notice ? { notice: dialog.notice } : {}), + ...(dialog.keys ? { keys: true } : {}), + } + if (dialog.view === "activity") { + const view = activityRows({ + ...size, + ...(dialog.activity !== undefined ? { selected: dialog.activity } : {}), + }) + dialog.activity = view.item?.key + shown = { activity: view } + setDialogRows(view.rows) + } else { + const view = explorerRows({ + ...size, + open: dialog.opened, + full: dialog.full, + filter: dialog.filter, + ...(dialog.node !== undefined ? { selected: dialog.node } : {}), + ...(dialog.button !== undefined ? { focus: { button: dialog.button } } : {}), + ...(dialog.typing !== undefined ? { typing: dialog.typing } : {}), + }) + dialog.node = view.node?.key + if (dialog.button !== undefined && view.buttons.length > 0) + dialog.button = Math.min(dialog.button, view.buttons.length - 1) + else dialog.button = undefined + shown = { ledger: view } + setDialogRows(view.rows) + } + } + api.renderer.requestRender() + } + /** Several changes in one turn are one paint (Shell's painter). */ + let scheduled = false + const draw = () => { + if (scheduled) return + scheduled = true + setTimeout(() => { + scheduled = false + try { + paint() + } catch (error) { + log.error("paint failed", { error }) + } + }, 0) + } + + return { + sidebarLines, + dialogRows, + setBlock: (box) => { + block = box + }, + resized: () => sidebarWidth() !== drawnAt, + paint, + draw, + shown: () => shown, + reading, + } +} diff --git a/packages/trust/src/tui/requests.ts b/packages/trust/src/tui/requests.ts new file mode 100644 index 00000000..99fc671c --- /dev/null +++ b/packages/trust/src/tui/requests.ts @@ -0,0 +1,206 @@ +/** + * Requests: what OpenCode asks, decided and answered. A request is read as soon as it is seen, decided + * by the engine against your rules, written to the ledger, and — when Trust answers — replied "once". + * A reply you give is counted as one. `reconcile` picks up requests whose asking or answer was missed. + */ + +import type { Host } from "@opencode-cockpit/client/host" +import type { Log } from "@opencode-cockpit/client/log" +import { commandOf, type Seen } from "../core/adapt/seen.ts" +import type { Engine } from "../core/engine.ts" +import type { Request } from "../core/keys.ts" +import type { Event } from "../core/ledger.ts" +import { rulesFrom } from "../core/rules.ts" +import type { Live } from "./paint.ts" +import { createSource } from "./source.ts" + +/** How long a bash request waits for its call's command line before it is decided without one. */ +const PARK_MS = 1_000 +/** Calls remembered for their command line: far more than can be waiting at once. */ +const CALLS_MAX = 500 + +export interface Requests { + /** OpenCode's config, read again: the rules Trust decides with. */ + loadRules(): Promise + /** Whether the rules could be read; without them Trust answers nothing. */ + rulesReady(): boolean + /** Requests already waiting, and any the events never said were answered; `adopt` decides them. */ + reconcile(adopt: boolean): Promise + dispose(): void +} + +export function createRequests(input: { + api: Host + log: Log + engine: Engine + directory: string + live: Live + write: (events: readonly Event[]) => void + draw: () => void +}): Requests { + const { api, log, engine, directory, live, write, draw } = input + /** OpenCode's config as its `config.get` returned it; undefined until read, and Trust stays out. */ + let opencodeConfig: unknown + let rulesReady = false + const calls = new Map() + const agents = new Map() + + const loadRules = async () => { + try { + opencodeConfig = await feed.config() + rulesReady = true + if (live.trouble?.startsWith("OpenCode's config")) live.trouble = undefined + log.debug("rules", { rules: rulesFrom(opencodeConfig).length }) + } catch (error) { + /** Without the rules there is no knowing what you asked to be asked about: Trust stays out. */ + rulesReady = false + log.error("config unreadable", { error }) + live.trouble = "OpenCode's config unreadable — not answering" + } + draw() + } + + /** + * Bash requests whose call has not said its command line yet, by call id. Measured on 1.18.32: the + * call's `running` update — the one carrying `command` — arrived *after* `permission.asked` for + * three requests in four (the first call of a turn was the exception). So a request without its + * line waits for it, briefly; one that never gets it is decided without, which means asked. + */ + const parked = new Map }>() + + const lineOf = (request: Request) => + request.call ? (calls.get(request.call) ?? feed.call(request)) : undefined + + const asked = (request: Request, at: number) => { + /** + * Decided as soon as the request can be read, never after a file read: OpenCode's `--auto` + * answers in 15–22ms, and a decision that waited on the disk could answer a request already gone. + */ + if (!rulesReady) return + const call = lineOf(request) + if ( + request.permission === "bash" && + call?.line === undefined && + request.call && + !parked.has(request.call) + ) { + const key = request.call + parked.set(key, { request, at, timer: setTimeout(() => unpark(key), PARK_MS) }) + return + } + decideNow(request, at, call) + } + + /** The call's line arrived, a reply came first, or the wait ran out: decide with what is known. */ + const unpark = (key: string) => { + const waiting = parked.get(key) + if (!waiting) return + parked.delete(key) + clearTimeout(waiting.timer) + if (!lineOf(waiting.request)) log.debug("no command line", { request: waiting.request.id, call: key }) + decideNow(waiting.request, waiting.at, lineOf(waiting.request)) + } + + const decideNow = (request: Request, at: number, call: ReturnType) => { + const agent = + agents.get(request.sessionID) ?? feed.agent(request.sessionID, request.messageID) ?? "unknown" + const { judgement, event } = engine.ask({ + request, + context: { ...call, root: directory }, + agent, + rules: rulesFrom(opencodeConfig, agent), + at, + }) + log.debug("asked", { request: request.id, permission: request.permission, agent, why: judgement.why }) + write([event]) + draw() + if (!judgement.answer) return + feed + .approve(request) + .then(() => { + const auto = engine.answered(request.id, Date.now()) + if (auto) write([auto]) + log.info("auto", { + request: request.id, + session: request.sessionID, + call: request.call, + permission: request.permission, + agent, + subjects: judgement.items.map((item) => item.subject), + why: judgement.why, + ms: Date.now() - at, + }) + if (live.trouble?.startsWith("an answer failed")) live.trouble = undefined + draw() + }) + .catch((error) => { + /** The prompt is still there and yours: your answer to it counts as any other. */ + engine.failed(request.id) + log.warn("auto reply failed", { request: request.id, error }) + live.trouble = `an answer failed: ${error instanceof Error ? error.message : String(error)}` + draw() + }) + } + + const feed = createSource(api, log, (seen: Seen[]) => { + const at = Date.now() + for (const each of seen) { + switch (each.type) { + case "call": { + calls.set(each.call, commandOf(each.input)) + if (calls.size > CALLS_MAX) calls.delete(calls.keys().next().value as string) + if (parked.has(each.call) && calls.get(each.call)?.line !== undefined) unpark(each.call) + break + } + case "agent": + agents.set(each.sessionID, each.agent) + break + case "config": + void loadRules() + break + case "asked": + asked(each.request, at) + break + case "replied": { + /** Answered while it waited for its line: decided first, so the answer counts against it. */ + for (const [key, waiting] of parked) if (waiting.request.id === each.requestID) unpark(key) + const { events, credit } = engine.replied({ requestID: each.requestID, reply: each.reply, at }) + if (credit.kind === "ignored") + log.debug("reply not counted", { request: each.requestID, why: credit.why }) + write(events) + draw() + break + } + } + } + }) + + /** Requests already waiting when Trust started, and any the events never told us were answered. */ + const reconcile = async (adopt: boolean) => { + const listed = await feed.pending().catch((error) => { + log.debug("pending list failed", { error }) + return undefined + }) + if (!listed) return + const ids = new Set() + for (const each of listed) { + if (each.type !== "asked") continue + ids.add(each.request.id) + /** Its asking was not seen, so "now" stands in for it: a person answers later still. */ + if (adopt) asked(each.request, Date.now()) + } + engine.reconcile(ids, Date.now()) + draw() + } + + return { + loadRules, + rulesReady: () => rulesReady, + reconcile, + dispose: () => { + for (const waiting of parked.values()) clearTimeout(waiting.timer) + parked.clear() + feed.dispose() + }, + } +} diff --git a/packages/trust/test/view.test.ts b/packages/trust/test/view.test.ts index e972988e..316cee16 100644 --- a/packages/trust/test/view.test.ts +++ b/packages/trust/test/view.test.ts @@ -4,19 +4,12 @@ import { DAY, type Event } from "../src/core/ledger.ts" import { SAMPLE_NOW, SAMPLE_SETTINGS, SAMPLES } from "../src/core/sample.ts" import { configSnippet, revoke, widen, widenScope } from "../src/core/view/actions.ts" import { activityModel, activityRows, answerWhy, targetOf } from "../src/core/view/activity.ts" -import { - ALWAYS_KEY, - explorerModel, - explorerRows, - historyRuns, - type Node, - nodeTarget, - reveal, - TAIL, -} from "../src/core/view/explorer.ts" +import { historyRuns } from "../src/core/view/card.ts" +import { explorerRows } from "../src/core/view/explorer.ts" import { countsOf } from "../src/core/view/model.ts" import { type Row, rowText, widthOf } from "../src/core/view/rows.ts" import { sidebarRows, tally } from "../src/core/view/sidebar.ts" +import { ALWAYS_KEY, explorerModel, type Node, nodeTarget, reveal, TAIL } from "../src/core/view/tree.ts" const sidebarOf = (name: string, width: number, shown = false) => { const { engine, trouble } = (SAMPLES[name] as () => ReturnType<(typeof SAMPLES)["empty"]>)() diff --git a/packages/updater/README.md b/packages/updater/README.md index c86eb828..5754f548 100644 --- a/packages/updater/README.md +++ b/packages/updater/README.md @@ -59,8 +59,7 @@ shows exactly what will change, and `enter` again does it. Once a day it checks the registry and, if something is behind, says so once: `2 plugin updates available. Run /plugins-update.` Turn that off with `{ "updater": { "updateCheck": false } }` in `~/.config/opencode-cockpit/config.json`. Shell's old -`ui.updateCheck` is no longer read: a file that still has it gets a doctor line naming -`updater.updateCheck`, and `/cockpit-setup` moves it. +`ui.updateCheck` is not read: doctor says `"ui" is not a setting`, and `/cockpit-setup` moves it. ## What an update does diff --git a/packages/updater/package.json b/packages/updater/package.json index 215584b7..c331d021 100644 --- a/packages/updater/package.json +++ b/packages/updater/package.json @@ -21,14 +21,14 @@ "plugins" ], "exports": { + "./server": { + "types": "./types/server.d.ts", + "default": "./dist/server.js" + }, "./tui": { "types": "./types/tui/index.d.ts", "default": "./dist/tui/index.js" }, - "./core": { - "types": "./types/core/index.d.ts", - "default": "./dist/core/index.js" - }, "./cli": { "types": "./types/cli/main.d.ts", "default": "./dist/cli/main.js" @@ -39,6 +39,7 @@ "node": ">=18" }, "files": [ + "server.js", "tui.js", "dist", "types", diff --git a/packages/updater/server.js b/packages/updater/server.js new file mode 100644 index 00000000..10f25fa6 --- /dev/null +++ b/packages/updater/server.js @@ -0,0 +1,6 @@ +/** + * OpenCode 2 finds a plugin configured by *path* by the files at its root — `/tui`, + * `/server` — rather than through `exports` (docs/opencode/v2.md). A package installed by + * name resolves through `exports` as before; this file is only the door for the path case. + */ +export { default } from "./dist/server.js" diff --git a/packages/updater/src/cli/main.ts b/packages/updater/src/cli/main.ts index f49e06d6..ad6e1b09 100644 --- a/packages/updater/src/cli/main.ts +++ b/packages/updater/src/cli/main.ts @@ -11,10 +11,10 @@ import { spawnSync } from "node:child_process" import { accessSync, constants, existsSync, mkdirSync, rmSync, statSync } from "node:fs" import { homedir } from "node:os" import { createInterface } from "node:readline/promises" -import { nodeDisk } from "../core/disk.ts" import { fetchAllLatest, registryFrom } from "../core/registry.ts" -import { runOpencode } from "../core/spawn.ts" import { doctor } from "../doctor/run.ts" +import { nodeDisk } from "../io/disk.ts" +import { runOpencode } from "../io/spawn.ts" import { update } from "./run.ts" function worktree(cwd: string): string | undefined { diff --git a/packages/updater/src/core/disk.ts b/packages/updater/src/core/disk.ts index 9d63855b..cb7e4d3e 100644 --- a/packages/updater/src/core/disk.ts +++ b/packages/updater/src/core/disk.ts @@ -1,12 +1,8 @@ /** * The two things the core reads from disk, behind an interface so the tests can hand it a - * filesystem made of a plain object. - * - * Node APIs only: the same code runs inside OpenCode (Bun) and under `npx` (Node). + * filesystem made of a plain object. The real one is `io/disk.ts`. */ -import { readdirSync, readFileSync } from "node:fs" - export interface Disk { /** File contents, or undefined when it does not exist or cannot be read. */ read(path: string): string | undefined @@ -14,23 +10,6 @@ export interface Disk { list(path: string): string[] } -export const nodeDisk: Disk = { - read(path) { - try { - return readFileSync(path, "utf8") - } catch { - return undefined - } - }, - list(path) { - try { - return readdirSync(path) - } catch { - return [] - } - }, -} - /** A filesystem from `{ "/abs/path": "contents" }`, for tests. Directories are implied by paths. */ export function memoryDisk(files: Record): Disk { return { diff --git a/packages/updater/src/core/index.ts b/packages/updater/src/core/index.ts deleted file mode 100644 index b8306f46..00000000 --- a/packages/updater/src/core/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -export { type JsoncResult, parseJsonc } from "@opencode-cockpit/client/jsonc" -export * from "./apply.ts" -export * from "./cache.ts" -export * from "./configs.ts" -export * from "./disk.ts" -export * from "./gather.ts" -export * from "./plan.ts" -export * from "./registry.ts" -export * from "./settings.ts" -export * from "./spawn.ts" -export * from "./spec.ts" -export * from "./verify.ts" -export * from "./view/layout.ts" -export * from "./view/rows.ts" diff --git a/packages/updater/src/core/settings.ts b/packages/updater/src/core/settings.ts index c967ff67..6cf21b4c 100644 --- a/packages/updater/src/core/settings.ts +++ b/packages/updater/src/core/settings.ts @@ -3,8 +3,7 @@ * * Read through the loader every bay reads with (`@opencode-cockpit/client/settings`): * `~/.config/opencode-cockpit/config.json`, then the project's `.cockpit.json`, then plugin-entry - * options, later wins. `ui.updateCheck`, the switch Shell's own notice had, is an old name: the - * loader recognises it and doctor names the fix, but its value is not read. + * options, later wins. `ui.updateCheck`, the switch Shell's own notice had, is not read. */ import { baySettings, type SettingsNotice } from "@opencode-cockpit/client/settings" diff --git a/packages/updater/src/doctor/checks.ts b/packages/updater/src/doctor/checks.ts index f6371dea..bf077af4 100644 --- a/packages/updater/src/doctor/checks.ts +++ b/packages/updater/src/doctor/checks.ts @@ -25,7 +25,12 @@ export interface Check { } /** Bays whose agent half doctor checks is loaded beside the interface's (`doctor.test.ts` checks it). */ -export const SERVER_BAYS: readonly Bay[] = ["shell", "review", "subagents", "trail"] +export const SERVER_BAYS: readonly Bay[] = ["shell", "status", "review", "subagents", "trail"] +/** + * Bays whose agent half has nothing of its own: it is there so OpenCode 2 loads the interface. On + * OpenCode 1, where each half has its own file, the interface alone is the whole bay — not checked. + */ +export const EMPTY_SERVER_BAYS: readonly Bay[] = ["trust", "updater"] /** Where a config file sits in OpenCode's split: agent plugins or interface plugins. */ export type Half = "server" | "tui" @@ -112,7 +117,7 @@ export interface ServiceFacts { export interface SettingsFacts { files: { path: string; error?: string }[] modules: { path: string; exists: boolean }[] - /** What the settings loader would tell a bay: old names, unknown sidebar entries, wrong types. */ + /** What the settings loader would tell a bay: unknown names and sidebar entries, wrong types. */ notices?: { file: string; text: string }[] } diff --git a/packages/updater/src/io/disk.ts b/packages/updater/src/io/disk.ts new file mode 100644 index 00000000..7f357878 --- /dev/null +++ b/packages/updater/src/io/disk.ts @@ -0,0 +1,25 @@ +/** + * The real filesystem behind core's `Disk`. + * + * Node APIs only: the same code runs inside OpenCode (Bun) and under `npx` (Node). + */ + +import { readdirSync, readFileSync } from "node:fs" +import type { Disk } from "../core/disk.ts" + +export const nodeDisk: Disk = { + read(path) { + try { + return readFileSync(path, "utf8") + } catch { + return undefined + } + }, + list(path) { + try { + return readdirSync(path) + } catch { + return [] + } + }, +} diff --git a/packages/updater/src/core/spawn.ts b/packages/updater/src/io/spawn.ts similarity index 100% rename from packages/updater/src/core/spawn.ts rename to packages/updater/src/io/spawn.ts diff --git a/packages/updater/src/server.ts b/packages/updater/src/server.ts new file mode 100644 index 00000000..10a88a5b --- /dev/null +++ b/packages/updater/src/server.ts @@ -0,0 +1,12 @@ +/** + * Published entry point: `@opencode-cockpit/updater/server` — an agent half with nothing of its own, since + * the updater works from the interface and its CLI. + * + * It exists for OpenCode 2, which sets up a plugin's interface only once its agent half has loaded: + * listed in `opencode.json`, as `opencode plugin add` writes it, a Updater with no agent half loaded + * nothing at all (measured on 2.0.18). Like every Cockpit agent half, it offers `/cockpit-setup`. + */ + +import { dualServer } from "@opencode-cockpit/client/server" + +export default dualServer("opencode-cockpit.updater", async () => ({})) diff --git a/packages/updater/src/tui/index.tsx b/packages/updater/src/tui/index.tsx index 7c992195..1bc700b1 100644 --- a/packages/updater/src/tui/index.tsx +++ b/packages/updater/src/tui/index.tsx @@ -7,12 +7,12 @@ import type { TuiPluginApi } from "@opencode-ai/plugin/tui" import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client/feature" import { dualTui, type Host } from "@opencode-cockpit/client/host" import { type ApplyIo, applyPlan, manualSteps, readiness } from "../core/apply.ts" -import { nodeDisk } from "../core/disk.ts" import { type GatherIo, gather } from "../core/gather.ts" import type { Source } from "../core/plan.ts" import { fetchAllLatest, registryFrom } from "../core/registry.ts" import { updateCheckEnabled } from "../core/settings.ts" -import { runOpencode } from "../core/spawn.ts" +import { nodeDisk } from "../io/disk.ts" +import { runOpencode } from "../io/spawn.ts" import { UpdaterDialog } from "./dialog.tsx" const UPDATER_PACKAGE = "@opencode-cockpit/updater" diff --git a/packages/updater/test/doctor.test.ts b/packages/updater/test/doctor.test.ts index 343d4bd6..c6121693 100644 --- a/packages/updater/test/doctor.test.ts +++ b/packages/updater/test/doctor.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test" -import { readFileSync } from "node:fs" +import { existsSync, readdirSync, readFileSync } from "node:fs" import { join } from "node:path" import { memoryDisk } from "../src/core/disk.ts" -import { ago, type Check, SERVER_BAYS } from "../src/doctor/checks.ts" +import { ago, type Check, EMPTY_SERVER_BAYS, SERVER_BAYS } from "../src/doctor/checks.ts" import type { DoctorIo } from "../src/doctor/gather.ts" import { doctor } from "../src/doctor/run.ts" @@ -370,8 +370,8 @@ describe("the rest", () => { expect(found.Settings?.fix?.join()).toContain("gone.ts") }) - /** Status reads modules from `status.modules` only; doctor agrees, and names the old places as such. */ - test("modules under the old names are not checked: each is only the loader's notice", async () => { + /** Status reads modules from `status.modules` only; doctor agrees: elsewhere they are unknown names. */ + test("modules under the old names are not checked: each is only an unknown name", async () => { const file = `${HOME}/.config/opencode-cockpit/config.json` const found = await checks({ opencode: "2.0.15", @@ -381,8 +381,8 @@ describe("the rest", () => { }) const fix = found.Settings?.fix?.join("\n") ?? "" expect(fix).not.toContain("does not exist") - expect(fix).toContain(`${file}: "statusline" is no longer read — run /cockpit-setup`) - expect(fix).toContain(`${file}: "modules" at the top level is not read: it belongs in "status"`) + expect(fix).toContain(`${file}: "statusline" is not a setting: did you mean "status"?`) + expect(fix).toContain(`${file}: "modules" is not a setting`) }) }) @@ -442,7 +442,7 @@ describe("Cockpit's settings", () => { expect(found.Settings?.fix?.[0]).toEndWith("the whole file is ignored") }) - test("every old name and unknown sidebar entry is a fix line", async () => { + test("every unknown name and unknown sidebar entry is a fix line", async () => { const found = await checks({ opencode: "2.0.18", files: { @@ -457,10 +457,9 @@ describe("Cockpit's settings", () => { expect(found.Settings?.state).toBe("warn") expect(found.Settings?.summary).toBe("settings that are not read as written") expect(found.Settings?.fix).toEqual([ - `${GLOBAL}: "statusline" is no longer read — run /cockpit-setup`, - `${GLOBAL}: "ui.dockHeight" is no longer read — run /cockpit-setup`, + `${GLOBAL}: "statusline" is not a setting: did you mean "status"?`, + `${GLOBAL}: "ui" is not a setting`, `${GLOBAL}: "shells" in "sidebar" is not a bay: did you mean "shell"? (status, subagents, shell, trail, trust)`, - `${PROJECT}: "trust.sidebarOrder" is no longer read — run /cockpit-setup`, ]) }) }) @@ -629,11 +628,23 @@ describe("OpenCode 2's background service (it keeps the plugin code it started w describe("the bays doctor knows", () => { test("every bay it checks for an agent half publishes one", () => { - for (const bay of SERVER_BAYS) { + for (const bay of [...SERVER_BAYS, ...EMPTY_SERVER_BAYS]) { const manifest = JSON.parse( readFileSync(join(import.meta.dir, "..", "..", bay, "package.json"), "utf8"), ) expect(Object.keys(manifest.exports)).toContain("./server") } }) + + test("every bay that publishes an agent half is one it checks — Status gained one in 0.9 and was missed", () => { + const packages = join(import.meta.dir, "..", "..") + /** The bundle is checked on its own; the client is a library, not a bay. */ + const notBays = new Set(["opencode", "client"]) + const withServer = readdirSync(packages).filter((dir) => { + if (notBays.has(dir) || !existsSync(join(packages, dir, "package.json"))) return false + const manifest = JSON.parse(readFileSync(join(packages, dir, "package.json"), "utf8")) + return Object.keys(manifest.exports ?? {}).includes("./server") + }) + expect([...SERVER_BAYS, ...EMPTY_SERVER_BAYS].sort()).toEqual(withServer.sort()) + }) }) diff --git a/packages/updater/test/registry.test.ts b/packages/updater/test/registry.test.ts index 13e66de1..3770279a 100644 --- a/packages/updater/test/registry.test.ts +++ b/packages/updater/test/registry.test.ts @@ -59,13 +59,10 @@ describe("the daily notice setting", () => { expect(updateCheckEnabled(memoryDisk({}), where, undefined)).toBe(true) }) - /** An old name is not read (0.9): it is a notice naming `updater.updateCheck`, and the check stays on. */ - test("Shell's old `ui.updateCheck` is not read, and says what to write instead", () => { + /** A name from before 0.9 is not read: the check stays on. */ + test("Shell's old `ui.updateCheck` is not read", () => { const disk = memoryDisk({ [GLOBAL]: '{"ui":{"updateCheck":false}}' }) expect(updateCheckEnabled(disk, where, undefined)).toBe(true) - expect(updateCheck(disk, where, undefined).notices).toMatchObject([ - { bay: "updater", kind: "old", old: "ui.updateCheck", new: "updater.updateCheck" }, - ]) }) test("comments and trailing commas are fine, as in every bay", () => { diff --git a/scripts/README.md b/scripts/README.md index c73db709..95f10504 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -16,10 +16,11 @@ What each script is for, and what runs it. Anything here that nothing runs shoul | Script | What it does | Run by | | --- | --- | --- | -| `build.ts` | Compiles every package to `dist/` | `bun run build`, CI, the release workflow | +| `build.ts` | Compiles every package to `dist/`, clearing `dist/` and `types/` first | `bun run build`, CI, the release workflow | | `pack-check.ts` | Packs every package and checks what would publish | `bun run pack:check`, CI, the release workflow | -| `tui-smoke.ts` | Drives a real OpenCode in a PTY through each bay's interface | `bun run smoke:tui` | +| `tui-smoke.ts` | Drives a real OpenCode in a PTY through each bay's interface — every bay side by side, the bundle (`SMOKE_INSTALL=bundle`) or one bay alone (`SMOKE_INSTALL=`); one probe per bay in `smoke/probes/` | `bun run smoke:tui` | | `test-env.ts` | Keeps test runs' logs out of your own `cockpit.log` | `bun test` (preloaded by `bunfig.toml`) | +| `golden.ts` | Every bay's preview in every state, in colour, snapshotted to `.golden/` — a refactor that should change nothing proves it with `--check` | by hand, around a refactor | | `measure-agent.ts` | What every bay's behaviour measurement shares: a real OpenCode, one model turn, what it did | `packages//measure/agent.ts` | ## Working on Cockpit diff --git a/scripts/build.ts b/scripts/build.ts index 37959de0..e0969a04 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -1,5 +1,6 @@ /** - * Compiles every package's `src/` to plain JavaScript in `dist/`, which is what gets published. + * Compiles every package's `src/` to plain JavaScript in `dist/`, and its declarations to `types/`: + * what gets published. * * Every package builds through this one script on purpose: publishing compiled output from a * single pipeline keeps "works from a checkout, broken once installed" bugs from coming back. @@ -37,6 +38,14 @@ for (const pkg of packages) { const outDir = join(root, "packages", pkg, "dist") if (!existsSync(srcDir)) continue rmSync(outDir, { recursive: true, force: true }) + /** + * `types/` is `tsc -b`'s, and an incremental build never deletes what a moved or removed source + * left there — a stale file once kept a broken entry point looking fine. Gone with its build info, + * so the next typecheck writes it whole. + */ + rmSync(join(root, "packages", pkg, "types"), { recursive: true, force: true }) + for (const info of readdirSync(join(root, "packages", pkg)).filter((name) => name.endsWith(".tsbuildinfo"))) + rmSync(join(root, "packages", pkg, info), { force: true }) const files = [...new Bun.Glob("**/*.{ts,tsx}").scanSync(srcDir)].sort() if (files.length === 0) throw new Error(`no sources found in ${srcDir}`) @@ -62,3 +71,8 @@ for (const pkg of packages) { } console.log(`${pkg}: ${files.length} file${files.length === 1 ? "" : "s"} → packages/${pkg}/dist`) } + +/** The declarations publish beside the code: packing and releasing run this build and nothing else. */ +const types = Bun.spawnSync(["bunx", "tsc", "-b"], { cwd: root, stdout: "inherit", stderr: "inherit" }) +if (types.exitCode !== 0) process.exit(types.exitCode ?? 1) +console.log("types: tsc -b → packages/*/types") diff --git a/scripts/golden.ts b/scripts/golden.ts new file mode 100644 index 00000000..80192755 --- /dev/null +++ b/scripts/golden.ts @@ -0,0 +1,96 @@ +/** + * Every bay's preview, in every state, written to .golden/ — so a refactor that should change + * nothing can prove it did not: + * + * bun scripts/golden.ts # write the snapshots (before) + * bun scripts/golden.ts --check # compare against them (after); exits 1 and names what differs + * + * Each preview runs under a pseudo-terminal (`script`), so it draws in colour as it would for you: + * a tone that changed is a difference too. Status gets an empty config of its own, so your settings + * never leak into the snapshot. + */ +import { spawnSync } from "node:child_process" +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { join, resolve } from "node:path" + +const root = resolve(import.meta.dir, "..") +const out = join(root, ".golden") +const check = process.argv.includes("--check") +mkdirSync(out, { recursive: true }) +const emptyConfig = join(out, "empty-config.json") +writeFileSync(emptyConfig, "{}\n") + +const runs: Record = {} +const add = (name: string, pkg: string, args: string[] = []) => (runs[name] = [pkg, args]) + +add("client", "client") +for (const fixture of ["turn", "created", "sprawl", "huge", "awkward", "images", "clean"]) + add(`review-${fixture}`, "review", ["--fixture", fixture, "--width", "120", "--height", "40"]) +add("review-diff", "review", ["--diff", "--width", "120", "--height", "40"]) +add("review-keys", "review", ["--keys", "--width", "120", "--height", "40"]) +add("review-settings", "review", ["--settings", "2", "--width", "120", "--height", "40"]) +add("shell", "shell", ["--width", "38", "--columns", "100"]) +add("shell-notice", "shell", ["--part", "sidebar", "--notice", "--width", "30"]) +add("status", "status", ["--config", emptyConfig]) +add("status-bottom", "status", ["--config", emptyConfig, "--surface", "bottom", "--width", "120"]) +for (const fixture of ["sample", "advisor", "late", "continued", "finished", "names", "empty", "calls"]) + add(`subagents-${fixture}`, "subagents", ["--fixture", fixture, "--columns", "100"]) +add("subagents-widths", "subagents", ["--widths", "24,30,36,50"]) +add("subagents-keys", "subagents", ["--keys", "--columns", "100"]) +add("subagents-notice", "subagents", ["--notice", "--hide"]) +for (const sample of ["empty", "one", "busy", "long", "project"]) + add(`trail-${sample}`, "trail", ["--sample", sample, "--columns", "100", "--rows", "24"]) +add("trail-widths", "trail", ["--widths", "24,30,36,50"]) +for (const sample of [ + "empty", + "first", + "busy", + "dangerous", + "families", + "crowded", + "crowded-paused", + "paused", + "trouble", +]) + for (const view of ["activity", "ledger"]) + add(`trust-${sample}-${view}`, "trust", [ + "--sample", + sample, + "--view", + view, + "--columns", + "100", + "--rows", + "24", + ]) +add("trust-keys", "trust", ["--keys", "--columns", "100", "--rows", "24"]) + +const differs: string[] = [] +for (const [name, [pkg, args]] of Object.entries(runs)) { + const cwd = join(root, "packages", pkg) + // `script` gives the preview a terminal, so it colours as it would for you; -q keeps its own lines out + const result = spawnSync("script", ["-q", "/dev/null", "bun", "src/cli/preview.ts", ...args], { + cwd, + env: { ...process.env, NO_COLOR: "", COLUMNS: "120", LINES: "40", TZ: "UTC" }, + encoding: "utf8", + timeout: 60_000, + }) + const text = `${result.stdout}${result.stderr ? `\n--- stderr\n${result.stderr}` : ""}`.replace(/\r/g, "") + const file = join(out, `${name}.txt`) + if (!check) { + writeFileSync(file, text) + continue + } + if (!existsSync(file) || readFileSync(file, "utf8") !== text) { + differs.push(name) + writeFileSync(join(out, `${name}.now.txt`), text) + } +} +const count = Object.keys(runs).length +if (!check) console.log(`wrote ${count} previews to .golden/`) +else if (differs.length === 0) console.log(`${count} previews: identical`) +else { + console.log(`${differs.length} of ${count} previews differ (the new output is beside each, as .now.txt):`) + for (const name of differs) console.log(` ${name}`) + process.exit(1) +} diff --git a/scripts/measure-agent.ts b/scripts/measure-agent.ts index 94d497a7..aa733f5a 100644 --- a/scripts/measure-agent.ts +++ b/scripts/measure-agent.ts @@ -82,6 +82,8 @@ export interface Turn { session?: string stdout: string stderr: string + /** False when the turn ran out its five minutes, or was killed. */ + finished: boolean } export interface World { @@ -150,6 +152,7 @@ export function turn(at: World, prompt: string, session?: string): Turn { session: all.find((event) => typeof event.sessionID === "string")?.sessionID, stdout, stderr: result.stderr.toString(), + finished: result.exitCode !== null && !result.signalCode, } } @@ -166,16 +169,23 @@ export function stopDaemon(cockpitHome: string): void { /** A run's text, short, on one line. */ export const brief = (text: string, room = 200) => text.replace(/\s+/g, " ").slice(0, room) -/** Runs one measurement `runs` times, each tried again while it measured nothing; exits 0 when all passed. */ +/** + * Runs one measurement `runs` times, each tried again while it measured nothing; exits 0 when `pass` + * of them passed — every run, unless a bay settles for fewer (a free model misses some turns). It + * stops as soon as enough have passed: the rest would measure nothing more. + */ export async function measure( title: string, runs: number, once: (index: number) => Promise, attempts = 3, + pass = runs, ): Promise { console.log(title) let passed = 0 - for (let i = 1; i <= runs; i++) { + let ran = 0 + for (let i = 1; i <= runs && passed < pass; i++) { + ran++ let outcome = await once(i) for (let attempt = 2; !outcome.measured && attempt <= attempts; attempt++) { console.log(`run ${i}: the model did nothing this measures — trying again (${attempt}/${attempts})`) @@ -186,6 +196,6 @@ export async function measure= pass ? 0 : 1) } diff --git a/scripts/pack-check.ts b/scripts/pack-check.ts index 47a2ba83..8121917e 100644 --- a/scripts/pack-check.ts +++ b/scripts/pack-check.ts @@ -259,7 +259,7 @@ try { `export default { segments: { hi: () => ({ runs: [{ text: "hi" }] }) } } satisfies CustomModule\n`, ) const { loadCustomSegments } = await import( - join(dir, "node_modules", "@opencode-cockpit", "status", "dist", "core", "custom.js") + join(dir, "node_modules", "@opencode-cockpit", "status", "dist", "io", "custom.js") ) const loaded = await loadCustomSegments([join(outside, "mod.ts")], outside) if (loaded.errors.length > 0 || !loaded.segments.has("hi")) { @@ -306,7 +306,23 @@ try { console.log(` ${install.name}: trail's agent half loads, its bin and OpenCode 2's doors are there`) } - // The statusline bay has no server half and no daemon: there is nothing further to run. + /** + * Trust and the updater alone: an agent half with nothing of its own, which OpenCode 2 needs + * before it sets up their interface — without it, either installed alone loaded nothing there. + */ + for (const bay of ["trust", "updater"]) { + if (!install.packages.includes(`@opencode-cockpit/${bay}`)) continue + const { default: server } = await import(Bun.resolveSync(`@opencode-cockpit/${bay}/server`, dir)) + if (server?.id !== `opencode-cockpit.${bay}` || typeof server.setup !== "function") + throw new Error( + `${install.name}: @opencode-cockpit/${bay}/server must export the plugin for both OpenCodes`, + ) + if (!existsSync(join(dir, "node_modules", "@opencode-cockpit", bay, "server.js"))) + throw new Error(`${install.name}: ${bay} ships no server.js for OpenCode 2`) + console.log(` ${install.name}: ${bay}'s empty agent half loads, and OpenCode 2's door is there`) + } + + // Only Shell runs a daemon: without it there is nothing further to run. if (!bays.includes("@opencode-cockpit/shell")) { console.log(` ${install.name}: loads, compiled interface entry, authoring subpaths resolve`) return diff --git a/scripts/smoke/agent.ts b/scripts/smoke/agent.ts new file mode 100644 index 00000000..f8bae528 --- /dev/null +++ b/scripts/smoke/agent.ts @@ -0,0 +1,85 @@ +/** + * `AGENT=1` outside the interface: one real turn against the server halves, and each bay's own + * behaviour measurement. + */ + +import { join } from "node:path" +import { turn } from "../measure-agent.ts" +import { opencode, root, v2 } from "./harness.ts" +import type { Install } from "./install.ts" + +/** What a probe asks of the agent turn: a tool to call, and the heading its guidance carries. */ +export interface AgentAsk { + /** As the prompt says it, after "call": the tool, and anything it is called with. */ + call: string + tool: string + heading?: string +} + +const COUNT = ["", "one", "two", "three", "four", "five"] +const quoted = (headings: string[]) => { + const all = headings.map((heading) => `'${heading}'`) + return all.length > 1 ? `${all.slice(0, -1).join(", ")} and ${all.at(-1)}` : (all[0] ?? "") +} + +/** + * One real turn, by a free OpenCode Zen model, against the server halves — the only proof that the + * tools registered and the system prompt carries the guidance, on either version. Needs the network + * and a model willing to follow instructions, so it is opt-in. + */ +export function agentTurn(install: Install, asks: AgentAsk[]): void { + const headings = asks.flatMap((ask) => (ask.heading ? [ask.heading] : [])) + const prompt = [ + `Call ${asks.map((ask) => ask.call).join(", then call ")}.`, + ...(headings.length > 1 + ? [ + `Your system prompt has heading lines starting with ${quoted(headings)}.`, + `Quote all ${COUNT[headings.length]} heading lines exactly in your reply.`, + ] + : headings.length === 1 + ? [ + `Your system prompt has a heading line starting with ${quoted(headings)}.`, + "Quote it exactly in your reply.", + ] + : []), + ].join(" ") + const at = { + oc: { bin: opencode, version: "", v2 }, + work: install.project, + project: install.project, + model: "opencode/space-bunny-free", + env: install.env, + } + /** Bounded, and stdin closed: an open stdin or a permission prompt makes `opencode run` wait forever. */ + const { calls, said, stdout, stderr, finished } = turn(at, prompt) + if (!finished) throw new Error(`the agent turn never finished (5 min):\n${stdout.slice(-3000)}`) + const called = calls.filter((call) => call.status === "completed").map((call) => call.tool) + const report = `${stdout}\n${stderr}`.slice(-3000) + for (const { tool } of asks) { + if (!called.includes(tool)) throw new Error(`the agent never completed ${tool}:\n${report}`) + } + for (const heading of headings) { + if (!said.includes(heading)) throw new Error(`the agent was never told "${heading}":\n${report}`) + } +} + +/** + * A bay's behaviour measurement (`packages//measure/agent.ts`) against the entry this install + * loads it from: the guidance has to change what a real turn does, without the prompt naming the bay. + */ +export function measure(install: Install, name: "trail" | "shell" | "review", what: string, runs: string[]) { + const measured = Bun.spawnSync( + ["bun", join(root, `packages/${name}/measure/agent.ts`), "--plugin", install.entryOf(name), ...runs], + { + cwd: root, + env: { ...process.env, OPENCODE: opencode }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + /** Three attempts, of up to two five-minute turns each, inside each run of the measurement. */ + timeout: name === "trail" ? 6_000_000 : 2_000_000, + }, + ) + if (measured.exitCode !== 0) + throw new Error(`${what} measurement failed:\n${measured.stdout}\n${measured.stderr}`.slice(-3000)) +} diff --git a/scripts/smoke/commands.ts b/scripts/smoke/commands.ts new file mode 100644 index 00000000..8a5b7f6f --- /dev/null +++ b/scripts/smoke/commands.ts @@ -0,0 +1,103 @@ +/** Running a command the ways a person does: from the palette (`ctrl+p`), and by its slash name. */ + +import { expect, screen, type, until } from "./harness.ts" + +/** + * The palette, where a command can be listed and still do nothing you can see — Trust's "show or + * hide in the sidebar" did exactly that. The capture's selection is not to be trusted blindly + * (docs/building/testing.md), so the screen is read before `enter`: the entry has to be at the top, + * right under the query. + */ +const atTop = (listed: string, query: string, title: string) => { + const lines = listed.split("\n") + const field = lines.findIndex((line) => line.trim() === query) + return ( + field >= 0 && + lines + .slice(field + 1) + .filter((line) => line.trim()) + .slice(0, 2) + .some((line) => line.includes(title)) + ) +} + +/** + * The screen at rest, nothing open over it — taken before the first command runs. Each step waits + * for the screen to come back to it: a command's toast ("No finished subagents to clear") still up + * when `ctrl+p` was typed covered the palette, and the next step failed on v2. A line or two may + * differ (a tip, a toggled sidebar); a toast or a dialog is more. Timed out, it goes on anyway, and + * the palette check below says what was in the way. + */ +let quiet = "" +const atRest = (text: string) => { + const was = quiet.split("\n") + return text.split("\n").filter((line, y) => line !== was[y]).length <= 2 +} +/** At rest: a beat after the last search's palette closed, the same screen twice running (or 5s). */ +export async function settle(): Promise { + await Bun.sleep(1000) + quiet = await until(5000, (text) => { + const same = text === quiet + quiet = text + return same + }) +} + +/** Types "cockpit " into the palette: what it listed, closed again without running anything. */ +export async function search(query: string): Promise { + await type("\x10", 1000) + await type(query, 1500) + const listed = await screen() + await type("\x1b", 800) + return listed +} + +/** Runs `title` from the palette, once the screen is back at rest; what it drew after `waitMs`. */ +export async function palette(title: string, waitMs = 2500): Promise { + await until(12_000, atRest) + await type("\x10", 1000) // ctrl+p + await type(title, 1500) + const listed = await screen() + expect(atTop(listed, title, title), `the palette did not offer "${title}" first`, listed) + await type("\r", waitMs) + return await screen() +} + +/** `ready`: read as soon as it shows, for what does not stay — a toast the next one replaces. */ +export async function slash(name: string, ready?: (text: string) => boolean): Promise { + await type(`/${name}`, 300) + /** `enter` once the popup offers the name: with the agent busy it can take longer to list. */ + await until(4000, (text) => new RegExp(`/${name}\\s{2,}\\S`).test(text)) + await type("\r", ready ? 0 : 4000) + const drawn = ready ? await until(4000, ready) : await screen() + await type("\x1b", 1000) + return drawn +} + +/** + * A command the agent side ships, which OpenCode runs as its own: on OpenCode 1 `enter` on the popup + * first completes the name into the prompt, and a second `enter` sends it (measured, both versions). + */ +export async function shipped(name: string): Promise { + await type(`/${name}`, 300) + await until(4000, (text) => new RegExp(`/${name}\\s{2,}\\S`).test(text)) + await type("\r", 1200) + if (new RegExp(`┃\\s+/${name}\\s*$`, "m").test(await screen())) await type("\r", 0) +} + +/** + * The setup commands' slash names, shipped by the agent side, offered in the popup as they are typed + * — once each: the interface's palette entries for them carry no slash name. Only listed here, not + * run: running one asks a model, and a run without AGENT=1 stays offline. + */ +export async function popup(typed: string): Promise { + await type("\x15", 300) + await type(typed, 1500) + const listed = await screen() + await type("\x15", 300) + await type("\x1b", 800) + return listed +} +/** How many rows of the popup offer `name`: at the popup's edge, the name, a gap, its description. */ +export const offered = (listed: string, name: string) => + (listed.match(new RegExp(`┃ ${name} {2,}\\S`, "g")) ?? []).length diff --git a/scripts/smoke/harness.ts b/scripts/smoke/harness.ts new file mode 100644 index 00000000..c1f702f0 --- /dev/null +++ b/scripts/smoke/harness.ts @@ -0,0 +1,165 @@ +/** + * What every probe drives OpenCode with: the binary and its version, a headless xterm per OpenCode + * started, the keys typed into it, and the ways a screen is read back. + */ + +import { mkdirSync, rmSync, statSync } from "node:fs" +import { join } from "node:path" +import { Terminal } from "@xterm/headless" + +export const root = join(import.meta.dir, "..", "..") +/** OPENCODE picks the binary, so the same test can drive v1 and v2 side by side. */ +const binary = process.env.OPENCODE ?? Bun.which("opencode") +if (!binary) { + console.error("opencode binary not found; install OpenCode to run this smoke test") + process.exit(1) +} +export const opencode: string = binary + +/** Which OpenCode this is decides where plugins are configured and how it is started. */ +export const v2 = Bun.spawnSync([opencode, "--version"]) + .stdout.toString() + .trim() + .replace(/^opencode\s+v?/, "") + .startsWith("2") + +/** `AGENT=1`: the runs that need the network and a model willing to follow instructions. */ +export const agent = Boolean(process.env.AGENT) + +export const run = (cmd: string[], cwd: string) => { + const result = Bun.spawnSync(cmd, { cwd, stdout: "pipe", stderr: "pipe" }) + if (result.exitCode !== 0) throw new Error(`$ ${cmd.join(" ")}\n${result.stdout}\n${result.stderr}`) + return result.stdout.toString() +} + +/** + * Build and pack under a lock. `build` empties every `dist/` before it compiles, so a second run + * (v1 and v2 side by side, or a `dev:install`) packing at that moment shipped a bay without its + * files: OpenCode said "1 plugin failed" and Trust's commands were missing. A directory is the lock + * (`mkdir` is atomic); one older than ten minutes is left over from a killed run. + */ +const buildLock = join(root, "node_modules", ".cockpit-build.lock") +export async function withBuildLock(work: () => void): Promise { + for (;;) { + try { + mkdirSync(buildLock) + break + } catch { + const age = Date.now() - (statSync(buildLock, { throwIfNoEntry: false })?.mtimeMs ?? Date.now()) + if (age > 600_000) rmSync(buildLock, { recursive: true, force: true }) + else await Bun.sleep(500) + } + } + try { + work() + } finally { + rmSync(buildLock, { recursive: true, force: true }) + } +} + +export const cols = Number(process.env.SMOKE_COLS) || 150 +const rows = 40 +/** One per OpenCode started: the second run (AGENT=1) draws on a clean screen of its own. */ +let term = new Terminal({ cols, rows, allowProposedApi: true }) +type Pty = ReturnType & { terminal: { write(data: string): void } } +let proc: Pty | undefined + +export const screen = async () => { + await new Promise((done) => term.write("", done)) + const buffer = term.buffer.active + return Array.from( + { length: rows }, + (_, y) => buffer.getLine(buffer.baseY + y)?.translateToString(true) ?? "", + ).join("\n") +} + +/** v2 would attach to the user's background service; a private server keeps the run to itself. */ +export const launch = (env: Record, cwd: string, args: string[] = []) => { + const screenOf = new Terminal({ cols, rows, allowProposedApi: true }) + term = screenOf + proc = Bun.spawn([opencode, ...(v2 ? ["--standalone"] : []), ...args], { + cwd, + env, + terminal: { + cols, + rows, + data: (_t: unknown, chunk: Uint8Array) => screenOf.write(chunk.slice()), + }, + } as Parameters[1]) as Pty +} +export const kill = () => proc?.kill("SIGKILL") + +export const type = async (keys: string, waitMs: number) => { + proc?.terminal.write(keys) + await Bun.sleep(waitMs) +} + +/** The sidebar's half of a screen. */ +export const rightHalf = (text: string) => + text + .split("\n") + .map((line) => line.slice(Math.floor(cols / 2))) + .join("\n") +/** + * Which of `patterns` showed on some screen within `ms` — not all on one: a turn scrolls the first + * out of view before the last arrives. Done as soon as every one has. + */ +export const seen = async (ms: number, patterns: readonly RegExp[]) => { + const found = new Set() + for (const end = Date.now() + ms; found.size < patterns.length && Date.now() < end; await Bun.sleep(250)) { + const text = await screen() + for (const [at, pattern] of patterns.entries()) if (pattern.test(text)) found.add(at) + } + return { + all: found.size === patterns.length, + missing: patterns.filter((_, at) => !found.has(at)), + last: await screen(), + } +} +/** Reads the screen until `done` says so, or `ms` runs out; the last screen either way. */ +export const until = async (ms: number, done: (text: string) => boolean) => { + let text = await screen() + for (const end = Date.now() + ms; !done(text) && Date.now() < end; text = await screen()) { + await Bun.sleep(250) + } + return text +} +/** + * A block's heading in the sidebar, and the first row under it (past the heading's air) — read in + * the heading's own column, so the conversation beside it cannot answer for the block. + */ +export const under = (text: string, heading: string): string | undefined => { + const lines = text.split("\n") + const right = Math.floor(cols / 2) + for (const [y, line] of lines.entries()) { + const at = line.slice(right).search(new RegExp(`(^|\\s)${heading}(\\s|$)`)) + if (at < 0) continue + const x = right + at + (line[right + at] === " " ? 1 : 0) + const next = lines.slice(y + 1, y + 4).find((row) => row.slice(x).trim()) + return next?.slice(x).trim() + } + return undefined +} + +/** Fails with what was on screen. */ +export function expect(ok: unknown, what: string, text: string): void { + if (!ok) throw new Error(`${what}:\n${text}`) +} + +/** + * Every screen a probe kept, for the one check that holds on all of them: a plugin OpenCode could + * not load says so in the footer, whichever half it was. + */ +const kept: string[] = [] +export const keep = (...texts: string[]) => { + kept.push(...texts) +} +export function noPluginFailed(): void { + for (const text of kept) expect(!/plugins? failed/.test(text), "OpenCode could not load a plugin", text) +} + +/** What each probe proved, in the order it proved it: the run's last line. */ +export const passed: string[] = [] +export const pass = (note: string) => { + passed.push(note) +} diff --git a/scripts/smoke/install.ts b/scripts/smoke/install.ts new file mode 100644 index 00000000..8b5a4ed0 --- /dev/null +++ b/scripts/smoke/install.ts @@ -0,0 +1,186 @@ +/** + * The install a smoke run drives: build and pack every package, install what `SMOKE_INSTALL` names + * the way a person would, and write OpenCode's config, Cockpit's and a project for it. + * + * - `bays` (the default): every bay as its own package, side by side. + * - `bundle`: `opencode-cockpit` alone, which carries every bay. + * - a bay's name (`shell`, `trust`, …): that package alone — the install the docs give for one bay. + */ + +import { mkdtempSync } from "node:fs" +import { join } from "node:path" +import type { Feature as Bay } from "../../packages/opencode/src/features.ts" +import { FEATURES } from "../../packages/opencode/src/features.ts" +import { agent, root, run, v2, withBuildLock } from "./harness.ts" + +export type Mode = "bays" | "bundle" | Bay + +/** + * The order the bays are configured in, which is the order OpenCode loads them — and the first + * entry to load registers what every entry offers (`/cockpit-setup`). Kept as it always was. + */ +const ORDER: readonly Bay[] = ["shell", "status", "review", "updater", "subagents", "trail", "trust"] +const missing = FEATURES.filter((bay) => !ORDER.includes(bay)) +if (missing.length > 0) throw new Error(`scripts/smoke/install.ts: add ${missing.join(", ")} to ORDER`) + +export function readMode(raw = process.env.SMOKE_INSTALL || "bays"): Mode { + if (raw === "bays" || raw === "bundle" || (FEATURES as readonly string[]).includes(raw)) return raw as Mode + throw new Error(`SMOKE_INSTALL=${raw}: one of bays, bundle, ${FEATURES.join(", ")}`) +} + +export interface Install { + mode: Mode + /** Everything the run writes, removed afterwards unless KEEP=1. */ + work: string + /** A git checkout with one uncommitted change, where OpenCode is started. */ + project: string + env: Record + /** The bays this install loads. */ + bays: readonly Bay[] + /** Whether any entry ships an agent side: the one place `/cockpit-setup` comes from. */ + agentSide: boolean + /** The plugin entry that loads `bay`: its own package, or the bundle. */ + entryOf(bay: Bay): string +} + +export async function prepare(mode: Mode): Promise { + const work = mkdtempSync("/tmp/ck-smoke-") + const install = join(work, "install") + const project = join(work, "project") + const config = join(work, "config") + const home = join(work, "home") + + const tarballs = join(work, "tarballs") + await withBuildLock(() => { + run(["bun", "run", "build"], root) + /** The plumbing, then every bay — from the bundle's own list, so a new one cannot be left out. */ + for (const dir of ["protocol", "daemon", "client", "opencode", ...FEATURES]) { + run(["bun", "pm", "pack", "--destination", tarballs], join(root, "packages", dir)) + } + }) + const names = [...new Bun.Glob("*.tgz").scanSync(tarballs)] + const file = (prefix: string) => `file:${join(tarballs, names.find((n) => n.startsWith(prefix)) as string)}` + const pkg = (bay: Bay) => [`@opencode-cockpit/${bay}`, file(`opencode-cockpit-${bay}-`)] + const bays = mode === "bays" || mode === "bundle" ? ORDER : [mode] + /** The bundle's own name is a prefix of every bay's tarball: its version follows it directly. */ + const bundle = file( + `opencode-cockpit-${(await Bun.file(join(root, "packages/opencode/package.json")).json()).version}`, + ) + await Bun.write( + join(install, "package.json"), + JSON.stringify({ + name: "smoke", + private: true, + dependencies: mode === "bundle" ? { "opencode-cockpit": bundle } : Object.fromEntries(bays.map(pkg)), + /** Not on the registry at this version: every package the install reaches comes from its tarball. */ + overrides: { + "@opencode-cockpit/protocol": file("opencode-cockpit-protocol-"), + "@opencode-cockpit/daemon": file("opencode-cockpit-daemon-"), + "@opencode-cockpit/client": file("opencode-cockpit-client-"), + ...(mode === "bundle" ? Object.fromEntries(ORDER.map(pkg)) : {}), + }, + }), + ) + run(["npm", "install"], install) + + /** Review reads git, so the project is a checkout with exactly one change to show. */ + await Bun.write(join(project, "SMOKE-REVIEW.ts"), "export const answer = 41\n") + for (const cmd of [ + ["git", "init", "-q", "-b", "main"], + ["git", "config", "user.email", "smoke@example.com"], + ["git", "config", "user.name", "Smoke"], + ["git", "add", "-A"], + ["git", "commit", "-qm", "smoke"], + ]) { + run(cmd, project) + } + await Bun.write(join(project, "SMOKE-REVIEW.ts"), "export const answer = 42\n") + + // The plugins must live under node_modules: that is what disables OpenCode's Solid transform. + const bayDir = (name: string) => join(install, "node_modules", "@opencode-cockpit", name) + const bundleDir = join(install, "node_modules", "opencode-cockpit") + /** + * Which entries have an agent side, from what was installed. Status's agent side carries only the + * `status-setup` skill and its commands; Trust's and the updater's carry nothing of their own. + */ + const hasServer = async (dir: string) => + "./server" in (await Bun.file(join(dir, "package.json")).json()).exports + const tuiEntries = mode === "bundle" ? [bundleDir] : bays.map(bayDir) + const serverEntries: string[] = [] + for (const entry of tuiEntries) if (await hasServer(entry)) serverEntries.push(entry) + /** + * Where the install docs put them, which is where `opencode plugin add` writes. v1 reads `plugin` + * from opencode.json (the agent side) and tui.json (the interface). v2 reads `plugins` from + * opencode.json and loads both halves of every entry there — the bundle and each bay alike — so + * every entry goes in opencode.json alone, and nothing in cli.json. + */ + const files: [string, string, string, string[]][] = v2 + ? [["opencode.json", "https://opencode.ai/config.json", "plugins", tuiEntries]] + : [ + ["opencode.json", "https://opencode.ai/config.json", "plugin", serverEntries], + ["tui.json", "https://opencode.ai/tui.json", "plugin", tuiEntries], + ] + for (const [name, schema, key, plugins] of files) { + if (name !== "opencode.json" && plugins.length === 0) continue + /** v1's schema URLs mean nothing to v2, whose loader skipped files carrying them. */ + await Bun.write( + join(config, "opencode", name), + JSON.stringify({ + ...(v2 ? {} : { $schema: schema }), + [key]: plugins, + /** A model that needs no key, for the turns AGENT=1 runs inside the interface. */ + ...(agent && name === "opencode.json" ? { model: "opencode/space-bunny-free" } : {}), + /** + * The setup skills read and write Cockpit's config, outside the project: a prompt it would + * wait on forever here. OpenCode 2 is started with `--auto` for the same reason. + */ + ...(agent && !v2 && name === "opencode.json" ? { permission: { external_directory: "allow" } } : {}), + }), + ) + } + /** + * A statusline whose value has to come from somewhere the plugin cannot fake: a literal marker + * proves the line drew at all, and a command segment proves the whole pipeline -- spawn, parse, + * repaint -- works from a published build. + * + * The global file carries the section's name from before 0.9, `statusline`: it is not read, and + * Status has to say so in a `!` row instead of drawing as if nothing had been written. + */ + await Bun.write( + join(config, "opencode-cockpit", "config.json"), + JSON.stringify({ statusline: { preset: "minimal" } }), + ) + await Bun.write( + join(project, ".cockpit.json"), + JSON.stringify({ + status: { + surface: "bottom", + segments: [ + { type: "text", value: "STATUSLINE-DREW" }, + { type: "command", name: "smoke" }, + ], + commands: { smoke: { run: "printf 'COMMAND-RAN'", intervalMs: 250 } }, + }, + /** A value of the wrong kind in Review's section: the pane has to say so in a `!` row. */ + review: { source: 5 }, + }), + ) + + const env = { + ...process.env, + XDG_CONFIG_HOME: config, + /** OpenCode's kv lives here: without its own, a run writes plugin state into the user's real one. */ + XDG_STATE_HOME: join(work, "state"), + COCKPIT_HOME: home, + TERM: "xterm-256color", + } + return { + mode, + work, + project, + env, + bays, + agentSide: serverEntries.length > 0, + entryOf: (bay) => (mode === "bundle" ? bundleDir : bayDir(bay)), + } +} diff --git a/scripts/smoke/probe.ts b/scripts/smoke/probe.ts new file mode 100644 index 00000000..4c932607 --- /dev/null +++ b/scripts/smoke/probe.ts @@ -0,0 +1,29 @@ +/** + * A probe: one bay's steps against a real OpenCode, and what each has to show. The entry runs every + * probe's steps a stage at a time — so all of them share one OpenCode — and only for bays installed. + */ + +import type { Feature as Bay } from "../../packages/opencode/src/features.ts" +import type { AgentAsk } from "./agent.ts" +import type { Install } from "./install.ts" + +export interface Probe { + /** The bay it proves, or `setup`, whose palette entry every interface entry offers. */ + name: Bay | "setup" + /** What typing "cockpit " into the palette has to list. */ + listed: string + /** In the project's OpenCode, first: what the bay draws, and its own keys. */ + open?(install: Install): Promise + /** Its commands, run from the palette, once the screen is at rest. */ + palette?(install: Install): Promise + /** Its slash names; then the AGENT=1 steps that start a turn. */ + slash?(install: Install): Promise + /** In an OpenCode of its own, once the project's is closed. */ + apart?(install: Install): Promise + /** AGENT=1: the sidebar of a conversation nothing has happened in. */ + empty?(drawn: string): void + /** AGENT=1: what the agent turn has to call, and be told. */ + ask?: AgentAsk + /** AGENT=1: its behaviour measurement. */ + measure?(install: Install): void +} diff --git a/scripts/smoke/probes/review.ts b/scripts/smoke/probes/review.ts new file mode 100644 index 00000000..73a68416 --- /dev/null +++ b/scripts/smoke/probes/review.ts @@ -0,0 +1,52 @@ +/** + * Review: the panel draws the project's uncommitted change and names a setting from before 0.9, and + * the palette opens it full screen. + */ + +import { measure } from "../agent.ts" +import { palette } from "../commands.ts" +import { expect, keep, pass, screen, type } from "../harness.ts" +import type { Probe } from "../probe.ts" + +export const review: Probe = { + name: "review", + listed: "Open or close the changes", + /** + * The review panel, from the same published build. + * + * It reads the repository rather than the session, so the project is a real checkout with one + * uncommitted change — and the file is named for this test, so a panel that draws *something* + * cannot pass for a panel that drew the diff. + */ + async open() { + await type("\x18v", 3000) + const drawn = await screen() + await type("\x18v", 1500) // close the review again + keep(drawn) + for (const [what, marker] of [ + ["the review panel never drew", "review"], + ["the review panel drew no diff", "SMOKE-REVIEW"], + ["the review panel never named its broken setting", '"review.source" should be'], + ] as const) { + expect(drawn.includes(marker), what, drawn) + } + pass("review drew its diff and named its broken setting") + }, + /** Palette-only: closed, it used to change nothing on screen; now it opens, full screen. */ + async palette() { + const ran = await palette("Toggle the changes full screen", 3000) + /** Each surface is closed before the next: an open review takes the keys, `ctrl+p` included. */ + await type("\x1b", 1200) + keep(ran) + expect(ran.includes("SMOKE-REVIEW"), "the palette's full-screen toggle never opened the changes", ran) + /** Full screen puts the header on the top row; the pane leaves it to the conversation. */ + expect( + ran.split("\n")[0]?.includes("review"), + "the palette's toggle opened the changes, but not full screen", + ran, + ) + }, + ask: { call: "review_list", tool: "review_list", heading: "## Review comments" }, + /** A waiting comment is read with review_list and answered with review_reply. */ + measure: (install) => measure(install, "review", "Review's", ["--runs", "1"]), +} diff --git a/scripts/smoke/probes/setup.ts b/scripts/smoke/probes/setup.ts new file mode 100644 index 00000000..0e9667e8 --- /dev/null +++ b/scripts/smoke/probes/setup.ts @@ -0,0 +1,112 @@ +/** + * Setting Cockpit up with the agent. Every interface entry offers the palette entry; only an agent side + * ships `/cockpit-setup` and the `cockpit_settings` tool — so an install of Trust or the updater alone + * has the entry and no slash name. + */ + +import { join } from "node:path" +import { offered, popup, shipped } from "../commands.ts" +import { + agent, + expect, + keep, + kill, + launch, + pass, + rightHalf, + run, + screen, + seen, + type, + until, + v2, +} from "../harness.ts" +import type { Install } from "../install.ts" +import type { Probe } from "../probe.ts" +import { TOKENS_ROW } from "./status.ts" + +/** The line `/cockpit-setup` sends: once it is in the conversation, the command ran. */ +const SETUP_LINE = "Use the cockpit-setup skill to help me set up Cockpit." +/** The skill loaded, and its first step taken — the tool it reads the live state with. Either version. */ +const SETUP_SKILL_USED = [/Skill "cockpit-setup"/, /[⚙›] cockpit_settings/] +/** A prompt sent while the agent answers, waiting its turn: OpenCode 1's tag, OpenCode 2's line. */ +const QUEUED = /QUEUED|1 queued · Use the cockpit-setup skill/ + +export const setup: Probe = { + name: "setup", + listed: "Ask the agent to set up Cockpit", + async slash(install) { + const listed = await popup("/cockpit-se") + keep(listed) + /** Once: the shipped command's row, and no second one from the interface. None with no agent side. */ + const rows = offered(listed, "/cockpit-setup") + const want = install.agentSide ? 1 : 0 + expect(rows === want, `the slash popup offered /cockpit-setup ${rows} times, not ${want}`, listed) + pass( + install.agentSide + ? "/cockpit-setup offered once as it was typed" + : "/cockpit-setup not offered, with no agent side to ship it", + ) + }, + ask: { call: "cockpit_settings", tool: "cockpit_settings" }, +} + +/** + * AGENT=1: a second OpenCode, in a project nothing has happened in. `/cockpit-setup` from home has + * to open a conversation, and the agent there has to load the `cockpit-setup` skill and call + * `cockpit_settings` — the skill's first step. Run again while the agent answers, it has to queue + * behind the reply rather than cut it off. With the conversation open the sidebar draws: the screen + * returned is for each bay's `empty` to read — every block that lists something has to say it is + * there while it is empty. + */ +export async function presence(install: Install): Promise { + const fresh = join(install.work, "fresh") + await Bun.write(join(fresh, "README.md"), "fresh\n") + for (const cmd of [ + ["git", "init", "-q", "-b", "main"], + ["git", "config", "user.email", "smoke@example.com"], + ["git", "config", "user.name", "Smoke"], + ["git", "add", "-A"], + ["git", "commit", "-qm", "fresh"], + ]) { + run(cmd, fresh) + } + /** The skill has the agent read Cockpit's config, outside the project: nothing may wait on a prompt. */ + launch(install.env, fresh, v2 ? ["--auto"] : []) + await Bun.sleep(14_000) + await shipped("cockpit-setup") + const opened = await until(20_000, (text) => text.includes(SETUP_LINE)) + /** Again, while the agent is still answering the first. */ + await type("\x15", 300) + await shipped("cockpit-setup") + const queued = await until(8000, (text) => QUEUED.test(text)) + const used = await seen(180_000, SETUP_SKILL_USED) + /** Status's table fills its tokens row once the reply is in: what says the sidebar has drawn. */ + const tokens = install.bays.includes("status") + ? await until(60_000, (text) => TOKENS_ROW.test(rightHalf(text))) + : await screen() + await Bun.sleep(3000) + const settled = await screen() + kill() + const drawn = TOKENS_ROW.test(rightHalf(settled)) ? settled : tokens + keep(drawn) + if (process.env.SMOKE_SHOW) console.log(drawn) + expect( + opened.includes(SETUP_LINE), + "/cockpit-setup from home never opened a conversation with its line", + opened, + ) + expect( + used.all, + `/cockpit-setup's agent never used the skill (missing ${used.missing.join(", ")})`, + used.last, + ) + expect(QUEUED.test(queued), "/cockpit-setup while the agent answered never queued", queued) + pass( + "/cockpit-setup from home opened a conversation whose agent loaded the cockpit-setup skill and called cockpit_settings, and queued behind the reply", + ) + return drawn +} + +/** Whether `presence` can run: it needs a model, and the agent side that ships `/cockpit-setup`. */ +export const canPresence = (install: Install) => agent && install.agentSide diff --git a/scripts/smoke/probes/shell.ts b/scripts/smoke/probes/shell.ts new file mode 100644 index 00000000..8ca2e3b7 --- /dev/null +++ b/scripts/smoke/probes/shell.ts @@ -0,0 +1,92 @@ +/** + * Shell: a background shell started from the palette keeps its panel updating, and its console opens + * — in the dialog and full screen. + * + * Why it exists: OpenCode only Solid-compiles plugin JSX outside `node_modules`, so a published + * plugin can load, log, and talk to the daemon while rendering exactly one frozen frame (0.1.3 and + * 0.1.4 shipped that way). + */ + +import { measure } from "../agent.ts" +import { expect, keep, pass, screen, type, under } from "../harness.ts" +import type { Probe } from "../probe.ts" + +const ticks = (text: string) => [...text.matchAll(/tick (\d+)/g)].map((m) => Number(m[1])) + +export const shell: Probe = { + name: "shell", + listed: "Start a background shell", + async open() { + await type("\x10", 1000) // ctrl+p command palette + await type("Start a background shell", 1200) + await type("\r", 1200) + await type("i=0; while true; do i=$((i+1)); echo tick $i; sleep 1; done", 300) + await type("\r", 3500) + + const first = await screen() + await Bun.sleep(4000) + const second = await screen() + + /** + * The console, which was never opened here — so a crash on open was never caught here either. + * Pressing `?` walks both halves of the key row: the keys that act, and the rest in the panel. + */ + await type("\x18j", 3000) // ctrl+x j + const consoleScreen = await screen() + await type("?", 1500) + const consoleDetails = await screen() + await type("\x1b", 800) // esc, back to the conversation + + /** + * Full screen, which neither version's run opened before — so on OpenCode 2 it could draw nothing + * and still pass. `w` swaps the dialog for it and is remembered, so it is swapped back before leaving. + */ + await type("\x18j", 3000) + await type("w", 2500) + const fullScreen = await screen() + await type("w", 1500) + await type("\x1b", 800) + keep(first, second, consoleScreen, fullScreen) + if (process.env.SMOKE_SHOW) console.log(fullScreen) + + const firstMax = Math.max(0, ...ticks(first)) + const secondMax = Math.max(0, ...ticks(second)) + expect(firstMax > 0, "the panel never showed the shell's output", first) + expect( + secondMax > firstMax, + `the panel froze: still at tick ${firstMax} after 4s (published JSX not Solid-compiled?)`, + second, + ) + pass(`panel live, tick ${firstMax} → ${secondMax}`) + + for (const [what, marker] of [ + ["the console never drew its keys", "[?]"], + ["the console's action keys never drew", "[r]"], + ] as const) { + expect(consoleScreen.includes(marker), what, consoleScreen) + } + expect(consoleDetails.includes("keys"), "the details panel never listed the other keys", consoleDetails) + pass("console and its keys drew") + /** The dialog sits inside the host's frame; only full screen puts the header on the top row. */ + expect( + fullScreen.split("\n")[0]?.includes("RUN") && /^ {2}│ tick \d+/m.test(fullScreen), + "full screen never drew the console across the window", + fullScreen, + ) + pass("full screen drew") + }, + empty(drawn) { + expect( + under(drawn, "Shells")?.startsWith("none yet"), + `the sidebar never drew "Shells" with "none yet" under it`, + drawn, + ) + }, + ask: { + call: "the tool shell_start with command 'echo AGENT-SHELL-OK' and description 'agent probe'", + tool: "shell_start", + heading: "## Background shells", + }, + /** "start the dev server" goes in shell_start, and a second conversation reuses it rather than starting another. */ + measure: (install) => measure(install, "shell", "Shell's", ["--runs", "1"]), +} diff --git a/scripts/smoke/probes/status.ts b/scripts/smoke/probes/status.ts new file mode 100644 index 00000000..e3595c45 --- /dev/null +++ b/scripts/smoke/probes/status.ts @@ -0,0 +1,142 @@ +/** + * Status: the statusline draws from a published build and names a section nothing reads; its setup + * command is offered once; and its sidebar table flags an MCP server that failed, and only that one. + */ + +import { join } from "node:path" +import { offered, popup, shipped } from "../commands.ts" +import { agent, expect, keep, kill, launch, pass, rightHalf, screen, seen, type, until } from "../harness.ts" +import type { Probe } from "../probe.ts" + +/** The Status table's token row, which only a conversation with a reply in it fills. */ +export const TOKENS_ROW = / tokens [\d.]+k? · \d+%/ +/** `/status-setup`'s line, and the skill it names, loaded. */ +const STATUS_SKILL_USED = [/Use the status-setup skill/, /Skill "status-setup"/] +/** The section's name before 0.9: not a setting now, and the nearest one offered. */ +const OLD_SECTION = `! settings: "statusline" is not a` +/** A minimal stdio MCP server: answers initialize, lists one tool, runs it. Run with Bun. */ +const OK_MCP = `import { createInterface } from "node:readline" +const send = (msg) => process.stdout.write(JSON.stringify(msg) + "\\n") +createInterface({ input: process.stdin }).on("line", (line) => { + let req + try { req = JSON.parse(line) } catch { return } + if (req.id === undefined) return + if (req.method === "initialize") + return send({ jsonrpc: "2.0", id: req.id, result: { + protocolVersion: req.params?.protocolVersion ?? "2024-11-05", + capabilities: { tools: {} }, + serverInfo: { name: "ok-test", version: "1.0.0" }, + } }) + if (req.method === "tools/list") + return send({ jsonrpc: "2.0", id: req.id, result: { tools: [{ + name: "ping", description: "Answers pong.", inputSchema: { type: "object", properties: {} }, + }] } }) + if (req.method === "tools/call") + return send({ jsonrpc: "2.0", id: req.id, result: { content: [{ type: "text", text: "pong" }] } }) + send({ jsonrpc: "2.0", id: req.id, result: {} }) +}) +` + +export const status: Probe = { + name: "status", + listed: "Ask the agent to set up the status bay", + /** + * The statusline half. It shares this run rather than having its own, because what is being + * proved is the same thing for every bay: that published, pre-compiled JSX actually renders + * inside OpenCode. A statusline that never drew would otherwise reach users exactly the way the + * frozen shell panel did in 0.1.3. + */ + async open() { + const markers = ["STATUSLINE-DREW", "COMMAND-RAN", OLD_SECTION] + const drawn = await until(15_000, (text) => markers.every((marker) => text.includes(marker))) + keep(drawn) + for (const [what, marker] of [ + ["the statusline never drew", "STATUSLINE-DREW"], + ["the statusline's command segment never ran", "COMMAND-RAN"], + ] as const) { + expect(drawn.includes(marker), what, drawn) + } + expect(drawn.includes(OLD_SECTION), `Status never named the old "statusline" section`, drawn) + pass("statusline drew and named its old section") + }, + async slash() { + const listed = await popup("/status-se") + keep(listed) + /** Once: the shipped command's row, and no second one from the interface. */ + const rows = offered(listed, "/status-setup") + expect(rows === 1, `the slash popup offered /status-setup ${rows} times, not once`, listed) + pass("/status-setup offered once as it was typed") + if (!agent) return + /** + * AGENT=1: the command sends its line and the skill it names is loaded. In the conversation the + * subagent run left open, last, because it starts a turn. + */ + /** Whatever an earlier step left in the prompt goes first, or the name is typed after it. */ + await type("\x15", 300) + await shipped("status-setup") + const used = await seen(120_000, STATUS_SKILL_USED) + /** The skill asks a question next; `esc` dismisses it so nothing is left waiting. */ + await type("\x1b", 1000) + expect( + used.all, + `/status-setup never ran the status-setup skill (missing ${used.missing.join(", ")})`, + used.last, + ) + pass("/status-setup ran the status-setup skill") + }, + /** + * Status's `diagnostics` row in the sidebar table, on both versions (#34: OpenCode 2 hands a + * server's status as `{ status: "connected" }`, and every healthy server was flagged). A project + * with two MCP servers: a small stdio server that answers, and one whose command does not exist. + * The table — the default surface, nothing configures it here — draws in a conversation, so one + * short turn is sent (a free model; the only turn outside AGENT=1). Then, with the servers given + * time to connect or fail: no `! ok-test`, and `! broken-test` drawn, which proves the row was live. + * One `opencode.json` for both: 2.0 reads `mcp.servers`, 1.18 the flat keys (2.0 ignored a .jsonc). + */ + async apart(install) { + const at = join(install.work, "mcp") + const okMcp = join(install.work, "ok-mcp.ts") + await Bun.write(okMcp, OK_MCP) + const servers = { + "ok-test": { type: "local", command: [process.execPath, okMcp] }, + "broken-test": { type: "local", command: ["cockpit-does-not-exist"] }, + } + await Bun.write( + join(at, "opencode.json"), + JSON.stringify({ model: "opencode/space-bunny-free", mcp: { servers, ...servers } }), + ) + await Bun.write(join(at, "README.md"), "mcp\n") + launch(install.env, at) + await Bun.sleep(14_000) + await type("Reply with just the word hi.", 400) + await type("\r", 1000) + const drawn = await until(90_000, (text) => /! broken-test/.test(rightHalf(text))) + /** A healthy server that is flagged at all may be flagged only once it has connected. */ + await Bun.sleep(8000) + const settled = await screen() + kill() + keep(settled) + if (process.env.SMOKE_SHOW) console.log(settled) + expect( + /! broken-test/.test(rightHalf(drawn)) && /! broken-test/.test(rightHalf(settled)), + "the Status table never flagged the broken MCP server", + settled, + ) + for (const text of [drawn, settled]) { + expect(!/!\s+ok-test/.test(text), "the Status table flagged a healthy MCP server", text) + } + pass("the Status table flagged the broken MCP server and not the healthy one") + }, + /** + * The Status table, which nothing configures in a fresh project, has to be the default surface, + * with the global file's old `statusline` named in a `!` row in the sidebar. + */ + empty(drawn) { + expect(TOKENS_ROW.test(rightHalf(drawn)), "the sidebar never drew the Status table's tokens row", drawn) + expect( + rightHalf(drawn).includes(OLD_SECTION), + `the sidebar never named the old "statusline" section`, + drawn, + ) + }, +} diff --git a/scripts/smoke/probes/subagents.ts b/scripts/smoke/probes/subagents.ts new file mode 100644 index 00000000..0dc687b6 --- /dev/null +++ b/scripts/smoke/probes/subagents.ts @@ -0,0 +1,156 @@ +/** + * Subagents: its clear answers from the palette; with AGENT=1, a real subagent shows in the sidebar + * and its full screen opens, takes messages and relays the answer. + */ + +import { palette, slash } from "../commands.ts" +import { agent, cols, expect, keep, pass, rightHalf, screen, type, under, until } from "../harness.ts" +import type { Probe } from "../probe.ts" + +/** + * A subagent's row in the sidebar: its status glyph, then the agent's name in the block's agent column, + * eight cells at most, so `explore` whole (the old `expl…`/`exp…` still read, for an older build). + */ +const EXPLORE_ROW = /[●○⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] exp(lore|l?o?…) / + +/** Found again before every click: the blocks above it (the statusline's) grow as the turn runs. */ +const clickSubagent = async () => { + const lines = (await screen()).split("\n") + const right = Math.floor(cols / 2) + const y = lines.findIndex((line) => EXPLORE_ROW.test(line.slice(right))) + if (y < 0) throw new Error(`the sidebar never showed the subagent:\n${lines.join("\n")}`) + const x = right + ((lines[y] as string).slice(right).search(EXPLORE_ROW) as number) + 3 + await type(`\x1b[<0;${x + 1};${y + 1}M`, 80) + await type(`\x1b[<0;${x + 1};${y + 1}m`, 2500) +} + +/** + * AGENT=1: a real subagent, launched from the interface. The sidebar has to show it while it works, + * a click on it has to open the full screen with its run, and `/subagents` has to open that screen + * rather than OpenCode's own `/agents`, whose name it contains. + */ +const inTheInterface = async () => { + await type( + "Use the task/subagent tool to launch an explore subagent with the task: read SMOKE-REVIEW.ts and report its export. Wait for it, then reply DONE.", + 400, + ) + await type("\r", 1000) + let sidebar = "" + for (let i = 0; i < 45 && !EXPLORE_ROW.test(sidebar); i++) { + await Bun.sleep(2000) + sidebar = await screen() + } + await Bun.sleep(4000) + await clickSubagent() + const full = await screen() + /** `?` swaps the run for every key the pane takes, and `?` again brings the run back. */ + await type("?", 1200) + const keys = await screen() + await type("?", 1000) + /** + * The cursor onto the last item and open it, then a message typed into the pane, not a dialog. + * Each key waits on what it needs on screen rather than a fixed time: the run's first item can + * take a while to arrive on a slow turn, and `enter` with no cursor yet opens nothing. + */ + await until(30_000, (text) => /[›⌄◇◆] /.test(rightHalf(text))) + await type("k", 300) + await until(5000, (text) => text.includes("▌ ")) + await type("\r", 1200) + const toggled = await screen() + await type("m", 600) + await type("hello there", 1200) + const typing = await screen() + await type("\x1b", 800) + await type("q", 1500) + const slashed = await slash("subagents") + /** + * A message to the finished subagent: it answers, and the exchange is added to the main + * conversation without starting a turn there (v1 draws it as a message, v2 as one line). + */ + await clickSubagent() + /** The relay is for a finished subagent: one still at work (or held on a permission) answers its run. */ + const finished = (await screen()).includes("done in") + await type("m", 600) + /** Half typed, half pasted — a paste is one event, not keys, and used to land in OpenCode's prompt. */ + await type("Reply with just the word ", 600) + await type("\x1b[200~RELAY-OK.\x1b[201~", 1000) + const pasted = await screen() + await type("\r", 2000) + let relayed = await screen() + for (let i = 0; i < 40 && !relayed.includes("The main agent now knows"); i++) { + await Bun.sleep(2000) + relayed = await screen() + } + await type("q", 1500) + /** Whatever an earlier step left in the prompt goes, or its menu covers the conversation. */ + await type("\x15", 300) + await type("\x1b", 1500) + const conversation = await screen() + /** `x` on a finished subagent takes it off the list; on a working one it asks first. */ + await clickSubagent() + await type("x", 1500) + const removed = await screen() + await type("q", 1000) + + for (const [what, text, marker] of [ + ["the sidebar never showed the Subagents block", sidebar, "Subagents"], + ["a click on the subagent never opened its full screen", full, "EXPLORE"], + ["the full screen never drew its keys", full, "[m] Message"], + ["the full screen never offered every key", full, "[?] Keys"], + ["? never showed every key", keys, "KEYS"], + ["the keys screen never said how back", keys, "[esc] Hide Keys"], + ["enter never opened the selected item", toggled, "▌ "], + ["m never opened the message input in the pane", typing, "┃ hello there"], + ["/subagents never opened the full screen", slashed, "[m] Message"], + ] as const) { + expect(text.includes(marker), what, text) + } + /** The heading's count reaches the sidebar's edge whole: rows drawn wider than it were clipped. */ + expect( + /Subagents +\d+ (running|done|failed)\b/.test(sidebar), + "the sidebar's Subagents heading was clipped", + sidebar, + ) + expect( + pasted.includes("┃ Reply with just the word RELAY-OK."), + "a paste never reached the message field", + pasted, + ) + if (finished) { + expect( + relayed.includes("The main agent now knows"), + "the subagent's answer was never relayed to the main agent", + relayed, + ) + expect( + /Cockpit notification|Subagent exchange/.test(conversation), + "the main conversation never showed the relayed exchange", + conversation, + ) + } else console.log("relay not checked: the subagent had not finished when the message was sent") + const asked = removed.includes("Press x again") + const listed = EXPLORE_ROW.test(removed) + expect(asked || !listed, "x neither removed the subagent nor asked to stop it", removed) + pass("a subagent showed in the sidebar and opened full screen, by click and by /subagents, with its keys") +} + +export const subagents: Probe = { + name: "subagents", + listed: "Open the subagents", + async palette() { + const ran = await palette("Clear finished subagents", 1200) + keep(ran) + expect(ran.includes("No finished subagents to clear."), "the palette's clear never answered", ran) + }, + async slash() { + if (agent) await inTheInterface() + }, + empty(drawn) { + expect( + under(drawn, "Subagents")?.startsWith("none yet"), + `the sidebar never drew "Subagents" with "none yet" under it`, + drawn, + ) + }, + ask: { call: "subagents_list", tool: "subagents_list", heading: "## Subagents" }, +} diff --git a/scripts/smoke/probes/trail.ts b/scripts/smoke/probes/trail.ts new file mode 100644 index 00000000..6815e317 --- /dev/null +++ b/scripts/smoke/probes/trail.ts @@ -0,0 +1,33 @@ +/** Trail: its screen opens from the palette, and says so when nothing is recorded yet. */ + +import { measure } from "../agent.ts" +import { palette } from "../commands.ts" +import { expect, keep, pass, type, under } from "../harness.ts" +import type { Probe } from "../probe.ts" + +export const trail: Probe = { + name: "trail", + listed: "Show what this conversation made", + /** From home there is no conversation, so Trail opens on the project's records: none, and says so. */ + async palette() { + const ran = await palette("Show what this conversation made", 2500) + await type("\x1b", 1200) + keep(ran) + for (const marker of ["Nothing recorded in this project yet.", "[esc] Close"]) { + expect(ran.includes(marker), `the palette's Trail never drew "${marker}"`, ran) + } + pass("trail opened empty") + }, + empty(drawn) { + expect( + under(drawn, "Trail")?.startsWith("none yet"), + `the sidebar never drew "Trail" with "none yet" under it`, + drawn, + ) + }, + /** + * A turn that opens a PR (with a fake `gh`) records it. Trail's on a free model misses about one + * turn in six, so it is two of three; the others still one of one. + */ + measure: (install) => measure(install, "trail", "Trail's", ["--runs", "3", "--pass", "2"]), +} diff --git a/scripts/smoke/probes/trust.ts b/scripts/smoke/probes/trust.ts new file mode 100644 index 00000000..c81f1907 --- /dev/null +++ b/scripts/smoke/probes/trust.ts @@ -0,0 +1,23 @@ +/** Trust: its sidebar toggle says what it did, and its activity screen opens, from the palette. */ + +import { palette } from "../commands.ts" +import { expect, keep, type } from "../harness.ts" +import type { Probe } from "../probe.ts" + +export const trust: Probe = { + name: "trust", + listed: "Show what Trust answers for you", + async palette() { + const toggled = await palette("Show or hide Trust in the sidebar", 1200) + const ledger = await palette("Show what Trust answers for you", 2500) + await type("\x1b", 1200) + keep(toggled, ledger) + expect( + toggled.includes("Shown in the sidebar."), + "the palette's Trust sidebar toggle said nothing", + toggled, + ) + /** /trust opens on what Trust did; the ledger is one key further, behind `l`. */ + expect(ledger.includes("Open the ledger"), "the palette never opened Trust's activity screen", ledger) + }, +} diff --git a/scripts/smoke/probes/updater.ts b/scripts/smoke/probes/updater.ts new file mode 100644 index 00000000..f8995b33 --- /dev/null +++ b/scripts/smoke/probes/updater.ts @@ -0,0 +1,36 @@ +/** The updater: its dialog opens from the palette and by its slash name. */ + +import { palette, slash } from "../commands.ts" +import { expect, keep, pass, type, v2 } from "../harness.ts" +import type { Probe } from "../probe.ts" + +/** OpenCode 2 updates plugins itself; there the commands point at it instead of opening the dialog. */ +const drewDialog = (what: string, text: string) => { + for (const marker of v2 ? ["change the version"] : ["Plugins", "published", "local", "Review"]) { + expect(text.includes(marker), `${what} did not draw "${marker}"`, text) + } +} + +export const updater: Probe = { + name: "updater", + listed: "Update plugins", + async palette() { + const ran = await palette("Update plugins", 4000) + await type("\x1b", 1200) + keep(ran) + drewDialog("the palette's Update plugins", ran) + }, + /** + * The updater's dialog, opened by its slash name and by the old one it replaced — two slash names + * for one surface is exactly what docs/opencode/keys-and-commands.md warns can break the popup. + * Every plugin here is a local path, so the list must say so, and nothing may be written. + */ + async slash() { + const drawn = await slash("plugins-update") + keep(drawn) + // `SMOKE_SHOW=1 bun run smoke:tui` prints the updater's frame: a marker proves it drew, not how. + if (process.env.SMOKE_SHOW) console.log(drawn) + drewDialog("/plugins-update", drawn) + pass("updater answered its slash name") + }, +} diff --git a/scripts/tui-smoke.ts b/scripts/tui-smoke.ts index 7825d148..e5b4ae2f 100644 --- a/scripts/tui-smoke.ts +++ b/scripts/tui-smoke.ts @@ -2,931 +2,133 @@ * Drives a real OpenCode against the packed packages installed into `node_modules`, and asserts * the Shell panel keeps updating — and that every bay's commands are found and run from `ctrl+p`. * - * bun scripts/tui-smoke.ts + * bun scripts/tui-smoke.ts every bay as its own package, side by side + * SMOKE_INSTALL=bundle bun scripts/tui-smoke.ts the `opencode-cockpit` bundle alone + * SMOKE_INSTALL=trust bun scripts/tui-smoke.ts one bay alone (any of the bundle's FEATURES) + * + * `OPENCODE=` picks the binary, `AGENT=1` adds the model turns, `KEEP=1` keeps the install, + * `SMOKE_SHOW=1` prints the frames a marker cannot describe. * * Why it exists: OpenCode only Solid-compiles plugin JSX outside `node_modules`, so a published * plugin can load, log, and talk to the daemon while rendering exactly one frozen frame (0.1.3 and * 0.1.4 shipped that way). Only a real OpenCode can prove the rendering path; unit tests and the * pack check cannot. Needs the `opencode` binary, so it stays out of CI. + * + * The steps and what they assert live in one probe per bay (`smoke/probes/`); this file decides the + * order they run in, a stage at a time, and runs a probe only when its bay is installed. */ -import { mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs" -import { join } from "node:path" -import { Terminal } from "@xterm/headless" -import { FEATURES } from "../packages/opencode/src/features.ts" - -const root = join(import.meta.dir, "..") -/** OPENCODE picks the binary, so the same test can drive v1 and v2 side by side. */ -const opencode = process.env.OPENCODE ?? Bun.which("opencode") -if (!opencode) { - console.error("opencode binary not found; install OpenCode to run this smoke test") - process.exit(1) -} - -/** Which OpenCode this is decides where plugins are configured and how it is started. */ -const v2 = Bun.spawnSync([opencode, "--version"]) - .stdout.toString() - .trim() - .replace(/^opencode\s+v?/, "") - .startsWith("2") -console.log(`smoke against OpenCode ${v2 ? "2" : "1"} (${opencode})`) - -const work = mkdtempSync("/tmp/ck-smoke-") -const install = join(work, "install") -const project = join(work, "project") -const config = join(work, "config") -const home = join(work, "home") - -const run = (cmd: string[], cwd: string) => { - const result = Bun.spawnSync(cmd, { cwd, stdout: "pipe", stderr: "pipe" }) - if (result.exitCode !== 0) throw new Error(`$ ${cmd.join(" ")}\n${result.stdout}\n${result.stderr}`) - return result.stdout.toString() -} - -/** - * `AGENT=1`: one real turn, by a free OpenCode Zen model, against the server halves — the only proof - * that the tools registered and the system prompt carries the guidance, on either version. Needs the - * network and a model willing to follow instructions, so it is opt-in. - */ -function agentTurn(env: Record) { - const prompt = [ - "Call the tool shell_start with command 'echo AGENT-SHELL-OK' and description 'agent probe', then call review_list, then call subagents_list, then call cockpit_settings.", - "Your system prompt has heading lines starting with '## Background shells', '## Review comments' and '## Subagents'.", - "Quote all three heading lines exactly in your reply.", - ].join(" ") - const args = [ - opencode as string, - "run", - ...(v2 ? ["--standalone", "--auto"] : []), - "-m", - "opencode/space-bunny-free", - ] - /** Bounded, and stdin closed: an open stdin or a permission prompt makes `opencode run` wait forever. */ - const result = Bun.spawnSync([...args, "--format", "json", prompt], { - cwd: project, - env, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - timeout: 300_000, - }) - if (result.exitCode === null || result.signalCode) - throw new Error(`the agent turn never finished (5 min):\n${result.stdout.toString().slice(-3000)}`) - const events = result.stdout - .toString() - .split("\n") - .filter((line) => line.startsWith("{")) - .map((line) => JSON.parse(line) as { type: string; part?: Record }) - /** v2 runs plugin tools through Code Mode: the calls are listed on its `execute` part. */ - const called = events - .filter((event) => event.type === "tool_use") - .flatMap((event) => { - const part = event.part as unknown as { - tool: string - state: { - status: string - metadata?: { metadata?: { toolCalls?: { tool: string; status: string }[] } } - } - } - return [ - { tool: part.tool, status: part.state.status }, - ...(part.state.metadata?.metadata?.toolCalls ?? []), - ] - }) - .filter((call) => call.status === "completed") - .map((call) => call.tool) - const said = events - .filter((event) => event.type === "text") - .map((event) => (event.part as unknown as { text: string }).text) - .join("\n") - const report = `${result.stdout}\n${result.stderr}`.slice(-3000) - for (const name of ["shell_start", "review_list", "subagents_list", "cockpit_settings"]) { - if (!called.includes(name)) throw new Error(`the agent never completed ${name}:\n${report}`) - } - for (const heading of ["## Background shells", "## Review comments", "## Subagents"]) { - if (!said.includes(heading)) throw new Error(`the agent was never told "${heading}":\n${report}`) - } -} - -const cols = Number(process.env.SMOKE_COLS) || 150 -const rows = 40 -/** One per OpenCode started: the second run (AGENT=1) draws on a clean screen of its own. */ -let term = new Terminal({ cols, rows, allowProposedApi: true }) -const screen = async () => { - await new Promise((done) => term.write("", done)) - const buffer = term.buffer.active - return Array.from( - { length: rows }, - (_, y) => buffer.getLine(buffer.baseY + y)?.translateToString(true) ?? "", - ).join("\n") -} -/** The sidebar's half of a screen. */ -const rightHalf = (text: string) => - text - .split("\n") - .map((line) => line.slice(Math.floor(cols / 2))) - .join("\n") -/** - * Which of `patterns` showed on some screen within `ms` — not all on one: a turn scrolls the first - * out of view before the last arrives. Done as soon as every one has. - */ -const seen = async (ms: number, patterns: readonly RegExp[]) => { - const found = new Set() - for (const end = Date.now() + ms; found.size < patterns.length && Date.now() < end; await Bun.sleep(250)) { - const text = await screen() - for (const [at, pattern] of patterns.entries()) if (pattern.test(text)) found.add(at) - } - return { - all: found.size === patterns.length, - missing: patterns.filter((_, at) => !found.has(at)), - last: await screen(), - } -} -/** Reads the screen until `done` says so, or `ms` runs out; the last screen either way. */ -const until = async (ms: number, done: (text: string) => boolean) => { - let text = await screen() - for (const end = Date.now() + ms; !done(text) && Date.now() < end; text = await screen()) { - await Bun.sleep(250) - } - return text -} -/** - * A block's heading in the sidebar, and the first row under it (past the heading's air) — read in - * the heading's own column, so the conversation beside it cannot answer for the block. - */ -const under = (text: string, heading: string): string | undefined => { - const lines = text.split("\n") - const right = Math.floor(cols / 2) - for (const [y, line] of lines.entries()) { - const at = line.slice(right).search(new RegExp(`(^|\\s)${heading}(\\s|$)`)) - if (at < 0) continue - const x = right + at + (line[right + at] === " " ? 1 : 0) - const next = lines.slice(y + 1, y + 4).find((row) => row.slice(x).trim()) - return next?.slice(x).trim() - } - return undefined -} -/** - * A subagent's row in the sidebar: its status glyph, then the agent's name in the block's agent column, - * eight cells at most, so `explore` whole (the old `expl…`/`exp…` still read, for an older build). - */ -const EXPLORE_ROW = /[●○⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] exp(lore|l?o?…) / -/** The Status table's token row, which only a conversation with a reply in it fills. */ -const TOKENS_ROW = / tokens [\d.]+k? · \d+%/ -/** The line `/cockpit-setup` sends: once it is in the conversation, the command ran. */ -const SETUP_LINE = "Use the cockpit-setup skill to help me set up Cockpit." -/** The skill loaded, and its first step taken — the tool it reads the live state with. Either version. */ -const SETUP_SKILL_USED = [/Skill "cockpit-setup"/, /[⚙›] cockpit_settings/] -/** `/statusline`'s line, which says the new name first, and the skill it names, loaded. */ -const STATUS_SKILL_USED = [ - /\/statusline is now \/status-setup\. Use the status-setup skill/, - /Skill "status-setup"/, -] -/** A prompt sent while the agent answers, waiting its turn: OpenCode 1's tag, OpenCode 2's line. */ -const QUEUED = /QUEUED|1 queued · Use the cockpit-setup skill/ -/** A minimal stdio MCP server: answers initialize, lists one tool, runs it. Run with Bun. */ -const OK_MCP = `import { createInterface } from "node:readline" -const send = (msg) => process.stdout.write(JSON.stringify(msg) + "\\n") -createInterface({ input: process.stdin }).on("line", (line) => { - let req - try { req = JSON.parse(line) } catch { return } - if (req.id === undefined) return - if (req.method === "initialize") - return send({ jsonrpc: "2.0", id: req.id, result: { - protocolVersion: req.params?.protocolVersion ?? "2024-11-05", - capabilities: { tools: {} }, - serverInfo: { name: "ok-test", version: "1.0.0" }, - } }) - if (req.method === "tools/list") - return send({ jsonrpc: "2.0", id: req.id, result: { tools: [{ - name: "ping", description: "Answers pong.", inputSchema: { type: "object", properties: {} }, - }] } }) - if (req.method === "tools/call") - return send({ jsonrpc: "2.0", id: req.id, result: { content: [{ type: "text", text: "pong" }] } }) - send({ jsonrpc: "2.0", id: req.id, result: {} }) -}) -` - -/** - * Build and pack under a lock. `build` empties every `dist/` before it compiles, so a second run - * (v1 and v2 side by side, or a `dev:install`) packing at that moment shipped a bay without its - * files: OpenCode said "1 plugin failed" and Trust's commands were missing. A directory is the lock - * (`mkdir` is atomic); one older than ten minutes is left over from a killed run. - */ -const buildLock = join(root, "node_modules", ".cockpit-build.lock") -async function withBuildLock(work: () => void): Promise { - for (;;) { - try { - mkdirSync(buildLock) - break - } catch { - const age = Date.now() - (statSync(buildLock, { throwIfNoEntry: false })?.mtimeMs ?? Date.now()) - if (age > 600_000) rmSync(buildLock, { recursive: true, force: true }) - else await Bun.sleep(500) - } - } - try { - work() - } finally { - rmSync(buildLock, { recursive: true, force: true }) - } +import { rmSync } from "node:fs" +import { agentTurn } from "./smoke/agent.ts" +import { search, settle } from "./smoke/commands.ts" +import { + agent, + expect, + keep, + kill, + launch, + noPluginFailed, + opencode, + pass, + passed, + v2, +} from "./smoke/harness.ts" +import { prepare, readMode } from "./smoke/install.ts" +import type { Probe } from "./smoke/probe.ts" +import { review } from "./smoke/probes/review.ts" +import { canPresence, presence, setup } from "./smoke/probes/setup.ts" +import { shell } from "./smoke/probes/shell.ts" +import { status } from "./smoke/probes/status.ts" +import { subagents } from "./smoke/probes/subagents.ts" +import { trail } from "./smoke/probes/trail.ts" +import { trust } from "./smoke/probes/trust.ts" +import { updater } from "./smoke/probes/updater.ts" + +const mode = readMode() +console.log(`smoke against OpenCode ${v2 ? "2" : "1"} (${opencode}), install: ${mode}`) +const install = await prepare(mode) +const all = install.bays.length > 1 +/** Setup's palette entry comes with every interface entry, so its probe always runs. */ +const here = (probe: Probe) => probe.name === "setup" || install.bays.includes(probe.name) +const stage = async (step: "open" | "palette" | "slash" | "apart", probes: Probe[]) => { + for (const probe of probes) if (here(probe)) await probe[step]?.(install) } try { - const tarballs = join(work, "tarballs") - await withBuildLock(() => { - run(["bun", "run", "build"], root) - /** The plumbing, then every bay — from the bundle's own list, so a new one cannot be left out. */ - for (const dir of ["protocol", "daemon", "client", "opencode", ...FEATURES]) { - run(["bun", "pm", "pack", "--destination", tarballs], join(root, "packages", dir)) - } - }) - const names = [...new Bun.Glob("*.tgz").scanSync(tarballs)] - const file = (prefix: string) => `file:${join(tarballs, names.find((n) => n.startsWith(prefix)) as string)}` - await Bun.write( - join(install, "package.json"), - JSON.stringify({ - name: "smoke", - private: true, - dependencies: { - "@opencode-cockpit/shell": file("opencode-cockpit-shell-"), - "@opencode-cockpit/status": file("opencode-cockpit-status-"), - "@opencode-cockpit/review": file("opencode-cockpit-review-"), - "@opencode-cockpit/updater": file("opencode-cockpit-updater-"), - "@opencode-cockpit/subagents": file("opencode-cockpit-subagents-"), - "@opencode-cockpit/trust": file("opencode-cockpit-trust-"), - "@opencode-cockpit/trail": file("opencode-cockpit-trail-"), - }, - overrides: { - "@opencode-cockpit/protocol": file("opencode-cockpit-protocol-"), - "@opencode-cockpit/daemon": file("opencode-cockpit-daemon-"), - "@opencode-cockpit/client": file("opencode-cockpit-client-"), - }, - }), - ) - run(["npm", "install"], install) - - /** Review reads git, so the project is a checkout with exactly one change to show. */ - await Bun.write(join(project, "SMOKE-REVIEW.ts"), "export const answer = 41\n") - for (const cmd of [ - ["git", "init", "-q", "-b", "main"], - ["git", "config", "user.email", "smoke@example.com"], - ["git", "config", "user.name", "Smoke"], - ["git", "add", "-A"], - ["git", "commit", "-qm", "smoke"], - ]) { - run(cmd, project) - } - await Bun.write(join(project, "SMOKE-REVIEW.ts"), "export const answer = 42\n") - - // The plugins must live under node_modules: that is what disables OpenCode's Solid transform. - const bay = (name: string) => join(install, "node_modules", "@opencode-cockpit", name) - const tuiBays = [ - bay("shell"), - bay("status"), - bay("review"), - bay("updater"), - bay("subagents"), - bay("trail"), - bay("trust"), - ] - /** Status's agent side carries only the `status-setup` skill and its commands. */ - const serverBays = [bay("shell"), bay("status"), bay("review"), bay("subagents"), bay("trail")] - /** - * v1 reads `plugin` from opencode.json and tui.json; v2 reads `plugins` from opencode.json and - * cli.json (docs/opencode/v2.md). The same packages go in either way. - */ - const files: [string, string, string, string[]][] = v2 - ? [ - ["opencode.json", "https://opencode.ai/config.json", "plugins", serverBays], - ["cli.json", "https://opencode.ai/cli.json", "plugins", tuiBays], - ] - : [ - ["opencode.json", "https://opencode.ai/config.json", "plugin", serverBays], - ["tui.json", "https://opencode.ai/tui.json", "plugin", tuiBays], - ] - for (const [name, schema, key, plugins] of files) { - /** v1's schema URLs mean nothing to v2, whose loader skipped files carrying them. */ - await Bun.write( - join(config, "opencode", name), - JSON.stringify({ - ...(v2 ? {} : { $schema: schema }), - [key]: plugins, - /** A model that needs no key, for the turns AGENT=1 runs inside the interface. */ - ...(process.env.AGENT && name === "opencode.json" ? { model: "opencode/space-bunny-free" } : {}), - /** - * The setup skills read and write Cockpit's config, outside the project: a prompt it would - * wait on forever here. OpenCode 2 is started with `--auto` for the same reason. - */ - ...(process.env.AGENT && !v2 && name === "opencode.json" - ? { permission: { external_directory: "allow" } } - : {}), - }), - ) - } - /** - * A statusline whose value has to come from somewhere the plugin cannot fake: a literal marker - * proves the line drew at all, and a command segment proves the whole pipeline -- spawn, parse, - * repaint -- works from a published build. - * - * The global file carries the section's name from before 0.9, `statusline`: it is no longer read, - * and Status has to say so in a `!` row instead of drawing as if nothing had been written. - */ - await Bun.write( - join(config, "opencode-cockpit", "config.json"), - JSON.stringify({ statusline: { preset: "minimal" } }), - ) - await Bun.write( - join(project, ".cockpit.json"), - JSON.stringify({ - status: { - surface: "bottom", - segments: [ - { type: "text", value: "STATUSLINE-DREW" }, - { type: "command", name: "smoke" }, - ], - commands: { smoke: { run: "printf 'COMMAND-RAN'", intervalMs: 250 } }, - }, - /** An old name in Review's section: the pane has to say so in a `!` row. */ - review: { sidebarOrder: 3 }, - }), - ) - - const env = { - ...process.env, - XDG_CONFIG_HOME: config, - /** OpenCode's kv lives here: without its own, a run writes plugin state into the user's real one. */ - XDG_STATE_HOME: join(work, "state"), - COCKPIT_HOME: home, - TERM: "xterm-256color", - } - /** v2 would attach to the user's background service; a private server keeps the run to itself. */ - const launch = (cwd: string, args: string[] = []) => { - const screenOf = new Terminal({ cols, rows, allowProposedApi: true }) - term = screenOf - return Bun.spawn([opencode, ...(v2 ? ["--standalone"] : []), ...args], { - cwd, - env, - terminal: { - cols, - rows, - data: (_t: unknown, chunk: Uint8Array) => screenOf.write(chunk.slice()), - }, - } as Parameters[1]) as ReturnType & { - terminal: { write(data: string): void } - } - } - let proc = launch(project) - const type = async (keys: string, waitMs: number) => { - proc.terminal.write(keys) - await Bun.sleep(waitMs) - } - + launch(install.env, install.project) await Bun.sleep(14_000) // OpenCode start-up, plugin install and load - await type("\x10", 1000) // ctrl+p command palette - await type("Start a background shell", 1200) - await type("\r", 1200) - await type("i=0; while true; do i=$((i+1)); echo tick $i; sleep 1; done", 300) - await type("\r", 3500) - - const first = await screen() - await Bun.sleep(4000) - const second = await screen() - - /** - * The console, which was never opened here — so a crash on open was never caught here either. - * Pressing `?` walks both halves of the key row: the keys that act, and the rest in the panel. - */ - await type("\x18j", 3000) // ctrl+x j - const consoleScreen = await screen() - await type("?", 1500) - const consoleDetails = await screen() - await type("\x1b", 800) // esc, back to the conversation - - /** - * Full screen, which neither version's run opened before — so on OpenCode 2 it could draw nothing - * and still pass. `w` swaps the dialog for it and is remembered, so it is swapped back before leaving. - */ - await type("\x18j", 3000) - await type("w", 2500) - const fullScreen = await screen() - await type("w", 1500) - await type("\x1b", 800) - if (process.env.SMOKE_SHOW) console.log(fullScreen) - /** The dialog sits inside the host's frame; only full screen puts the header on the top row. */ - if (!fullScreen.split("\n")[0]?.includes("RUN") || !/^ {2}│ tick \d+/m.test(fullScreen)) { - throw new Error(`full screen never drew the console across the window:\n${fullScreen}`) - } - - for (const [what, marker] of [ - ["the console never drew its keys", "[?]"], - ["the console's action keys never drew", "[r]"], - ] as const) { - if (!consoleScreen.includes(marker)) throw new Error(`${what}:\n${consoleScreen}`) - } - if (!consoleDetails.includes("keys")) { - throw new Error(`the details panel never listed the other keys:\n${consoleDetails}`) - } - - /** - * The review panel, from the same published build. - * - * It reads the repository rather than the session, so the project is a real checkout with one - * uncommitted change — and the file is named for this test, so a panel that draws *something* - * cannot pass for a panel that drew the diff. - */ - await type("\x18v", 3000) - const review = await screen() - await type("\x18v", 1500) // close the review again - - /** - * The palette (`ctrl+p`), where a command can be listed and still do nothing you can see — Trust's - * "show or hide in the sidebar" did exactly that. Every bay's commands must be found by typing - * "cockpit" and the bay, and one command per bay (palette-only where the bay has one) must show - * its effect. The capture's selection is not to be trusted blindly (docs/building/testing.md), so - * the screen is read before `enter`: the entry has to be at the top, right under the query. - */ - const atTop = (listed: string, query: string, title: string) => { - const lines = listed.split("\n") - const field = lines.findIndex((line) => line.trim() === query) - return ( - field >= 0 && - lines - .slice(field + 1) - .filter((line) => line.trim()) - .slice(0, 2) - .some((line) => line.includes(title)) - ) - } - /** - * The screen at rest, nothing open over it — taken before the first command runs. Each step waits - * for the screen to come back to it: a command's toast ("No finished subagents to clear") still up - * when `ctrl+p` was typed covered the palette, and the next step failed on v2. A line or two may - * differ (a tip, a toggled sidebar); a toast or a dialog is more. Timed out, it goes on anyway, and - * the palette check below says what was in the way. - */ - let quiet = "" - const atRest = (text: string) => { - const was = quiet.split("\n") - return text.split("\n").filter((line, y) => line !== was[y]).length <= 2 - } - const palette = async (title: string, waitMs = 2500) => { - await until(12_000, atRest) - await type("\x10", 1000) // ctrl+p - await type(title, 1500) - const listed = await screen() - if (!atTop(listed, title, title)) - throw new Error(`the palette did not offer "${title}" first:\n${listed}`) - await type("\r", waitMs) - return await screen() - } - const found: [string, string][] = [] - for (const [name, title] of [ - ["shell", "Start a background shell"], - ["status", "Ask the agent to set up the status bay"], - ["review", "Open or close the changes"], - ["updater", "Update plugins"], - ["subagents", "Open the subagents"], - ["trail", "Show what this conversation made"], - ["trust", "Show what Trust answers for you"], - ["setup", "Ask the agent to set up Cockpit"], - ] as const) { - await type("\x10", 1000) - await type(`cockpit ${name}`, 1500) - const listed = await screen() - if (!listed.includes(title)) found.push([`"cockpit ${name}" never listed "${title}"`, listed]) - await type("\x1b", 800) - } - /** At rest: a beat after the last search's palette closed, the same screen twice running (or 5s). */ - await Bun.sleep(1000) - quiet = await until(5000, (text) => { - const same = text === quiet - quiet = text - return same - }) - /** Each surface is closed before the next: an open review takes the keys, `ctrl+p` included. */ - const ranReview = await palette("Toggle the changes full screen", 3000) - await type("\x1b", 1200) - const ran = { - /** Palette-only: closed, it used to change nothing on screen; now it opens, full screen. */ - review: ranReview, - subagents: await palette("Clear finished subagents", 1200), - trust: await palette("Show or hide Trust in the sidebar", 1200), - ledger: await palette("Show what Trust answers for you", 2500), - } - await type("\x1b", 1200) - const ranUpdater = await palette("Update plugins", 4000) - await type("\x1b", 1200) - /** From home there is no conversation, so Trail opens on the project's records: none, and says so. */ - const ranTrail = await palette("Show what this conversation made", 2500) - await type("\x1b", 1200) - - /** - * The updater's dialog, opened by its slash name and by the old one it replaced — two slash names - * for one surface is exactly what docs/opencode/keys-and-commands.md warns can break the popup. - * Every plugin here is a local path, so the list must say so, and nothing may be written. - */ - /** - * AGENT=1: a real subagent, launched from the interface. The sidebar has to show it while it works, - * a click on it has to open the full screen with its run, and `/subagents` has to open that screen - * rather than OpenCode's own `/agents`, whose name it contains. - */ - const subagentsInTheInterface = async () => { - await type( - "Use the task/subagent tool to launch an explore subagent with the task: read SMOKE-REVIEW.ts and report its export. Wait for it, then reply DONE.", - 400, - ) - await type("\r", 1000) - let sidebar = "" - for (let i = 0; i < 45 && !EXPLORE_ROW.test(sidebar); i++) { - await Bun.sleep(2000) - sidebar = await screen() - } - await Bun.sleep(4000) - /** Found again before every click: the blocks above it (the statusline's) grow as the turn runs. */ - const clickSubagent = async () => { - const lines = (await screen()).split("\n") - const right = Math.floor(cols / 2) - const y = lines.findIndex((line) => EXPLORE_ROW.test(line.slice(right))) - if (y < 0) throw new Error(`the sidebar never showed the subagent:\n${lines.join("\n")}`) - const x = right + ((lines[y] as string).slice(right).search(EXPLORE_ROW) as number) + 3 - proc.terminal.write(`\x1b[<0;${x + 1};${y + 1}M`) - await Bun.sleep(80) - await type(`\x1b[<0;${x + 1};${y + 1}m`, 2500) - } - await clickSubagent() - const full = await screen() - /** `?` swaps the run for every key the pane takes, and `?` again brings the run back. */ - await type("?", 1200) - const keys = await screen() - await type("?", 1000) - /** - * The cursor onto the last item and open it, then a message typed into the pane, not a dialog. - * Each key waits on what it needs on screen rather than a fixed time: the run's first item can - * take a while to arrive on a slow turn, and `enter` with no cursor yet opens nothing. - */ - await until(30_000, (text) => /[›⌄◇◆] /.test(rightHalf(text))) - await type("k", 300) - await until(5000, (text) => text.includes("▌ ")) - await type("\r", 1200) - const toggled = await screen() - await type("m", 600) - await type("hello there", 1200) - const typing = await screen() - await type("\x1b", 800) - await type("q", 1500) - const slashed = await slash("subagents") - /** - * A message to the finished subagent: it answers, and the exchange is added to the main - * conversation without starting a turn there (v1 draws it as a message, v2 as one line). - */ - await clickSubagent() - /** The relay is for a finished subagent: one still at work (or held on a permission) answers its run. */ - const finished = (await screen()).includes("done in") - await type("m", 600) - /** Half typed, half pasted — a paste is one event, not keys, and used to land in OpenCode's prompt. */ - await type("Reply with just the word ", 600) - await type("\x1b[200~RELAY-OK.\x1b[201~", 1000) - const pasted = await screen() - await type("\r", 2000) - let relayed = await screen() - for (let i = 0; i < 40 && !relayed.includes("The main agent now knows"); i++) { - await Bun.sleep(2000) - relayed = await screen() - } - await type("q", 1500) - /** Whatever an earlier step left in the prompt goes, or its menu covers the conversation. */ - await type("\x15", 300) - await type("\x1b", 1500) - const conversation = await screen() - /** `x` on a finished subagent takes it off the list; on a working one it asks first. */ - await clickSubagent() - await type("x", 1500) - const removed = await screen() - await type("q", 1000) - return { sidebar, full, keys, toggled, typing, slashed, finished, pasted, relayed, conversation, removed } - } - - /** `ready`: read as soon as it shows, for what does not stay — a toast the next one replaces. */ - const slash = async (name: string, ready?: (text: string) => boolean) => { - await type(`/${name}`, 300) - /** `enter` once the popup offers the name: with the agent busy it can take longer to list. */ - await until(4000, (text) => new RegExp(`/${name}\\s{2,}\\S`).test(text)) - await type("\r", ready ? 0 : 4000) - const drawn = ready ? await until(4000, ready) : await screen() - await type("\x1b", 1000) - return drawn - } - /** - * A command the agent side ships, which OpenCode runs as its own: on OpenCode 1 `enter` on the popup - * first completes the name into the prompt, and a second `enter` sends it (measured, both versions). - */ - const shipped = async (name: string) => { - await type(`/${name}`, 300) - await until(4000, (text) => new RegExp(`/${name}\\s{2,}\\S`).test(text)) - await type("\r", 1200) - if (new RegExp(`┃\\s+/${name}\\s*$`, "m").test(await screen())) await type("\r", 0) - } - const updater = await slash("plugins-update") - const subagents = process.env.AGENT ? await subagentsInTheInterface() : undefined - /** - * The setup commands' slash names, shipped by the agent side, offered in the popup as they are typed - * — once each: the interface's palette entries for them carry no slash name. Only listed here, not - * run: running one asks a model, and a run without AGENT=1 stays offline. - */ - const popup = async (typed: string) => { - await type("\x15", 300) - await type(typed, 1500) - const listed = await screen() - await type("\x15", 300) - await type("\x1b", 800) - return listed - } - const popups = { - "/cockpit-setup": await popup("/cockpit-se"), - "/status-setup": await popup("/status-se"), - } - /** - * AGENT=1: Status's command under its old name, kept for a release as a command of its own whose - * line says the new name first, and the skill it names loaded. In the conversation the subagent run - * left open, last, because it starts a turn. `/status-setup` sends the same line without the note. - */ - const status = process.env.AGENT - ? await (async () => { - /** Whatever an earlier step left in the prompt goes first, or the name is typed after it. */ - await type("\x15", 300) - await shipped("statusline") - const old = await seen(120_000, STATUS_SKILL_USED) - /** The skill asks a question next; `esc` dismisses it so nothing is left waiting. */ - await type("\x1b", 1000) - return { old } - })() - : undefined - proc.kill("SIGKILL") - - /** - * AGENT=1: a second OpenCode, in a project nothing has happened in. `/cockpit-setup` from home has - * to open a conversation, and the agent there has to load the `cockpit-setup` skill and call - * `cockpit_settings` — the skill's first step. Run again while the agent answers, it has to queue - * behind the reply rather than cut it off. With the conversation open the sidebar draws: every - * block that lists something has to say it is there while it is empty — the heading and `none yet` - * — and the Status table, which nothing configures here, has to be the default surface, with the - * global file's old `statusline` named in a `!` row in the sidebar. - */ - const presence = async () => { - const fresh = join(work, "fresh") - await Bun.write(join(fresh, "README.md"), "fresh\n") - for (const cmd of [ - ["git", "init", "-q", "-b", "main"], - ["git", "config", "user.email", "smoke@example.com"], - ["git", "config", "user.name", "Smoke"], - ["git", "add", "-A"], - ["git", "commit", "-qm", "fresh"], - ]) { - run(cmd, fresh) - } - /** The skill has the agent read Cockpit's config, outside the project: nothing may wait on a prompt. */ - proc = launch(fresh, v2 ? ["--auto"] : []) - await Bun.sleep(14_000) - await shipped("cockpit-setup") - const opened = await until(20_000, (text) => text.includes(SETUP_LINE)) - /** Again, while the agent is still answering the first. */ - await type("\x15", 300) - await shipped("cockpit-setup") - const queued = await until(8000, (text) => QUEUED.test(text)) - const used = await seen(180_000, SETUP_SKILL_USED) - const drawn = await until(60_000, (text) => TOKENS_ROW.test(rightHalf(text))) - await Bun.sleep(3000) - const settled = await screen() - proc.kill("SIGKILL") - return { opened, used, queued, drawn: TOKENS_ROW.test(rightHalf(settled)) ? settled : drawn } - } - const fresh = process.env.AGENT ? await presence() : undefined - - /** - * Status's `diagnostics` row in the sidebar table, on both versions (#34: OpenCode 2 hands a - * server's status as `{ status: "connected" }`, and every healthy server was flagged). A project - * with two MCP servers: a small stdio server that answers, and one whose command does not exist. - * The table — the default surface, nothing configures it here — draws in a conversation, so one - * short turn is sent (a free model; the only turn outside AGENT=1). Then, with the servers given - * time to connect or fail: no `! ok-test`, and `! broken-test` drawn, which proves the row was live. - * One `opencode.json` for both: 2.0 reads `mcp.servers`, 1.18 the flat keys (2.0 ignored a .jsonc). - */ - const mcpStatus = async () => { - const at = join(work, "mcp") - const okMcp = join(work, "ok-mcp.ts") - await Bun.write(okMcp, OK_MCP) - const servers = { - "ok-test": { type: "local", command: [process.execPath, okMcp] }, - "broken-test": { type: "local", command: ["cockpit-does-not-exist"] }, - } - await Bun.write( - join(at, "opencode.json"), - JSON.stringify({ model: "opencode/space-bunny-free", mcp: { servers, ...servers } }), - ) - await Bun.write(join(at, "README.md"), "mcp\n") - proc = launch(at) - await Bun.sleep(14_000) - await type("Reply with just the word hi.", 400) - await type("\r", 1000) - const drawn = await until(90_000, (text) => /! broken-test/.test(rightHalf(text))) - /** A healthy server that is flagged at all may be flagged only once it has connected. */ - await Bun.sleep(8000) - const settled = await screen() - proc.kill("SIGKILL") - return { drawn, settled } + await stage("open", [shell, status, review]) + + /** + * Every installed bay's commands must be found by typing "cockpit" and the bay, and one command per + * bay (palette-only where the bay has one) must show its effect. A bay installed alone must not + * bring the others: their entries are not listed at all. + */ + const probes = [shell, status, review, updater, subagents, trail, trust, setup] + for (const probe of probes) { + const listed = await search(`cockpit ${probe.name}`) + keep(listed) + if (here(probe)) + expect(listed.includes(probe.listed), `"cockpit ${probe.name}" never listed "${probe.listed}"`, listed) + else + expect( + !listed.includes(probe.listed), + `"cockpit ${probe.name}" listed "${probe.listed}", not installed`, + listed, + ) } - const mcp = await mcpStatus() - // `SMOKE_SHOW=1 bun run smoke:tui` prints the updater's frame: a marker proves it drew, not how. - if (process.env.SMOKE_SHOW) console.log(updater) + await settle() + await stage("palette", [review, subagents, trust, updater, trail]) + pass( + all + ? `every bay and /cockpit-setup found under "cockpit" in the palette, and the commands ran from there` + : `${mode} and /cockpit-setup found under "cockpit" in the palette, no other bay, and the commands ran from there`, + ) + /** Status's AGENT=1 step starts a turn, so it is last. */ + await stage("slash", [updater, subagents, setup, status]) + kill() /** - * The statusline half. It shares this run rather than having its own, because what is being - * proved is the same thing for both bays: that published, pre-compiled JSX actually renders - * inside OpenCode. A statusline that never drew would otherwise reach users exactly the way the - * frozen shell panel did in 0.1.3. + * AGENT=1: the sidebar of a conversation nothing has happened in — every block that lists something + * has to say it is there while it is empty, the heading and `none yet`, under the Status table. */ - for (const [what, marker] of [ - ["the statusline never drew", "STATUSLINE-DREW"], - ["the statusline's command segment never ran", "COMMAND-RAN"], - ] as const) { - if (!second.includes(marker)) throw new Error(`${what}:\n${second}`) - } - if (!second.includes(`! settings: "statusline" is no longer read`)) - throw new Error(`Status never named the old "statusline" section:\n${second}`) - for (const [name, listed] of Object.entries(popups)) { - /** Once: the shipped command's row, and no second one from the interface. */ - /** A row: at the popup's edge, the name, a gap on the same line, its description. */ - const rows = listed.match(new RegExp(`┃ ${name} {2,}\\S`, "g")) ?? [] - if (rows.length !== 1) - throw new Error(`the slash popup offered ${name} ${rows.length} times, not once:\n${listed}`) - } - if (status && !status.old.all) - throw new Error( - `/statusline never ran the status-setup skill with its new name said (missing ${status.old.missing.join(", ")}):\n${status.old.last}`, - ) - - for (const marker of ["Nothing recorded in this project yet.", "[esc] Close"]) { - if (!ranTrail.includes(marker)) - throw new Error(`the palette's Trail never drew "${marker}":\n${ranTrail}`) - } - - for (const [what, marker] of [ - ["the review panel never drew", "review"], - ["the review panel drew no diff", "SMOKE-REVIEW"], - ["the review panel never named its old setting", '! settings: "review.sidebarOrder"'], - ] as const) { - if (!review.includes(marker)) throw new Error(`${what}:\n${review}`) - } - - const missing = found[0] - if (missing) throw new Error(`${missing[0]}:\n${missing[1]}`) - for (const [what, text, marker] of [ - ["the palette's full-screen toggle never opened the changes", ran.review, "SMOKE-REVIEW"], - ["the palette's clear never answered", ran.subagents, "No finished subagents to clear."], - ["the palette's Trust sidebar toggle said nothing", ran.trust, "Shown in the sidebar."], - /** /trust opens on what Trust did; the ledger is one key further, behind `l`. */ - ["the palette never opened Trust's activity screen", ran.ledger, "Open the ledger"], - ] as const) { - if (!text.includes(marker)) throw new Error(`${what}:\n${text}`) - } - /** Full screen puts the header on the top row; the pane leaves it to the conversation. */ - if (!ran.review.split("\n")[0]?.includes("review")) - throw new Error(`the palette's toggle opened the changes, but not full screen:\n${ran.review}`) - - for (const [what, text] of [ - ["/plugins-update", updater], - ["the palette's Update plugins", ranUpdater], - ] as const) { - /** OpenCode 2 updates plugins itself; there the commands point at it instead of opening the dialog. */ - for (const marker of v2 ? ["change the version"] : ["Plugins", "published", "local", "Review"]) { - if (!text.includes(marker)) throw new Error(`${what} did not draw "${marker}":\n${text}`) - } - } - - if (subagents) { - for (const [what, text, marker] of [ - ["the sidebar never showed the Subagents block", subagents.sidebar, "Subagents"], - ["a click on the subagent never opened its full screen", subagents.full, "EXPLORE"], - ["the full screen never drew its keys", subagents.full, "[m] Message"], - ["the full screen never offered every key", subagents.full, "[?] Keys"], - ["? never showed every key", subagents.keys, "KEYS"], - ["the keys screen never said how back", subagents.keys, "[esc] Hide Keys"], - ["enter never opened the selected item", subagents.toggled, "▌ "], - ["m never opened the message input in the pane", subagents.typing, "┃ hello there"], - ["/subagents never opened the full screen", subagents.slashed, "[m] Message"], - ] as const) { - if (!text.includes(marker)) throw new Error(`${what}:\n${text}`) - } - /** The heading's count reaches the sidebar's edge whole: rows drawn wider than it were clipped. */ - if (!/Subagents +\d+ (running|done|failed)\b/.test(subagents.sidebar)) - throw new Error(`the sidebar's Subagents heading was clipped:\n${subagents.sidebar}`) - if (!subagents.pasted.includes("┃ Reply with just the word RELAY-OK.")) - throw new Error(`a paste never reached the message field:\n${subagents.pasted}`) - if (subagents.finished) { - if (!subagents.relayed.includes("The main agent now knows")) - throw new Error(`the subagent's answer was never relayed to the main agent:\n${subagents.relayed}`) - if (!/Cockpit notification|Subagent exchange/.test(subagents.conversation)) - throw new Error(`the main conversation never showed the relayed exchange:\n${subagents.conversation}`) - } else console.log("relay not checked: the subagent had not finished when the message was sent") - const asked = subagents.removed.includes("Press x again") - const listed = EXPLORE_ROW.test(subagents.removed) - if (!asked && listed) - throw new Error(`x neither removed the subagent nor asked to stop it:\n${subagents.removed}`) - } - - /** A plugin OpenCode could not load says so in the footer, whichever half it was. */ - for (const text of [ - first, - second, - consoleScreen, - fullScreen, - review, - updater, - ranTrail, - ...Object.values(ran), - ...Object.values(popups), - ...(fresh ? [fresh.drawn] : []), - mcp.settled, - ]) { - if (/plugins? failed/.test(text)) throw new Error(`OpenCode could not load a plugin:\n${text}`) - } - - const ticks = (text: string) => [...text.matchAll(/tick (\d+)/g)].map((m) => Number(m[1])) - const firstMax = Math.max(0, ...ticks(first)) - const secondMax = Math.max(0, ...ticks(second)) - if (firstMax === 0) throw new Error(`the panel never showed the shell's output:\n${first}`) - if (secondMax <= firstMax) { - throw new Error( - `the panel froze: still at tick ${firstMax} after 4s (published JSX not Solid-compiled?)\n${second}`, + if (canPresence(install)) { + const drawn = await presence(install) + for (const probe of probes) if (here(probe)) probe.empty?.(drawn) + pass( + all + ? "an empty sidebar said none yet in every block, under the Status table" + : `an empty sidebar drew ${mode}'s block as it should`, ) } - if (process.env.SMOKE_SHOW) console.log(mcp.settled) - if (!/! broken-test/.test(rightHalf(mcp.drawn)) || !/! broken-test/.test(rightHalf(mcp.settled))) - throw new Error(`the Status table never flagged the broken MCP server:\n${mcp.settled}`) - for (const text of [mcp.drawn, mcp.settled]) { - if (/!\s+ok-test/.test(text)) throw new Error(`the Status table flagged a healthy MCP server:\n${text}`) - } - if (fresh) { - const { opened, used, queued, drawn } = fresh - if (process.env.SMOKE_SHOW) console.log(drawn) - if (!opened.includes(SETUP_LINE)) - throw new Error(`/cockpit-setup from home never opened a conversation with its line:\n${opened}`) - if (!used.all) - throw new Error( - `/cockpit-setup's agent never used the skill (missing ${used.missing.join(", ")}):\n${used.last}`, + await stage("apart", [status]) + noPluginFailed() + + if (agent) { + const asks = [shell, review, subagents, ...(install.agentSide ? [setup] : [])] + .filter(here) + .flatMap((probe) => (probe.ask ? [probe.ask] : [])) + if (asks.length > 0) { + agentTurn(install, asks) + pass( + all + ? "an agent called the bays' tools and was told about them" + : "an agent called the tools and was told about them", ) - if (!QUEUED.test(queued)) - throw new Error(`/cockpit-setup while the agent answered never queued:\n${queued}`) - for (const heading of ["Subagents", "Shells", "Trail"]) { - if (!under(drawn, heading)?.startsWith("none yet")) - throw new Error(`the sidebar never drew "${heading}" with "none yet" under it:\n${drawn}`) } - if (!TOKENS_ROW.test(rightHalf(drawn))) - throw new Error(`the sidebar never drew the Status table's tokens row:\n${drawn}`) - if (!rightHalf(drawn).includes(`! settings: "statusline" is no longer`)) - throw new Error(`the sidebar never named the old "statusline" section:\n${drawn}`) - } - if (process.env.AGENT) agentTurn(env) - /** - * AGENT=1: each bay's measurement against its installed server half — the guidance has to change - * what a real turn does, without the prompt naming the bay. Trail: a turn that opens a PR (with a - * fake `gh`) records it. Shell: "start the dev server" goes in shell_start, and a second - * conversation reuses it rather than starting another. Review: a waiting comment is read with - * review_list and answered with review_reply. - */ - if (process.env.AGENT) { - for (const [name, what] of [ - ["trail", "Trail's"], - ["shell", "Shell's"], - ["review", "Review's"], - ] as const) { - /** - * Trail's on a free model misses about one turn in six, so it is two of three; the others - * still one of one. - */ - const runs = name === "trail" ? ["--runs", "3", "--pass", "2"] : ["--runs", "1"] - const measured = Bun.spawnSync( - ["bun", join(root, `packages/${name}/measure/agent.ts`), "--plugin", bay(name), ...runs], - { - cwd: root, - env: { ...process.env, OPENCODE: opencode }, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - /** Three attempts, of up to two five-minute turns each, inside each run of the measurement. */ - timeout: name === "trail" ? 6_000_000 : 2_000_000, - }, + const measured = [trail, shell, review].filter((probe) => here(probe) && probe.measure) + for (const probe of measured) probe.measure?.(install) + const names = measured.map((probe) => `${probe.name[0]?.toUpperCase()}${probe.name.slice(1)}'s`) + if (names.length > 0) + pass( + `${[names.slice(0, -1).join(", "), names.at(-1)].filter(Boolean).join(" and ")} ${names.length > 1 ? "measurements" : "measurement"} passed`, ) - if (measured.exitCode !== 0) - throw new Error(`${what} measurement failed:\n${measured.stdout}\n${measured.stderr}`.slice(-3000)) - } } - console.log( - `tui smoke passed: panel live, tick ${firstMax} → ${secondMax}; console and its keys drew; full screen drew; statusline drew and named its old section; review drew its diff and named its old setting; updater answered its slash name; trail opened empty; every bay and /cockpit-setup found under "cockpit" in the palette, and the commands ran from there; /cockpit-setup and /status-setup offered once each as they were typed${subagents ? "; a subagent showed in the sidebar and opened full screen, by click and by /subagents, with its keys; /statusline ran the status-setup skill, saying its new name" : ""}${fresh ? "; /cockpit-setup from home opened a conversation whose agent loaded the cockpit-setup skill and called cockpit_settings, and queued behind the reply; an empty sidebar said none yet in every block, under the Status table" : ""}; the Status table flagged the broken MCP server and not the healthy one${process.env.AGENT ? "; an agent called the bays' tools and was told about them; Trail's, Shell's and Review's measurements passed" : ""}`, - ) + console.log(`tui smoke passed (${mode}): ${passed.join("; ")}`) } finally { + /** A probe that failed left its OpenCode running. */ + kill() /** KEEP=1 leaves the install and project behind, to inspect what a run actually loaded. */ - if (process.env.KEEP) console.log(`kept ${work}`) - else rmSync(work, { recursive: true, force: true }) + if (process.env.KEEP) console.log(`kept ${install.work}`) + else rmSync(install.work, { recursive: true, force: true }) } diff --git a/site/engine/bays/status.ts b/site/engine/bays/status.ts index b3e6b879..db11ba99 100644 --- a/site/engine/bays/status.ts +++ b/site/engine/bays/status.ts @@ -9,7 +9,7 @@ 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. */ +/* SIDEBAR_SEGMENTS (status/src/core/config/lines.ts), copied: the config folder 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)) diff --git a/site/scripts/engine.ts b/site/scripts/engine.ts index 1db0ab80..c31d1987 100644 --- a/site/scripts/engine.ts +++ b/site/scripts/engine.ts @@ -7,7 +7,7 @@ * * Bun rather than Vite: Trust reaches for node:path at runtime, which Bun polyfills and Vite does not. */ -import { rmSync } from "node:fs" +import { existsSync, readFileSync, rmSync } from "node:fs" import { join, resolve } from "node:path" const site = resolve(import.meta.dir, "..") @@ -33,10 +33,16 @@ const result = await Bun.build({ 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. + // Packages import each other by name; the site reads their source, not a build: the + // subpath's `exports` entry names its built file, and `dist/x.js` is `src/x.ts` (or `.tsx`). build.onResolve({ filter: /^@opencode-cockpit\// }, ({ path }) => { - const [, pkg, sub] = path.match(/^@opencode-cockpit\/([^/]+)\/?(.*)$/) ?? [] - return { path: join(packages, pkg, "src", `${sub || "index"}.ts`) } + const [, pkg = "", sub = ""] = path.match(/^@opencode-cockpit\/([^/]+)\/?(.*)$/) ?? [] + const manifest = JSON.parse(readFileSync(join(packages, pkg, "package.json"), "utf8")) + const entry = manifest.exports?.[sub ? `./${sub}` : "."] + const built: string | undefined = typeof entry === "string" ? entry : entry?.default + if (!built) throw new Error(`${path}: not in ${pkg}'s exports`) + const source = join(packages, pkg, built.replace(/^\.\/dist\//, "src/").replace(/\.js$/, ".ts")) + return { path: existsSync(source) ? source : `${source}x` } }) }, }, diff --git a/site/src/content/docs/configuration.md b/site/src/content/docs/configuration.md index 724d58d6..b393b730 100644 --- a/site/src/content/docs/configuration.md +++ b/site/src/content/docs/configuration.md @@ -21,8 +21,8 @@ get the defaults below. Type `/cockpit-setup`, or just ask — "make my sidebar quieter", "hide the shells block when it's empty", "move trail above subagents". The agent loads the `cockpit-setup` skill that ships with Cockpit and reads what is installed and written now with its `cockpit_settings` tool: every value -and where it came from, anything from before 0.9 that is no longer read, OpenCode's own sidebar -blocks. It fixes the old names first, offers a starting point (everything visible, quiet, minimal, +and where it came from, every name it does not read, OpenCode's own sidebar blocks. It fixes those +first, offers a starting point (everything visible, quiet, minimal, or Status as a line under the prompt), asks only what is left, one question at a time, writes the smallest file that does it, and checks it reads back with no notices. It asks before touching OpenCode's own files, and never suggests turning OpenCode's Todo block off. From the home screen the @@ -237,14 +237,16 @@ See [Updater](/updater/overview/). ## Names from before 0.9 -0.9 gave every bay the same shape, so some names changed. **The old ones are not read.** Each one a -file still carries is drawn as a `!` row in its bay's block, printed by -[doctor](/help/doctor/), and fixed first by `/cockpit-setup`: +0.9 gave every bay the same shape, so some names changed. **The old ones are not read**, and since +0.10 they are names like any other Cockpit does not know: `/cockpit-setup` lists each one, and a +top-level one is also a line in [doctor](/help/doctor/) and a `!` row at the top of Status's column: ``` -! settings: "statusline" is no longer read — run /cockpit-setup +! settings: "statusline" is not a setting: did you mean "status"? ``` +To move a file from before 0.9: + | Before 0.9 | Now | | --- | --- | | `statusline` | `status` | diff --git a/site/src/content/docs/help/doctor.md b/site/src/content/docs/help/doctor.md index dae30c7f..72508924 100644 --- a/site/src/content/docs/help/doctor.md +++ b/site/src/content/docs/help/doctor.md @@ -77,9 +77,10 @@ shell's processes with it), and a Cockpit home it can write to. **Settings.** `~/.config/opencode-cockpit/config.json` and the project's `.cockpit.json` parse (comments and trailing commas are fine; a file that does not is ignored whole, and the defaults -apply), every setting is read as written — a name from before 0.9, a value of the wrong kind, a -Status `override` that matches no segment are each listed, the same notices the bays draw as `!` -rows — and every statusline module they list exists. +apply), every setting is read as written — a top-level name nothing reads, a value of the wrong +kind, a Status `override` that matches no segment are each listed, the same notices the bays draw as +`!` rows — and every statusline module they list exists. A key a bay does not read inside its own +section (`review.sidebarOrder`) is for `/cockpit-setup`, which names every one. ## For an issue diff --git a/site/src/content/docs/help/troubleshooting.md b/site/src/content/docs/help/troubleshooting.md index 61939f26..b237084e 100644 --- a/site/src/content/docs/help/troubleshooting.md +++ b/site/src/content/docs/help/troubleshooting.md @@ -155,9 +155,10 @@ terminal. Cockpit never restarts the service itself: that would cut every open w Settings merge global → project → plugin entry, and a `.cockpit.json` in the project wins over your global file, key by key. They are read when OpenCode starts, so a change applies after a restart. -A setting Cockpit cannot use is never dropped in silence: a file that does not parse, a name from -before 0.9 (`statusline`, `ui.*`, `sidebarOrder`…), a value of the wrong kind or a top-level name -nothing reads is a `!` row in the bay's sidebar block and a line in doctor's **Settings** check. +A setting Cockpit cannot use is never dropped in silence: a file that does not parse, a value of the +wrong kind or a top-level name nothing reads (`statusline`, `ui`, a name from before 0.9) is a `!` +row in the sidebar and a line in doctor's **Settings** check; a key a bay does not read +(`review.sidebarOrder`) is named by `/cockpit-setup`. Comments and trailing commas are fine. To see every value and where it came from, ask the agent — `/cockpit-setup`, or "why is my shells block hidden?" — which reads them with `cockpit_settings`. diff --git a/site/src/content/docs/status/configuration.md b/site/src/content/docs/status/configuration.md index 51ef15be..5a5f7d57 100644 --- a/site/src/content/docs/status/configuration.md +++ b/site/src/content/docs/status/configuration.md @@ -301,5 +301,4 @@ The top-level `"sidebar"` list says, for every bay at once — Status first by d ``` That puts the table under the shells. It has no effect on the bottom surface, where there is nothing -to share the row with. A `sidebarOrder` in the `status` section is no longer read; it is a `!` row -pointing here. See [Configuration](/configuration/#the-sidebar-order). +to share the row with. A `sidebarOrder` in the `status` section is not read. See [Configuration](/configuration/#the-sidebar-order). diff --git a/site/src/content/docs/status/drawing.md b/site/src/content/docs/status/drawing.md index 3e212741..d64a9d8e 100644 --- a/site/src/content/docs/status/drawing.md +++ b/site/src/content/docs/status/drawing.md @@ -111,14 +111,13 @@ find: ## Let an agent draw it -Type **`/status-setup`** in OpenCode (`/statusline` until 0.9), or just ask for the change. It draws +Type **`/status-setup`** in OpenCode, or just ask for the change. It draws nothing: the agent loads the **`status-setup` skill** that ships with Status — the presets, every segment, the design rules this bay learned the expensive way, and the preview to check a line with — reads what is written now with `cockpit_settings`, and asks what you want it to show. For another agent, Claude Code for example, the same skill ships in the package, at -`skills/status-setup/` — the design rules are in its `references/design.md`. (`skills/statusline-design/`, -its name before 0.9, is kept for 0.9 for anyone who copied it, and removed in 0.10.) +`skills/status-setup/` — the design rules are in its `references/design.md`. ```sh # Claude Code, for this project or for every project diff --git a/site/src/content/docs/status/overview.mdx b/site/src/content/docs/status/overview.mdx index 4c46915f..2fb8f8df 100644 --- a/site/src/content/docs/status/overview.mdx +++ b/site/src/content/docs/status/overview.mdx @@ -117,8 +117,7 @@ have given to a bay you actually operate would be charging you for the privilege `/status-setup` asks the agent rather than opening a panel, because the useful next step is usually "change this for me". The skill reads what is written now with `cockpit_settings`, so it knows what it is changing; asking in plain words ("put the statusline at the bottom") loads it too. From the -home screen it opens a conversation. `/statusline`, its name until 0.9, still works for one release -and says the new name. +home screen it opens a conversation. ## Three ways to configure it diff --git a/site/src/content/docs/subagents/overview.md b/site/src/content/docs/subagents/overview.md index d62e243b..afe4e327 100644 --- a/site/src/content/docs/subagents/overview.md +++ b/site/src/content/docs/subagents/overview.md @@ -179,8 +179,8 @@ In the `subagents` section of `~/.config/opencode-cockpit/config.json`, or a pro | `enabled` | `true` | `false` switches both halves off; so does `features.subagents: false` | Where the block sits is the top-level `"sidebar"` list's to say. The names from before 0.9 — -`hideFinishedAfter`, `hideNestedAfter`, `sidebarOrder` — are no longer read: the block shows a `!` -row naming the new one, and `/cockpit-setup` fixes it. See +`hideFinishedAfter`, `hideNestedAfter`, `sidebarOrder` — are not read; `/cockpit-setup` names them +and fixes them. See [Configuration](/configuration/). ## Known limits diff --git a/site/src/content/docs/trust/overview.mdx b/site/src/content/docs/trust/overview.mdx index c6624d09..0e389779 100644 --- a/site/src/content/docs/trust/overview.mdx +++ b/site/src/content/docs/trust/overview.mdx @@ -273,7 +273,7 @@ In the bundle's entry (`"trust": { … }`), the package's own, or the `trust` se | `keybinds` | `{ "cockpit.trust.ledger": "p" }` | The key that opens the ledger | Where the block sits is the top-level `"sidebar"` list's to say — Trust last by default; a -`trust.sidebarOrder` from before 0.9 is no longer read. See +`trust.sidebarOrder` from before 0.9 is not read. See [Configuration](/configuration/#the-sidebar-order). ## Where it keeps what it learned diff --git a/site/src/content/docs/updater/overview.mdx b/site/src/content/docs/updater/overview.mdx index e8fe74fb..130315bf 100644 --- a/site/src/content/docs/updater/overview.mdx +++ b/site/src/content/docs/updater/overview.mdx @@ -109,5 +109,5 @@ again. To turn it off: { "updater": { "updateCheck": false } } ``` -Shell's old `ui.updateCheck` is no longer read: a file that still has it gets a doctor line naming -`updater.updateCheck`, and `/cockpit-setup` moves it here. +Shell's old `ui.updateCheck` is not read: doctor says `"ui" is not a setting`, and `/cockpit-setup` +moves it here.