From 400cc0810f6425d7b12cf953822c34ee2f0fdd6f Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 12:34:17 -0500 Subject: [PATCH 01/34] =?UTF-8?q?scripts/golden.ts:=20every=20preview,=20i?= =?UTF-8?q?n=20every=20state,=20snapshotted=20=E2=80=94=20the=20proof=20a?= =?UTF-8?q?=20refactor=20changed=20nothing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 51 previews across Review, Shell, Status, Subagents, Trail, Trust and the client, run under a pseudo-terminal so they draw in colour (a tone that moved is a difference), Status on an empty config of its own. `bun scripts/golden.ts` before, `--check` after. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 ++ scripts/README.md | 1 + scripts/golden.ts | 96 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+) create mode 100644 scripts/golden.ts diff --git a/.gitignore b/.gitignore index 5ead7ed..8cc0e26 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/scripts/README.md b/scripts/README.md index c73db70..c37e3c5 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -20,6 +20,7 @@ What each script is for, and what runs it. Anything here that nothing runs shoul | `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` | | `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/golden.ts b/scripts/golden.ts new file mode 100644 index 0000000..8019275 --- /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) +} From 6ad3323b189d7b2cb8b9d83801efe349b1821d56 Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 12:37:49 -0500 Subject: [PATCH 02/34] Tests: Review's key table and Trail's tone colours, the two the 0.9 review found untested MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review: every binding runs a command and every command has a key; no key means two things; a key goes through the guard and leaves the keys screen first, except ?, close and the numbers; and the keys screen shows every bound key — or names it as unlisted, with where it is said instead. Trail: each tone is its theme colour, ink falls back panel → background → text, and a chip is its tone faded into the dialog by its TINTS amount, made once, never on a theme with nothing to tint. Co-Authored-By: Claude Opus 5.5 --- packages/review/test/panel/keys.test.ts | 118 ++++++++++++++++++++++++ packages/trail/test/render.test.ts | 88 ++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 packages/review/test/panel/keys.test.ts create mode 100644 packages/trail/test/render.test.ts diff --git a/packages/review/test/panel/keys.test.ts b/packages/review/test/panel/keys.test.ts new file mode 100644 index 0000000..6f21e1b --- /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/trail/test/render.test.ts b/packages/trail/test/render.test.ts new file mode 100644 index 0000000..b71910f --- /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() + }) +}) From 7ff81f22a9ed28ca2445ea7b91a4951defd55dbc Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 16:36:28 -0500 Subject: [PATCH 03/34] =?UTF-8?q?Doctor:=20Status=20has=20an=20agent=20hal?= =?UTF-8?q?f=20since=200.9=20=E2=80=94=20check=20both=20of=20its=20halves?= =?UTF-8?q?=20on=20OpenCode=201=20too?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SERVER_BAYS left Status out, so a Status in tui.json but not opencode.json — no /status-setup, no skill — drew no warning. And the test now checks the other way round: every bay that publishes ./server is one doctor checks, which would have caught this. Co-Authored-By: Claude Opus 5.5 --- packages/updater/src/doctor/checks.ts | 2 +- packages/updater/test/doctor.test.ts | 14 +++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/updater/src/doctor/checks.ts b/packages/updater/src/doctor/checks.ts index f6371de..ed4ee3b 100644 --- a/packages/updater/src/doctor/checks.ts +++ b/packages/updater/src/doctor/checks.ts @@ -25,7 +25,7 @@ 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"] /** Where a config file sits in OpenCode's split: agent plugins or interface plugins. */ export type Half = "server" | "tui" diff --git a/packages/updater/test/doctor.test.ts b/packages/updater/test/doctor.test.ts index 343d4bd..af40fce 100644 --- a/packages/updater/test/doctor.test.ts +++ b/packages/updater/test/doctor.test.ts @@ -1,5 +1,5 @@ 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" @@ -636,4 +636,16 @@ describe("the bays doctor knows", () => { 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].sort()).toEqual(withServer.sort()) + }) }) From ee44fcb705288ca3b543ff7fb8e279d9e9932178 Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 17:09:06 -0500 Subject: [PATCH 04/34] =?UTF-8?q?tui-smoke:=20one=20probe=20per=20bay=20in?= =?UTF-8?q?=20scripts/smoke/,=20and=20install=20modes=20=E2=80=94=20the=20?= =?UTF-8?q?bundle,=20or=20one=20bay=20alone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SMOKE_INSTALL=bays (the default, as before) | bundle | . The harness, the install and the command helpers are modules of their own; each probe owns its steps and what they assert, and the entry only orders the stages and runs a probe when its bay is installed. A bay alone must not bring the others' palette entries, and /cockpit-setup is offered only where an agent side ships it. Co-Authored-By: Claude Opus 5.5 --- scripts/README.md | 2 +- scripts/smoke/agent.ts | 112 ++++ scripts/smoke/commands.ts | 103 +++ scripts/smoke/harness.ts | 165 +++++ scripts/smoke/install.ts | 188 ++++++ scripts/smoke/probe.ts | 29 + scripts/smoke/probes/review.ts | 52 ++ scripts/smoke/probes/setup.ts | 112 ++++ scripts/smoke/probes/shell.ts | 92 +++ scripts/smoke/probes/status.ts | 145 +++++ scripts/smoke/probes/subagents.ts | 156 +++++ scripts/smoke/probes/trail.ts | 33 + scripts/smoke/probes/trust.ts | 23 + scripts/smoke/probes/updater.ts | 36 + scripts/tui-smoke.ts | 1010 +++-------------------------- 15 files changed, 1353 insertions(+), 905 deletions(-) create mode 100644 scripts/smoke/agent.ts create mode 100644 scripts/smoke/commands.ts create mode 100644 scripts/smoke/harness.ts create mode 100644 scripts/smoke/install.ts create mode 100644 scripts/smoke/probe.ts create mode 100644 scripts/smoke/probes/review.ts create mode 100644 scripts/smoke/probes/setup.ts create mode 100644 scripts/smoke/probes/shell.ts create mode 100644 scripts/smoke/probes/status.ts create mode 100644 scripts/smoke/probes/subagents.ts create mode 100644 scripts/smoke/probes/trail.ts create mode 100644 scripts/smoke/probes/trust.ts create mode 100644 scripts/smoke/probes/updater.ts diff --git a/scripts/README.md b/scripts/README.md index c37e3c5..011159f 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -18,7 +18,7 @@ What each script is for, and what runs it. Anything here that nothing runs shoul | --- | --- | --- | | `build.ts` | Compiles every package to `dist/` | `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` | diff --git a/scripts/smoke/agent.ts b/scripts/smoke/agent.ts new file mode 100644 index 0000000..b0834a5 --- /dev/null +++ b/scripts/smoke/agent.ts @@ -0,0 +1,112 @@ +/** + * `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 { 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 args = [opencode, "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: install.project, + env: install.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 { 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 0000000..8a5b7f6 --- /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 0000000..c1f702f --- /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 0000000..35bcf9f --- /dev/null +++ b/scripts/smoke/install.ts @@ -0,0 +1,188 @@ +/** + * 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 and the updater have none. + */ + 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) + /** + * 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. The bundle goes where the + * docs put it: in both files on v1, and on v2 in opencode.json alone, which loads both its halves. + */ + const files: [string, string, string, string[]][] = v2 + ? [ + ["opencode.json", "https://opencode.ai/config.json", "plugins", serverEntries], + ["cli.json", "https://opencode.ai/cli.json", "plugins", mode === "bundle" ? [] : 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 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", + } + 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 0000000..4c93260 --- /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 0000000..7a141ba --- /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 old setting", '! settings: "review.sidebarOrder"'], + ] as const) { + expect(drawn.includes(marker), what, drawn) + } + pass("review drew its diff and named its old 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 0000000..0e9667e --- /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 0000000..8ca2e3b --- /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 0000000..f8e5256 --- /dev/null +++ b/scripts/smoke/probes/status.ts @@ -0,0 +1,145 @@ +/** + * Status: the statusline draws from a published build and names a section from before 0.9; 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+%/ +/** `/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"/, +] +const OLD_SECTION = `! settings: "statusline" is no longer read` +/** 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: 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. + */ + /** 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) + expect( + old.all, + `/statusline never ran the status-setup skill with its new name said (missing ${old.missing.join(", ")})`, + old.last, + ) + pass("/statusline ran the status-setup skill, saying its new name") + }, + /** + * 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(`! settings: "statusline" is no longer`), + `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 0000000..0dc687b --- /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 0000000..6815e31 --- /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 0000000..c81f190 --- /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 0000000..f8995b3 --- /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 7825d14..e5b4ae2 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 }) } From 369b591359382010e86373b349d3138c3d20bb0c Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 17:26:09 -0500 Subject: [PATCH 05/34] =?UTF-8?q?Tooling:=20lint=20and=20tests=20see=20onl?= =?UTF-8?q?y=20the=20repository=20=E2=80=94=20not=20a=20worktree=20an=20ag?= =?UTF-8?q?ent=20keeps=20under=20.claude/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Biome read the worktree's own biome.json as a second root and refused to run; bun test ran its 135 test files beside ours, and the two copies collided on the real daemons and terminals they start (a 79-second run went past 20 minutes). Biome now follows .gitignore, and tests are found under packages/, where every one of them lives. Co-Authored-By: Claude Opus 5.5 --- biome.json | 1 + bunfig.toml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/biome.json b/biome.json index 53df3f0..b2dcc4b 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 3a6f4e5..641ff13 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"] From 14c18560ef15490956d6a0dcafde7a545849f076 Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 17:31:47 -0500 Subject: [PATCH 06/34] Packages publish only the entry points Cockpit uses: six that nothing imported are gone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review/frame — broken since it was added (it named dist/core/frame.js; the file was always dist/core/view/frame.js, 0.6 through 0.9); trail/core, trust/core and updater/core with the index files that only served them; status/fixtures; client/elements. client/brief stays: Status's interface imports it. status/segment stays for custom segments, shell/connect for pack-check. Proven: every published entry point's runtime and .d.ts exports, against main, differ by these six alone; pack-check loads the bundle and each bay alone. Co-Authored-By: Claude Opus 5.5 --- packages/client/package.json | 4 ---- packages/review/package.json | 4 ---- packages/status/package.json | 4 ---- packages/trail/package.json | 4 ---- packages/trail/src/core/index.ts | 14 -------------- packages/trust/package.json | 4 ---- packages/trust/src/core/index.ts | 16 ---------------- packages/updater/package.json | 4 ---- packages/updater/src/core/index.ts | 14 -------------- 9 files changed, 68 deletions(-) delete mode 100644 packages/trail/src/core/index.ts delete mode 100644 packages/trust/src/core/index.ts delete mode 100644 packages/updater/src/core/index.ts diff --git a/packages/client/package.json b/packages/client/package.json index eb88dbf..8bc8612 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -48,10 +48,6 @@ "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" diff --git a/packages/review/package.json b/packages/review/package.json index d646042..e79fb7b 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/status/package.json b/packages/status/package.json index 5428b3d..32191ad 100644 --- a/packages/status/package.json +++ b/packages/status/package.json @@ -39,10 +39,6 @@ "./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/trail/package.json b/packages/trail/package.json index 6313c01..cfa044f 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/core/index.ts b/packages/trail/src/core/index.ts deleted file mode 100644 index a4b9416..0000000 --- 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/trust/package.json b/packages/trust/package.json index c5fb4fa..af1c815 100644 --- a/packages/trust/package.json +++ b/packages/trust/package.json @@ -26,10 +26,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/trust/src/core/index.ts b/packages/trust/src/core/index.ts deleted file mode 100644 index 7d1056d..0000000 --- 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/updater/package.json b/packages/updater/package.json index 215584b..669b11d 100644 --- a/packages/updater/package.json +++ b/packages/updater/package.json @@ -25,10 +25,6 @@ "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" diff --git a/packages/updater/src/core/index.ts b/packages/updater/src/core/index.ts deleted file mode 100644 index b8306f4..0000000 --- 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" From 314bda52c346d8206a785c611a20bcf6e8d85dda Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 17:43:53 -0500 Subject: [PATCH 07/34] =?UTF-8?q?Trail:=20the=20journal=20moves=20to=20io/?= =?UTF-8?q?=20=E2=80=94=20core/=20is=20pure,=20as=20CONTRIBUTING=20says?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit journal.ts appends to and reads the trail's file; it goes to src/io/, which may import core/ but is never imported by it. paths.ts stays in core/: it only works out where the file goes. CONTRIBUTING gains the line for io/ and says what core/ may not do: touch the filesystem, git, the network or another process — the clock or an environment variable as a parameter's default is fine. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 5 ++++- packages/trail/src/agent/plugin.ts | 2 +- packages/trail/src/{core => io}/journal.ts | 4 ++-- packages/trail/src/tui/actions.ts | 2 +- packages/trail/src/tui/index.tsx | 2 +- packages/trail/test/journal.test.ts | 2 +- 6 files changed, 10 insertions(+), 7 deletions(-) rename packages/trail/src/{core => io}/journal.ts (97%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index edea33d..2edc228 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. diff --git a/packages/trail/src/agent/plugin.ts b/packages/trail/src/agent/plugin.ts index d806c33..ae5efc0 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/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 258d6ce..530e085 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 846399a..26dfe4b 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 27bce51..96d155b 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/test/journal.test.ts b/packages/trail/test/journal.test.ts index 8508d7b..7434d7d 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 => ({ From 4f673ecf7567a62331392c623361fe63a0f3efa6 Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 17:43:53 -0500 Subject: [PATCH 08/34] =?UTF-8?q?Review:=20git,=20the=20store's=20files=20?= =?UTF-8?q?and=20the=20viewer=20move=20to=20io/=20=E2=80=94=20and=20source?= =?UTF-8?q?s.ts=20splits=20by=20what=20it=20does?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git/sources.ts (473 lines) asked git, read files, and decided what the answers meant. The deciding — decide, pickBase, withCounts, nameStatus, statusChange, the caps — is core/git/changes.ts, pure; the asking — runGit, readBlob, readWorking, worktreeChanges, branchChanges, baseCandidates — is io/git.ts. Every line of the old file is in one of the two. The store's persistence and the system viewer move to io/ whole; the store's paths stay in core/, where they only work out a location. Co-Authored-By: Claude Opus 5.5 --- packages/review/measure/agent.ts | 2 +- packages/review/src/agent/plugin.ts | 2 +- packages/review/src/agent/tools/shared.ts | 2 +- packages/review/src/core/git/changes.ts | 195 +++++++++++++++++ .../src/{core/git/sources.ts => io/git.ts} | 207 ++---------------- .../review/src/{core/store => io}/persist.ts | 6 +- packages/review/src/{core => io}/viewer.ts | 2 +- packages/review/src/tui/data/changes.ts | 3 +- packages/review/src/tui/index.tsx | 6 +- packages/review/src/tui/panel/actions.ts | 6 +- packages/review/src/tui/view/dialogs.tsx | 2 +- packages/review/test/agent/plugin.test.ts | 2 +- packages/review/test/agent/tools.test.ts | 2 +- packages/review/test/git/binary.test.ts | 3 +- packages/review/test/git/kinds.test.ts | 3 +- packages/review/test/git/sources.test.ts | 10 +- packages/review/test/image/viewer.test.ts | 2 +- packages/review/test/store/persist.test.ts | 2 +- 18 files changed, 239 insertions(+), 218 deletions(-) create mode 100644 packages/review/src/core/git/changes.ts rename packages/review/src/{core/git/sources.ts => io/git.ts} (60%) rename packages/review/src/{core/store => io}/persist.ts (95%) rename packages/review/src/{core => io}/viewer.ts (99%) diff --git a/packages/review/measure/agent.ts b/packages/review/measure/agent.ts index 0010529..2a7f789 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/src/agent/plugin.ts b/packages/review/src/agent/plugin.ts index 94c7f19..5f369e0 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 0a8348d..a5a99a1 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/core/git/changes.ts b/packages/review/src/core/git/changes.ts new file mode 100644 index 0000000..0dec1fe --- /dev/null +++ b/packages/review/src/core/git/changes.ts @@ -0,0 +1,195 @@ +/** + * 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 — and reading them all would stall the TUI. */ +export const MAX_FILES = 200 +/** 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/git/sources.ts b/packages/review/src/io/git.ts similarity index 60% rename from packages/review/src/core/git/sources.ts rename to packages/review/src/io/git.ts index c634bea..4dcc0fa 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. * @@ -247,70 +174,8 @@ export async function worktreeChanges(cwd: string, git: RunGit = runGit): Promis 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 -} - -/** 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. - */ -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 -} - /** 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 +223,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. * @@ -460,14 +300,3 @@ export async function branchChanges( } 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 f9f2ef4..5950dc6 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 75552f8..7326c9a 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 0c9486f..8c068a5 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 2671a84..6bb2bdb 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 3f09804..4bd70e5 100644 --- a/packages/review/src/tui/panel/actions.ts +++ b/packages/review/src/tui/panel/actions.ts @@ -11,7 +11,6 @@ */ import type { Host } from "@opencode-cockpit/client/host" -import { baseCandidates } from "../../core/git/sources.ts" import type { Guard } from "../../core/guard.ts" import { drop, @@ -26,7 +25,6 @@ import { 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" @@ -40,7 +38,9 @@ import { 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 type { Queries } from "./queries.ts" diff --git a/packages/review/src/tui/view/dialogs.tsx b/packages/review/src/tui/view/dialogs.tsx index c5e9555..6628e62 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 3a50052..b4391e5 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 4e0563f..34f56e7 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 1bb3255..e5ef14f 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 cebe68b..45e4c9c 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 4567bd4..02b04a9 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 f1038e2..1639a9b 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/store/persist.test.ts b/packages/review/test/store/persist.test.ts index 1d0ebd9..386a942 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 From 2ca4cd0683577c60b3b8f9273cdc30bf7337d6e1 Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 17:43:53 -0500 Subject: [PATCH 09/34] =?UTF-8?q?Updater:=20the=20real=20disk=20and=20spaw?= =?UTF-8?q?ning=20opencode=20move=20to=20io/=20=E2=80=94=20core/=20keeps?= =?UTF-8?q?=20the=20Disk=20it=20is=20handed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core already took its filesystem as an argument; disk.ts also held the real one. nodeDisk goes to io/disk.ts, and core/disk.ts keeps the Disk interface, the in-memory disk the tests use, and readJson. spawn.ts, which runs opencode, moves to io/ whole. Co-Authored-By: Claude Opus 5.5 --- packages/updater/src/cli/main.ts | 4 ++-- packages/updater/src/core/disk.ts | 23 +------------------- packages/updater/src/io/disk.ts | 25 ++++++++++++++++++++++ packages/updater/src/{core => io}/spawn.ts | 0 packages/updater/src/tui/index.tsx | 4 ++-- 5 files changed, 30 insertions(+), 26 deletions(-) create mode 100644 packages/updater/src/io/disk.ts rename packages/updater/src/{core => io}/spawn.ts (100%) diff --git a/packages/updater/src/cli/main.ts b/packages/updater/src/cli/main.ts index f49e06d..ad6e1b0 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 9d63855..cb7e4d3 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/io/disk.ts b/packages/updater/src/io/disk.ts new file mode 100644 index 0000000..7f35787 --- /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/tui/index.tsx b/packages/updater/src/tui/index.tsx index 7c99219..1bc700b 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" From dcf4b59017db5220ac72659c31d69c19344346ed Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 19:10:14 -0500 Subject: [PATCH 10/34] =?UTF-8?q?Status:=20the=20budget,=20the=20shell,=20?= =?UTF-8?q?custom=20modules=20and=20the=20other=20file=20move=20to=20io/?= =?UTF-8?q?=20=E2=80=94=20core/=20only=20decides?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core/budget, core/command and core/custom read files, run commands and import modules; they now live in io/, and core/ keeps the types and the decisions (which path a module resolves to, what a missing one says). previewSettings no longer reads the disk itself: `as` takes a readFile from its caller. pack-check loads custom modules from their new place. Co-Authored-By: Claude Opus 5.5 --- packages/status/src/cli/preview.ts | 20 +++- packages/status/src/core/budget.ts | 9 -- packages/status/src/core/command.ts | 19 ---- packages/status/src/core/custom.ts | 122 +----------------------- packages/status/src/core/preview.ts | 14 +-- packages/status/src/io/budget.ts | 12 +++ packages/status/src/io/command.ts | 20 ++++ packages/status/src/io/custom.ts | 123 +++++++++++++++++++++++++ packages/status/src/tui/index.tsx | 2 +- packages/status/src/tui/state/store.ts | 6 +- packages/status/test/command.test.ts | 3 +- packages/status/test/custom.test.ts | 3 +- packages/status/test/examples.test.ts | 2 +- scripts/pack-check.ts | 2 +- 14 files changed, 189 insertions(+), 168 deletions(-) create mode 100644 packages/status/src/io/budget.ts create mode 100644 packages/status/src/io/command.ts create mode 100644 packages/status/src/io/custom.ts diff --git a/packages/status/src/cli/preview.ts b/packages/status/src/cli/preview.ts index 3c05c00..185e6e8 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 { budgetFile } from "../core/budget.ts" import { type ResolvedLine, resolveLines, type Surface } from "../core/config.ts" -import { loadCustomSegments, resolveModulePath } from "../core/custom.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/budget.ts b/packages/status/src/core/budget.ts index 63b4468..126fd8a 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/command.ts b/packages/status/src/core/command.ts index 8108f4a..014406c 100644 --- a/packages/status/src/core/command.ts +++ b/packages/status/src/core/command.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/custom.ts b/packages/status/src/core/custom.ts index 5a4ab14..9cc465f 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 { basename, isAbsolute, resolve } from "node:path" import type { SegmentConfig } from "./config.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/preview.ts b/packages/status/src/core/preview.ts index 66daa6e..868bbb5 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 { @@ -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/io/budget.ts b/packages/status/src/io/budget.ts new file mode 100644 index 0000000..eda9ad7 --- /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 0000000..85ea2e9 --- /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 0000000..2db12c5 --- /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/tui/index.tsx b/packages/status/src/tui/index.tsx index b008dc2..6907907 100644 --- a/packages/status/src/tui/index.tsx +++ b/packages/status/src/tui/index.tsx @@ -7,11 +7,11 @@ 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 { 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" diff --git a/packages/status/src/tui/state/store.ts b/packages/status/src/tui/state/store.ts index 8111b60..1aa410b 100644 --- a/packages/status/src/tui/state/store.ts +++ b/packages/status/src/tui/state/store.ts @@ -1,7 +1,7 @@ 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 { 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.ts" import type { StatusContext } from "../../core/context.ts" import { @@ -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/command.test.ts b/packages/status/test/command.test.ts index abcd5bb..0863af2 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/custom.test.ts b/packages/status/test/custom.test.ts index fae5253..59782d5 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 20cebb8..f46e277 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/scripts/pack-check.ts b/scripts/pack-check.ts index 47a2ba8..d6f0fc5 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")) { From 08ec90714f966de2f5bb7e4d3908369eaae5ba1e Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 19:14:33 -0500 Subject: [PATCH 11/34] =?UTF-8?q?Client:=20files=20by=20what=20they=20talk?= =?UTF-8?q?=20to=20=E2=80=94=20daemon/,=20opencode/,=20settings/,=20setup/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/ was 25 flat files. The daemon's client, connection and spawn go to daemon/; what OpenCode hands a plugin (host, server, surfaces, elements, sidebar, service) to opencode/; the settings and what reads them to settings/; brief and conventions join setup/. Pure moves: the exports map points every public subpath at its new file, so `client/host`, `client/settings`, `client/setup`, … are unchanged — the public API snapshot is identical. service.ts finds its package one folder further up. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 2 +- packages/client/package.json | 44 +++++++++---------- .../cockpit-setup/references/settings.md | 2 +- packages/client/src/cli/preview.ts | 4 +- packages/client/src/cli/reference.ts | 4 +- packages/client/src/{ => daemon}/client.ts | 0 .../client/src/{ => daemon}/connection.ts | 0 packages/client/src/{ => daemon}/spawn.ts | 0 packages/client/src/index.ts | 4 +- .../client/src/{ => opencode}/elements.tsx | 2 +- packages/client/src/{ => opencode}/host.ts | 6 +-- packages/client/src/{ => opencode}/server.ts | 4 +- packages/client/src/{ => opencode}/service.ts | 7 +-- packages/client/src/{ => opencode}/sidebar.ts | 0 .../client/src/{ => opencode}/surfaces.ts | 4 +- packages/client/src/{ => settings}/catalog.ts | 4 +- packages/client/src/{ => settings}/checks.ts | 2 +- .../src/{settings.ts => settings/index.ts} | 4 +- packages/client/src/{ => settings}/jsonc.ts | 0 packages/client/src/settings/old-names.ts | 2 +- .../src/{ => settings}/plugin-entries.ts | 2 +- packages/client/src/{ => setup}/brief.ts | 2 +- .../client/src/{ => setup}/conventions.ts | 0 packages/client/src/setup/host-blocks.ts | 4 +- .../client/src/{setup.ts => setup/index.ts} | 20 ++++----- packages/client/src/setup/installs.ts | 6 +-- packages/client/src/setup/palette.ts | 4 +- packages/client/src/setup/report.ts | 8 ++-- packages/client/src/setup/server.ts | 4 +- packages/client/src/setup/text.ts | 4 +- packages/client/src/setup/tune.ts | 4 +- packages/client/test/catalog.test.ts | 8 ++-- packages/client/test/checks.test.ts | 6 +-- packages/client/test/conventions.test.ts | 2 +- packages/client/test/host.test.ts | 10 ++++- packages/client/test/jsonc.test.ts | 2 +- packages/client/test/plugin-entries.test.ts | 4 +- packages/client/test/server.test.ts | 2 +- packages/client/test/service.test.ts | 4 +- packages/client/test/settings.test.ts | 2 +- packages/client/test/setup.test.ts | 8 ++-- packages/client/test/sidebar.test.ts | 4 +- packages/client/test/surfaces.test.ts | 6 +-- 43 files changed, 110 insertions(+), 101 deletions(-) rename packages/client/src/{ => daemon}/client.ts (100%) rename packages/client/src/{ => daemon}/connection.ts (100%) rename packages/client/src/{ => daemon}/spawn.ts (100%) rename packages/client/src/{ => opencode}/elements.tsx (96%) rename packages/client/src/{ => opencode}/host.ts (99%) rename packages/client/src/{ => opencode}/server.ts (99%) rename packages/client/src/{ => opencode}/service.ts (97%) rename packages/client/src/{ => opencode}/sidebar.ts (100%) rename packages/client/src/{ => opencode}/surfaces.ts (97%) rename packages/client/src/{ => settings}/catalog.ts (99%) rename packages/client/src/{ => settings}/checks.ts (98%) rename packages/client/src/{settings.ts => settings/index.ts} (99%) rename packages/client/src/{ => settings}/jsonc.ts (100%) rename packages/client/src/{ => settings}/plugin-entries.ts (97%) rename packages/client/src/{ => setup}/brief.ts (98%) rename packages/client/src/{ => setup}/conventions.ts (100%) rename packages/client/src/{setup.ts => setup/index.ts} (82%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2edc228..8122520 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -159,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/packages/client/package.json b/packages/client/package.json index 8bc8612..e416b66 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -29,60 +29,60 @@ "default": "./dist/feature.js" }, "./host": { - "types": "./types/host.d.ts", - "default": "./dist/host.js" + "types": "./types/opencode/host.d.ts", + "default": "./dist/opencode/host.js" }, "./server": { - "types": "./types/server.d.ts", - "default": "./dist/server.js" + "types": "./types/opencode/server.d.ts", + "default": "./dist/opencode/server.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" }, "./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/references/settings.md b/packages/client/skills/cockpit-setup/references/settings.md index 80d2fd3..ce95efd 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 diff --git a/packages/client/src/cli/preview.ts b/packages/client/src/cli/preview.ts index a5c4c7b..c3fc424 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", diff --git a/packages/client/src/cli/reference.ts b/packages/client/src/cli/reference.ts index a0d4e90..b77c7cd 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/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 13935c9..c1339ff 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 1c9030b..34ab9a4 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/host.ts b/packages/client/src/opencode/host.ts similarity index 99% rename from packages/client/src/host.ts rename to packages/client/src/opencode/host.ts index a157dbf..03a597c 100644 --- a/packages/client/src/host.ts +++ b/packages/client/src/opencode/host.ts @@ -14,10 +14,10 @@ import type { TuiDialogSelectOption, TuiPluginApi, 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 { cockpitVersion, createLog, type Log, silentLog } from "../log.ts" +import { registerSetup } from "../setup/palette.ts" 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 @@ -725,7 +725,7 @@ export function dualTui(id: string, start: Start) { opencodeVersion: opencode, cockpit: cockpitVersion(), }) - /** `/cockpit-setup`: every entry offers it, the first in a window registers it (setup.ts). */ + /** `/cockpit-setup`: every entry offers it, the first in a window registers it (setup/). */ try { registerSetup(host, id) } catch (error) { diff --git a/packages/client/src/server.ts b/packages/client/src/opencode/server.ts similarity index 99% rename from packages/client/src/server.ts rename to packages/client/src/opencode/server.ts index 5d5536f..d197632 100644 --- a/packages/client/src/server.ts +++ b/packages/client/src/opencode/server.ts @@ -21,9 +21,9 @@ import { type ToolDefinition, tool, } from "@opencode-ai/plugin" -import { cockpitVersion, createLog, type Log, silentLog } from "./log.ts" +import { cockpitVersion, createLog, type Log, silentLog } from "../log.ts" +import { setupServer } from "../setup/server.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" 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 7270a83..b535d40 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 { claimFeature } from "../feature.ts" +import type { Log } from "../log.ts" import type { Host } from "./host.ts" -import type { Log } from "./log.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 100% rename from packages/client/src/sidebar.ts rename to packages/client/src/opencode/sidebar.ts diff --git a/packages/client/src/surfaces.ts b/packages/client/src/opencode/surfaces.ts similarity index 97% rename from packages/client/src/surfaces.ts rename to packages/client/src/opencode/surfaces.ts index 53ada9a..aeb58cb 100644 --- a/packages/client/src/surfaces.ts +++ b/packages/client/src/opencode/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/catalog.ts b/packages/client/src/settings/catalog.ts similarity index 99% rename from packages/client/src/catalog.ts rename to packages/client/src/settings/catalog.ts index 5baec23..d0e5de3 100644 --- a/packages/client/src/catalog.ts +++ b/packages/client/src/settings/catalog.ts @@ -10,7 +10,7 @@ * 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, OLD_NAMES, SHARED_DEFAULTS, type SharedSettings, SIDEBAR_BAYS } from "./index.ts" export interface KeyInfo { key: string @@ -425,7 +425,7 @@ export function settingsReference(): string { ), ] return [ - "", + "", "", "# Cockpit settings reference", "", diff --git a/packages/client/src/checks.ts b/packages/client/src/settings/checks.ts similarity index 98% rename from packages/client/src/checks.ts rename to packages/client/src/settings/checks.ts index cf7913c..8d53bbb 100644 --- a/packages/client/src/checks.ts +++ b/packages/client/src/settings/checks.ts @@ -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 99% rename from packages/client/src/settings.ts rename to packages/client/src/settings/index.ts index 21d0dc1..bc905a4 100644 --- a/packages/client/src/settings.ts +++ b/packages/client/src/settings/index.ts @@ -32,9 +32,9 @@ 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" +import { oldAtTop, oldInSection } from "./old-names.ts" -export { OLD_NAMES } from "./settings/old-names.ts" +export { OLD_NAMES } from "./old-names.ts" // ── Names ────────────────────────────────────────────────────────────────────────────────────── 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/old-names.ts b/packages/client/src/settings/old-names.ts index 7266435..e5ee734 100644 --- a/packages/client/src/settings/old-names.ts +++ b/packages/client/src/settings/old-names.ts @@ -7,7 +7,7 @@ * ignored. */ -import { type Bay, SETUP_COMMAND, type SettingsNotice } from "../settings.ts" +import { type Bay, SETUP_COMMAND, type SettingsNotice } from "./index.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"] 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 0e72e9e..3941ee9 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 8efc9dd..3b2ac47 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.ts" /** The session on screen, if there is one. */ function sessionOnScreen(host: Host): string | undefined { diff --git a/packages/client/src/conventions.ts b/packages/client/src/setup/conventions.ts similarity index 100% rename from packages/client/src/conventions.ts rename to packages/client/src/setup/conventions.ts diff --git a/packages/client/src/setup/host-blocks.ts b/packages/client/src/setup/host-blocks.ts index 7d719ac..31499be 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 9eeaf84..f9e5e99 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 0709fec..29f896c 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 8ae99dc..c78b577 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.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 2d1ee73..5833c12 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 f54e3d3..bfb4373 100644 --- a/packages/client/src/setup/server.ts +++ b/packages/client/src/setup/server.ts @@ -4,9 +4,9 @@ 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.ts" +import { instructionPaths, writeSection } from "./conventions.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 0344c2e..55b00e1 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" diff --git a/packages/client/src/setup/tune.ts b/packages/client/src/setup/tune.ts index f27837f..56aedae 100644 --- a/packages/client/src/setup/tune.ts +++ b/packages/client/src/setup/tune.ts @@ -8,7 +8,7 @@ */ import { homedir } from "node:os" -import { BAY_ABOUT, BAY_COMMANDS, DEFAULT_KEYS } from "../catalog.ts" +import { BAY_ABOUT, BAY_COMMANDS, DEFAULT_KEYS } from "../settings/catalog.ts" import { findSections, type GitRun, @@ -19,7 +19,7 @@ import { sectionText, type WriteAction, type Written, -} from "../conventions.ts" +} from "./conventions.ts" import { isObject } from "./installs.ts" import { CONVENTIONS_TOOL } from "./names.ts" import { type BayState, type ReportInput, readText, type SettingsReport } from "./report.ts" diff --git a/packages/client/test/catalog.test.ts b/packages/client/test/catalog.test.ts index 1029837..c1b6ddc 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.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 8dd7fb2..d63d193 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 diff --git a/packages/client/test/conventions.test.ts b/packages/client/test/conventions.test.ts index 9780782..669a31f 100644 --- a/packages/client/test/conventions.test.ts +++ b/packages/client/test/conventions.test.ts @@ -10,7 +10,7 @@ import { sectionText, ticketPrefixes, writeSection, -} from "../src/conventions.ts" +} from "../src/setup/conventions.ts" /** * The second phase of /cockpit-setup writes into someone's own instructions file. What is tested is diff --git a/packages/client/test/host.test.ts b/packages/client/test/host.test.ts index 164cabf..da72e97 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.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 6fbe6f8..e600ad6 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 513ca94..cd6499e 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 d7a650e..8cef691 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.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 54ac90b..bedb58d 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.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 82eae1b..aee2257 100644 --- a/packages/client/test/settings.test.ts +++ b/packages/client/test/settings.test.ts @@ -6,7 +6,7 @@ import { OLD_NAMES, orderOf, type SettingsWhere, -} from "../src/settings.ts" +} 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 diff --git a/packages/client/test/setup.test.ts b/packages/client/test/setup.test.ts index 0db9b7b..d8e16bd 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.ts" +import type { ServerHost } from "../src/opencode/server.ts" +import { sectionText, writeSection } from "../src/setup/conventions.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 diff --git a/packages/client/test/sidebar.test.ts b/packages/client/test/sidebar.test.ts index 50a349d..e32264a 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.ts" +import { orderedSidebar } from "../src/opencode/sidebar.ts" describe("orderedSidebar", () => { test("sidebar blocks register in their order, everything else at once", () => { diff --git a/packages/client/test/surfaces.test.ts b/packages/client/test/surfaces.test.ts index 5eb6e17..21b02ed 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.ts" +import { keyText, openText, registerSurfaces, surfacesLine } from "../src/opencode/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 From b6a2a88d582bbe03e21215452227dcc22b85bd4f Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 19:24:56 -0500 Subject: [PATCH 12/34] =?UTF-8?q?Settings:=20names=20from=20before=200.9?= =?UTF-8?q?=20are=20no=20longer=20recognised=20=E2=80=94=20they=20are=20un?= =?UTF-8?q?known=20names=20like=20a=20typo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.9 kept detecting the old names for one release, each a `!` row naming the new key; 0.10 drops settings/old-names.ts and its two calls. Their values were never read and still are not. What changes is what is said: a top-level one (`statusline`, `ui`, Status's keys at the root) is "not a setting", with the nearest name offered, in Status's column and in doctor; one inside a bay's section (`review.sidebarOrder`, `subagents.hideFinishedAfter`) is named by /cockpit-setup only, like any key the bay does not read. `OLD_NAMES` and `SETUP_COMMAND` leave client/settings; the settings reference loses its old-names table (the site's configuration page keeps it for anyone moving an old file). Three previews showed an old-name notice; they show a wrong-kind one now. Co-Authored-By: Claude Opus 5.5 --- packages/client/skills/cockpit-setup/SKILL.md | 13 +- .../cockpit-setup/references/settings.md | 25 ---- packages/client/src/settings/catalog.ts | 16 +-- packages/client/src/settings/checks.ts | 2 +- packages/client/src/settings/index.ts | 39 ++---- packages/client/src/settings/old-names.ts | 120 ------------------ packages/client/src/setup/text.ts | 12 +- packages/client/test/checks.test.ts | 4 +- packages/client/test/design.test.ts | 10 +- packages/client/test/settings.test.ts | 81 +++--------- packages/client/test/setup.test.ts | 17 +-- packages/opencode/README.md | 7 +- packages/review/src/cli/preview.ts | 2 +- packages/review/src/core/config.ts | 2 +- packages/review/src/core/view/state.ts | 2 +- packages/review/test/view/settings.test.ts | 2 +- packages/shell/README.md | 5 +- packages/shell/src/cli/preview.ts | 2 +- packages/shell/src/core/config.ts | 3 +- packages/shell/test/config.test.ts | 14 +- packages/status/README.md | 14 +- packages/status/skills/status-setup/SKILL.md | 9 +- .../skills/status-setup/references/design.md | 2 +- .../status/skills/statusline-design/SKILL.md | 2 +- packages/status/src/core/config.ts | 6 +- packages/status/src/core/notices.ts | 2 +- packages/status/src/core/preview.ts | 2 +- packages/status/src/tui/index.tsx | 2 +- packages/status/test/config.test.ts | 25 ++-- packages/status/test/notices.test.ts | 10 +- packages/subagents/README.md | 3 +- packages/subagents/src/cli/preview.ts | 2 +- packages/subagents/src/core/config.ts | 2 +- packages/subagents/test/settings.test.ts | 11 +- packages/subagents/test/sidebar.test.ts | 6 +- packages/trust/README.md | 4 +- packages/trust/src/tui/index.tsx | 2 +- packages/updater/README.md | 3 +- packages/updater/src/core/settings.ts | 3 +- packages/updater/src/doctor/checks.ts | 2 +- packages/updater/test/doctor.test.ts | 15 +-- packages/updater/test/registry.test.ts | 7 +- site/src/content/docs/configuration.md | 14 +- site/src/content/docs/status/configuration.md | 3 +- site/src/content/docs/subagents/overview.md | 4 +- site/src/content/docs/trust/overview.mdx | 2 +- site/src/content/docs/updater/overview.mdx | 4 +- 47 files changed, 137 insertions(+), 402 deletions(-) delete mode 100644 packages/client/src/settings/old-names.ts diff --git a/packages/client/skills/cockpit-setup/SKILL.md b/packages/client/skills/cockpit-setup/SKILL.md index 4c07c85..90b591d 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 ce95efd..f6edcb6 100644 --- a/packages/client/skills/cockpit-setup/references/settings.md +++ b/packages/client/skills/cockpit-setup/references/settings.md @@ -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/settings/catalog.ts b/packages/client/src/settings/catalog.ts index d0e5de3..b8b6bd3 100644 --- a/packages/client/src/settings/catalog.ts +++ b/packages/client/src/settings/catalog.ts @@ -10,7 +10,7 @@ * writes, so neither can rot without a red test. */ -import { BAYS, type Bay, OLD_NAMES, SHARED_DEFAULTS, type SharedSettings, SIDEBAR_BAYS } from "./index.ts" +import { BAYS, type Bay, SHARED_DEFAULTS, type SharedSettings, SIDEBAR_BAYS } from "./index.ts" export interface KeyInfo { key: string @@ -454,19 +454,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/settings/checks.ts b/packages/client/src/settings/checks.ts index 8d53bbb..e4292aa 100644 --- a/packages/client/src/settings/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 diff --git a/packages/client/src/settings/index.ts b/packages/client/src/settings/index.ts index bc905a4..8d8beaf 100644 --- a/packages/client/src/settings/index.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 "./old-names.ts" - -export { OLD_NAMES } from "./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/settings/old-names.ts b/packages/client/src/settings/old-names.ts deleted file mode 100644 index e5ee734..0000000 --- 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 "./index.ts" - -/** Shell's keys that sat at the file's root before it had a section. */ -const ROOT_SHELL = ["watch", "kinds", "defaults", "lifecycle", "notify", "guidance", "listRunningShells"] - -/** - * Root keys Status used to read as its own when the file had no section — so a root `enabled: false` - * meant for something else turned the statusline off. A file's root is no bay's settings now. - */ -const ROOT_STATUS = [ - "enabled", - "debug", - "preset", - "surface", - "segments", - "separator", - "stack", - "icons", - "maxRows", - "lines", - "commands", - "modules", - "paddingLeft", - "paddingRight", - "paddingTop", - "paddingBottom", -] - -/** Old keys inside a bay's section (and its plugin options), and what replaced them. */ -const OLD_IN_SECTION: Readonly>>>> = { - status: { maxRows: "sidebarRows" }, - shell: { historyMinutes: "hideFinishedAfterMinutes" }, - subagents: { hideFinishedAfter: "hideFinishedAfterMinutes", hideNestedAfter: "hideNestedAfterSeconds" }, -} - -/** Shell's old `ui` group: where each key went. Anything not listed went to `shell.`. */ -const OLD_UI: Readonly> = { - historyMinutes: "shell.hideFinishedAfterMinutes", - updateCheck: "updater.updateCheck", - sidebarOrder: "sidebar", -} - -/** Every old name the loader recognises, and what to write instead — for the `cockpit-setup` skill's reference. */ -export const OLD_NAMES: readonly { old: string; new: string }[] = [ - { old: "statusline", new: "status" }, - { old: "status.maxRows", new: "status.sidebarRows" }, - ...ROOT_SHELL.map((key) => ({ old: key, new: `shell.${key}` })), - { old: "ui.", new: "shell." }, - { old: "ui.historyMinutes", new: "shell.hideFinishedAfterMinutes" }, - { old: "ui.updateCheck", new: "updater.updateCheck" }, - { old: "ui.sidebarOrder", new: "sidebar" }, - { old: ".sidebarOrder", new: "sidebar" }, - { old: "subagents.hideFinishedAfter", new: "subagents.hideFinishedAfterMinutes" }, - { old: "subagents.hideNestedAfter", new: "subagents.hideNestedAfterSeconds" }, -] - -const oldText = (old: string) => `"${old}" is no longer read — run ${SETUP_COMMAND}` - -const isObject = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value) - -/** - * Whether a key in one bay's section is an old name; when it is, it is noted. `prefix` is how a key is - * named in a notice: `shell.` in a file, nothing in plugin options (where the keys are the bay's own). - */ -export function oldInSection( - bay: Bay, - key: string, - value: unknown, - file: string, - prefix: string, - notes: SettingsNotice[], -): boolean { - const old = (name: string, now: string) => - notes.push({ bay, file, kind: "old", old: name, new: now, text: oldText(name) }) - const now = OLD_IN_SECTION[bay]?.[key] - if (key === "sidebarOrder") old(`${prefix}${key}`, "sidebar") - else if (now) old(`${prefix}${key}`, `${bay}.${now}`) - else if (bay === "shell" && key === "ui" && isObject(value)) { - for (const inner of Object.keys(value)) old(`${prefix}ui.${inner}`, OLD_UI[inner] ?? `shell.${inner}`) - } else return false - return true -} - -/** Whether a key at a file's top level is an old name; when it is, it is noted. */ -export function oldAtTop( - key: string, - value: unknown, - note: (notice: Omit) => void, -): boolean { - if (key === "statusline") { - note({ bay: "status", kind: "old", old: key, new: "status", text: oldText(key) }) - } else if (ROOT_SHELL.includes(key)) { - note({ bay: "shell", kind: "old", old: key, new: `shell.${key}`, text: oldText(key) }) - } else if (key === "ui" && isObject(value)) { - for (const inner of Object.keys(value)) { - const now = OLD_UI[inner] ?? `shell.${inner}` - const bay = now.startsWith("updater.") ? "updater" : "shell" - note({ bay, kind: "old", old: `ui.${inner}`, new: now, text: oldText(`ui.${inner}`) }) - } - } else if (ROOT_STATUS.includes(key)) { - note({ - bay: "status", - kind: "unread", - old: key, - new: `status.${key}`, - text: `"${key}" at the top level is not read: it belongs in "status"`, - }) - } else return false - return true -} diff --git a/packages/client/src/setup/text.ts b/packages/client/src/setup/text.ts index 55b00e1..4ab9433 100644 --- a/packages/client/src/setup/text.ts +++ b/packages/client/src/setup/text.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/test/checks.test.ts b/packages/client/test/checks.test.ts index d63d193..ff8cd92 100644 --- a/packages/client/test/checks.test.ts +++ b/packages/client/test/checks.test.ts @@ -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/design.test.ts b/packages/client/test/design.test.ts index 640c894..b743c5b 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/settings.test.ts b/packages/client/test/settings.test.ts index aee2257..5246a94 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/index.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 d8e16bd..90ab6a8 100644 --- a/packages/client/test/setup.test.ts +++ b/packages/client/test/setup.test.ts @@ -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/opencode/README.md b/packages/opencode/README.md index f926b93..c8501c9 100644 --- a/packages/opencode/README.md +++ b/packages/opencode/README.md @@ -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/review/src/cli/preview.ts b/packages/review/src/cli/preview.ts index 9f2e269..633d99a 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 41e03af..175f4a2 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/view/state.ts b/packages/review/src/core/view/state.ts index c03b954..523d1ad 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/test/view/settings.test.ts b/packages/review/test/view/settings.test.ts index d6aaac1..be510f6 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/shell/README.md b/packages/shell/README.md index dc205d8..3a51bda 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 d277b2d..9ce8eca 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 2931b8a..8ea12a2 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/test/config.test.ts b/packages/shell/test/config.test.ts index b1f3c7a..46a79b4 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 959e516..bcc6894 100644 --- a/packages/status/README.md +++ b/packages/status/README.md @@ -134,7 +134,7 @@ 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 +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. `/statusline`, its name until 0.9, still works for one release and says the new @@ -193,12 +193,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/skills/status-setup/SKILL.md b/packages/status/skills/status-setup/SKILL.md index dacb498..c066a57 100644 --- a/packages/status/skills/status-setup/SKILL.md +++ b/packages/status/skills/status-setup/SKILL.md @@ -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 7d17499..0435c40 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 index c5b0805..9002eb5 100644 --- a/packages/status/skills/statusline-design/SKILL.md +++ b/packages/status/skills/statusline-design/SKILL.md @@ -111,7 +111,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/src/core/config.ts b/packages/status/src/core/config.ts index 7e9b93f..d8b8cf3 100644 --- a/packages/status/src/core/config.ts +++ b/packages/status/src/core/config.ts @@ -15,9 +15,9 @@ import { * * ~/.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. + * 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. */ /** diff --git a/packages/status/src/core/notices.ts b/packages/status/src/core/notices.ts index 12983a7..ef32746 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 868bbb5..eb0821a 100644 --- a/packages/status/src/core/preview.ts +++ b/packages/status/src/core/preview.ts @@ -79,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 diff --git a/packages/status/src/tui/index.tsx b/packages/status/src/tui/index.tsx index 6907907..7d897dc 100644 --- a/packages/status/src/tui/index.tsx +++ b/packages/status/src/tui/index.tsx @@ -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/test/config.test.ts b/packages/status/test/config.test.ts index 68b8ccf..26b99ba 100644 --- a/packages/status/test/config.test.ts +++ b/packages/status/test/config.test.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/notices.test.ts b/packages/status/test/notices.test.ts index a54790c..a8c8266 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/subagents/README.md b/packages/subagents/README.md index 27bc2eb..f57b092 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 cc6c44c..fd58f46 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 27bdd6a..1736f0b 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/test/settings.test.ts b/packages/subagents/test/settings.test.ts index 022437c..fb8c751 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 80a0ff4..3e3c756 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/trust/README.md b/packages/trust/README.md index 36cd7ec..7081bfa 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/src/tui/index.tsx b/packages/trust/src/tui/index.tsx index af358ac..793fc28 100644 --- a/packages/trust/src/tui/index.tsx +++ b/packages/trust/src/tui/index.tsx @@ -144,7 +144,7 @@ export function createTrustTui({ source = TRUST_PACKAGE }: { source?: string } = 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 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. */ diff --git a/packages/updater/README.md b/packages/updater/README.md index c86eb82..5754f54 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/src/core/settings.ts b/packages/updater/src/core/settings.ts index c967ff6..6cf21b4 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 ed4ee3b..a42c8f9 100644 --- a/packages/updater/src/doctor/checks.ts +++ b/packages/updater/src/doctor/checks.ts @@ -112,7 +112,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/test/doctor.test.ts b/packages/updater/test/doctor.test.ts index af40fce..b069746 100644 --- a/packages/updater/test/doctor.test.ts +++ b/packages/updater/test/doctor.test.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`, ]) }) }) diff --git a/packages/updater/test/registry.test.ts b/packages/updater/test/registry.test.ts index 13e66de..3770279 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/site/src/content/docs/configuration.md b/site/src/content/docs/configuration.md index 724d58d..6a0f4cb 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: [doctor](/help/doctor/) and +`/cockpit-setup` list each one, and a top-level one is 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/status/configuration.md b/site/src/content/docs/status/configuration.md index 51ef15b..5a5f7d5 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/subagents/overview.md b/site/src/content/docs/subagents/overview.md index d62e243..afe4e32 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 c6624d0..0e38977 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 e8fe74f..130315b 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. From df9399ab2a8e81aa32e71bcbc0b3e2f536f20675 Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 19:33:36 -0500 Subject: [PATCH 13/34] =?UTF-8?q?Status:=20/statusline=20is=20gone=20?= =?UTF-8?q?=E2=80=94=20/status-setup=20is=20the=20command's=20only=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.9 renamed the command and kept the old name for one release, as a command of its own whose line said the new name. It goes in 0.10, as announced. The skill no longer lists it as a trigger; the smoke runs /status-setup with AGENT=1 instead, and looks for the old section's new notice. Co-Authored-By: Claude Opus 5.5 --- packages/opencode/README.md | 2 +- packages/opencode/test/duplicate.test.ts | 2 +- packages/status/README.md | 9 +++--- packages/status/skills/status-setup/SKILL.md | 2 +- packages/status/src/core/setup.ts | 6 +--- packages/status/src/server.ts | 12 +------ packages/status/test/setup.test.ts | 8 ++--- scripts/smoke/install.ts | 4 +-- scripts/smoke/probes/status.ts | 31 +++++++++---------- site/src/content/docs/help/troubleshooting.md | 7 +++-- site/src/content/docs/status/drawing.md | 2 +- site/src/content/docs/status/overview.mdx | 3 +- 12 files changed, 34 insertions(+), 54 deletions(-) diff --git a/packages/opencode/README.md b/packages/opencode/README.md index c8501c9..31d4f83 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. diff --git a/packages/opencode/test/duplicate.test.ts b/packages/opencode/test/duplicate.test.ts index dc3bea2..9e2b28d 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/status/README.md b/packages/status/README.md index bcc6894..a680cdf 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 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. `/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 diff --git a/packages/status/skills/status-setup/SKILL.md b/packages/status/skills/status-setup/SKILL.md index c066a57..d3b686b 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 diff --git a/packages/status/src/core/setup.ts b/packages/status/src/core/setup.ts index f68cbd2..2f1113f 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/server.ts b/packages/status/src/server.ts index 988eb90..761f75a 100644 --- a/packages/status/src/server.ts +++ b/packages/status/src/server.ts @@ -17,7 +17,7 @@ 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 { 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/test/setup.test.ts b/packages/status/test/setup.test.ts index b2bfc0b..65e32fd 100644 --- a/packages/status/test/setup.test.ts +++ b/packages/status/test/setup.test.ts @@ -13,7 +13,7 @@ import { type StatusConfig, } from "../src/core/config.ts" import { SEGMENT_ABOUT, statusReference } from "../src/core/reference.ts" -import { OLD_PROMPT, SETUP_PROMPT, SETUP_SKILL, SETUP_SKILL_DIR } from "../src/core/setup.ts" +import { SETUP_PROMPT, SETUP_SKILL, SETUP_SKILL_DIR } from "../src/core/setup.ts" import { createStatusServer } from "../src/server.ts" /** @@ -50,7 +50,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 +144,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/scripts/smoke/install.ts b/scripts/smoke/install.ts index 35bcf9f..e5a734d 100644 --- a/scripts/smoke/install.ts +++ b/scripts/smoke/install.ts @@ -145,8 +145,8 @@ export async function prepare(mode: Mode): Promise { * 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. + * 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"), diff --git a/scripts/smoke/probes/status.ts b/scripts/smoke/probes/status.ts index f8e5256..e3595c4 100644 --- a/scripts/smoke/probes/status.ts +++ b/scripts/smoke/probes/status.ts @@ -1,5 +1,5 @@ /** - * Status: the statusline draws from a published build and names a section from before 0.9; its setup + * 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. */ @@ -10,12 +10,10 @@ 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+%/ -/** `/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"/, -] -const OLD_SECTION = `! settings: "statusline" is no longer read` +/** `/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") @@ -70,22 +68,21 @@ export const status: Probe = { pass("/status-setup offered once as it was typed") if (!agent) return /** - * 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. + * 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("statusline") - const old = await seen(120_000, STATUS_SKILL_USED) + 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( - old.all, - `/statusline never ran the status-setup skill with its new name said (missing ${old.missing.join(", ")})`, - old.last, + used.all, + `/status-setup never ran the status-setup skill (missing ${used.missing.join(", ")})`, + used.last, ) - pass("/statusline ran the status-setup skill, saying its new name") + pass("/status-setup ran the status-setup skill") }, /** * Status's `diagnostics` row in the sidebar table, on both versions (#34: OpenCode 2 hands a @@ -137,7 +134,7 @@ export const status: Probe = { empty(drawn) { expect(TOKENS_ROW.test(rightHalf(drawn)), "the sidebar never drew the Status table's tokens row", drawn) expect( - rightHalf(drawn).includes(`! settings: "statusline" is no longer`), + rightHalf(drawn).includes(OLD_SECTION), `the sidebar never named the old "statusline" section`, drawn, ) diff --git a/site/src/content/docs/help/troubleshooting.md b/site/src/content/docs/help/troubleshooting.md index 61939f2..b237084 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/drawing.md b/site/src/content/docs/status/drawing.md index 3e21274..300060e 100644 --- a/site/src/content/docs/status/drawing.md +++ b/site/src/content/docs/status/drawing.md @@ -111,7 +111,7 @@ 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. diff --git a/site/src/content/docs/status/overview.mdx b/site/src/content/docs/status/overview.mdx index 4c46915..2fb8f8d 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 From 938e785bddf285cc680002f38b84f92248a16a91 Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 19:37:00 -0500 Subject: [PATCH 14/34] Build: clears dist/ and types/ first, and writes the declarations itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dist/ was cleared already; types/ is tsc -b's, and an incremental build never deletes what a moved or removed source left behind — a stale file kept review/frame looking fine for three releases. The build now removes types/ and the build info, then runs tsc -b, so packing and releasing (which run the build and nothing else before npm pack) ship declarations that match the code. Co-Authored-By: Claude Opus 5.5 --- scripts/README.md | 2 +- scripts/build.ts | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index 011159f..95f1050 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -16,7 +16,7 @@ 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 — 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`) | diff --git a/scripts/build.ts b/scripts/build.ts index 37959de..e0969a0 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") From 2e9a4c933ee058ea857635036bc8a11847997c52 Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 19:42:56 -0500 Subject: [PATCH 15/34] =?UTF-8?q?Client:=20host=20splits=20by=20OpenCode?= =?UTF-8?q?=20=E2=80=94=20host/v1,=20host/v2,=20host/keymap,=20and=20the?= =?UTF-8?q?=20shape=20in=20host/index?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit host.ts was 800 lines: the Host shape, the key helpers, the v1 wrapper, the v2 builder and the entry both load. Each is its own file now; index keeps the shape, dualTui and onPaste and re-exports the rest, so `client/host` exports exactly what it did (public API snapshot unchanged). No line of logic changed. Co-Authored-By: Claude Opus 5.5 --- packages/client/package.json | 4 +- packages/client/src/opencode/host/index.ts | 214 ++++++++++ packages/client/src/opencode/host/keymap.ts | 33 ++ packages/client/src/opencode/host/v1.ts | 129 ++++++ .../src/opencode/{host.ts => host/v2.ts} | 368 +----------------- packages/client/src/opencode/service.ts | 2 +- packages/client/src/opencode/sidebar.ts | 2 +- packages/client/src/setup/brief.ts | 2 +- packages/client/src/setup/palette.ts | 2 +- packages/client/test/host.test.ts | 2 +- packages/client/test/service.test.ts | 2 +- packages/client/test/setup.test.ts | 2 +- packages/client/test/sidebar.test.ts | 2 +- 13 files changed, 392 insertions(+), 372 deletions(-) create mode 100644 packages/client/src/opencode/host/index.ts create mode 100644 packages/client/src/opencode/host/keymap.ts create mode 100644 packages/client/src/opencode/host/v1.ts rename packages/client/src/opencode/{host.ts => host/v2.ts} (53%) diff --git a/packages/client/package.json b/packages/client/package.json index e416b66..66099a9 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -29,8 +29,8 @@ "default": "./dist/feature.js" }, "./host": { - "types": "./types/opencode/host.d.ts", - "default": "./dist/opencode/host.js" + "types": "./types/opencode/host/index.d.ts", + "default": "./dist/opencode/host/index.js" }, "./server": { "types": "./types/opencode/server.d.ts", diff --git a/packages/client/src/opencode/host/index.ts b/packages/client/src/opencode/host/index.ts new file mode 100644 index 0000000..0441581 --- /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 0000000..2124cd8 --- /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 0000000..5507231 --- /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/opencode/host.ts b/packages/client/src/opencode/host/v2.ts similarity index 53% rename from packages/client/src/opencode/host.ts rename to packages/client/src/opencode/host/v2.ts index 03a597c..8b53a1a 100644 --- a/packages/client/src/opencode/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 { cockpitVersion, createLog, type Log, silentLog } from "../log.ts" -import { registerSetup } from "../setup/palette.ts" -import { textElement } from "./elements.tsx" -import { registerServiceCheck } from "./service.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/). */ - 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/service.ts b/packages/client/src/opencode/service.ts index b535d40..bf8d080 100644 --- a/packages/client/src/opencode/service.ts +++ b/packages/client/src/opencode/service.ts @@ -29,7 +29,7 @@ import { fileURLToPath } from "node:url" import { resolvePaths } from "@opencode-cockpit/protocol" import { claimFeature } from "../feature.ts" import type { Log } from "../log.ts" -import type { Host } from "./host.ts" +import type { Host } from "./host/index.ts" export const RESTART_COMMAND = "opencode service restart" diff --git a/packages/client/src/opencode/sidebar.ts b/packages/client/src/opencode/sidebar.ts index 2138511..8f92c9b 100644 --- a/packages/client/src/opencode/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, diff --git a/packages/client/src/setup/brief.ts b/packages/client/src/setup/brief.ts index 3b2ac47..b344525 100644 --- a/packages/client/src/setup/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 "../opencode/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/palette.ts b/packages/client/src/setup/palette.ts index c78b577..c48d7c0 100644 --- a/packages/client/src/setup/palette.ts +++ b/packages/client/src/setup/palette.ts @@ -1,7 +1,7 @@ /** The interface: a palette entry that asks the agent to set Cockpit up. */ import { claimFeature } from "../feature.ts" -import type { Host } from "../opencode/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/test/host.test.ts b/packages/client/test/host.test.ts index da72e97..4fb6896 100644 --- a/packages/client/test/host.test.ts +++ b/packages/client/test/host.test.ts @@ -7,7 +7,7 @@ import { onPaste, themeFromV2, type V2Context, -} from "../src/opencode/host.ts" +} 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/service.test.ts b/packages/client/test/service.test.ts index bedb58d..b8ddd18 100644 --- a/packages/client/test/service.test.ts +++ b/packages/client/test/service.test.ts @@ -3,7 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { join } from "node:path" import { claimFeature } from "../src/feature.ts" import { silentLog } from "../src/log.ts" -import type { Host } from "../src/opencode/host.ts" +import type { Host } from "../src/opencode/host/index.ts" import { type AgentRecord, agentsDir, diff --git a/packages/client/test/setup.test.ts b/packages/client/test/setup.test.ts index 90ab6a8..582c08a 100644 --- a/packages/client/test/setup.test.ts +++ b/packages/client/test/setup.test.ts @@ -3,7 +3,7 @@ import { existsSync } from "node:fs" import { join } from "node:path" import { claimFeature } from "../src/feature.ts" import { silentLog } from "../src/log.ts" -import type { Host } from "../src/opencode/host.ts" +import type { Host } from "../src/opencode/host/index.ts" import type { ServerHost } from "../src/opencode/server.ts" import { sectionText, writeSection } from "../src/setup/conventions.ts" import { diff --git a/packages/client/test/sidebar.test.ts b/packages/client/test/sidebar.test.ts index e32264a..5296f75 100644 --- a/packages/client/test/sidebar.test.ts +++ b/packages/client/test/sidebar.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { Host } from "../src/opencode/host.ts" +import type { Host } from "../src/opencode/host/index.ts" import { orderedSidebar } from "../src/opencode/sidebar.ts" describe("orderedSidebar", () => { From 50b71803c11f9badc8ede0735e6a1f6c9bf472b4 Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 19:46:48 -0500 Subject: [PATCH 16/34] =?UTF-8?q?Client:=20server=20splits=20by=20concern?= =?UTF-8?q?=20=E2=80=94=20server/parts,=20v1,=20v2,=20skills,=20surfaces,?= =?UTF-8?q?=20and=20the=20entry=20in=20index?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit server.ts was 841 lines: what a feature answers with, the v1 hooks, the v2 context and its tools and commands, reading a skill's folder, and the entry both OpenCodes load. Each is its own file; surfaces moves in beside them. index re-exports exactly what `client/server` exported before (public API snapshot unchanged). No line of logic changed; v1's contentText is exported inside the folder only, for v2's tool text. Co-Authored-By: Claude Opus 5.5 --- packages/client/package.json | 4 +- packages/client/src/opencode/server.ts | 841 ------------------ packages/client/src/opencode/server/index.ts | 256 ++++++ packages/client/src/opencode/server/parts.ts | 187 ++++ packages/client/src/opencode/server/skills.ts | 39 + .../src/opencode/{ => server}/surfaces.ts | 4 +- packages/client/src/opencode/server/v1.ts | 158 ++++ packages/client/src/opencode/server/v2.ts | 215 +++++ packages/client/src/setup/server.ts | 2 +- packages/client/test/catalog.test.ts | 2 +- packages/client/test/server.test.ts | 2 +- packages/client/test/setup.test.ts | 2 +- packages/client/test/surfaces.test.ts | 4 +- 13 files changed, 865 insertions(+), 851 deletions(-) delete mode 100644 packages/client/src/opencode/server.ts create mode 100644 packages/client/src/opencode/server/index.ts create mode 100644 packages/client/src/opencode/server/parts.ts create mode 100644 packages/client/src/opencode/server/skills.ts rename packages/client/src/opencode/{ => server}/surfaces.ts (97%) create mode 100644 packages/client/src/opencode/server/v1.ts create mode 100644 packages/client/src/opencode/server/v2.ts diff --git a/packages/client/package.json b/packages/client/package.json index 66099a9..45d55d9 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -33,8 +33,8 @@ "default": "./dist/opencode/host/index.js" }, "./server": { - "types": "./types/opencode/server.d.ts", - "default": "./dist/opencode/server.js" + "types": "./types/opencode/server/index.d.ts", + "default": "./dist/opencode/server/index.js" }, "./log": { "types": "./types/log.d.ts", diff --git a/packages/client/src/opencode/server.ts b/packages/client/src/opencode/server.ts deleted file mode 100644 index d197632..0000000 --- a/packages/client/src/opencode/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 { setupServer } from "../setup/server.ts" -import { recordAgent } from "./service.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/opencode/server/index.ts b/packages/client/src/opencode/server/index.ts new file mode 100644 index 0000000..7be6702 --- /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 0000000..55fbd74 --- /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 0000000..9f0dea0 --- /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/opencode/surfaces.ts b/packages/client/src/opencode/server/surfaces.ts similarity index 97% rename from packages/client/src/opencode/surfaces.ts rename to packages/client/src/opencode/server/surfaces.ts index aeb58cb..c99a997 100644 --- a/packages/client/src/opencode/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 "../settings/catalog.ts" -import type { Bay } from "../settings/index.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 0000000..af3a47c --- /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 0000000..7c32575 --- /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/setup/server.ts b/packages/client/src/setup/server.ts index bfb4373..d9dc6c9 100644 --- a/packages/client/src/setup/server.ts +++ b/packages/client/src/setup/server.ts @@ -5,7 +5,7 @@ import { homedir } from "node:os" import { dirname } from "node:path" import { tool } from "@opencode-ai/plugin" import { claimedFeatures, claimFeature } from "../feature.ts" -import type { ServerHost, ServerParts } from "../opencode/server.ts" +import type { ServerHost, ServerParts } from "../opencode/server/index.ts" import { instructionPaths, writeSection } from "./conventions.ts" import { CONVENTIONS_TOOL, SETTINGS_TOOL, SETUP_PROMPT, SETUP_SKILL_DIR, SETUP_SLASH } from "./names.ts" import { previewCommands } from "./previews.ts" diff --git a/packages/client/test/catalog.test.ts b/packages/client/test/catalog.test.ts index c1b6ddc..efd0b04 100644 --- a/packages/client/test/catalog.test.ts +++ b/packages/client/test/catalog.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import { readFileSync } from "node:fs" import { join } from "node:path" -import { readSkill } from "../src/opencode/server.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 { diff --git a/packages/client/test/server.test.ts b/packages/client/test/server.test.ts index 8cef691..5d40a17 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/opencode/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/setup.test.ts b/packages/client/test/setup.test.ts index 582c08a..8ca8244 100644 --- a/packages/client/test/setup.test.ts +++ b/packages/client/test/setup.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path" import { claimFeature } from "../src/feature.ts" import { silentLog } from "../src/log.ts" import type { Host } from "../src/opencode/host/index.ts" -import type { ServerHost } from "../src/opencode/server.ts" +import type { ServerHost } from "../src/opencode/server/index.ts" import { sectionText, writeSection } from "../src/setup/conventions.ts" import { baysOfEntry, diff --git a/packages/client/test/surfaces.test.ts b/packages/client/test/surfaces.test.ts index 21b02ed..50bc0da 100644 --- a/packages/client/test/surfaces.test.ts +++ b/packages/client/test/surfaces.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" -import { composeParts, dualServer, type ServerParts } from "../src/opencode/server.ts" -import { keyText, openText, registerSurfaces, surfacesLine } from "../src/opencode/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" /** From 637eac8f5b9b0d0decb3fbfed884c26516cfce2d Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 19:51:14 -0500 Subject: [PATCH 17/34] =?UTF-8?q?Status:=20config.ts=20splits=20=E2=80=94?= =?UTF-8?q?=20the=20shape,=20reading=20it,=20what=20is=20wrong=20with=20it?= =?UTF-8?q?,=20and=20the=20lines=20it=20draws?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 616 lines held four concerns. core/config/ now has shape.ts (the section's types and the kind of each key), load.ts (loadStatus and the notices `cockpit_settings` lists), problems.ts (what only Status can tell is wrong), lines.ts (presets, overrides, lines resolved). index.ts is `status/config`, which the bundle's bin imports: it exports exactly what it did (public API snapshot unchanged). No cycles: shape ← lines ← problems ← load. No line of logic changed. Co-Authored-By: Claude Opus 5.5 --- packages/opencode/test/catalog.test.ts | 2 +- packages/status/package.json | 4 +- packages/status/src/cli/preview.ts | 2 +- packages/status/src/core/authoring.ts | 2 +- packages/status/src/core/builtins/session.ts | 2 +- packages/status/src/core/builtins/settings.ts | 2 +- packages/status/src/core/command.ts | 2 +- packages/status/src/core/config.ts | 616 ------------------ packages/status/src/core/config/index.ts | 35 + packages/status/src/core/config/lines.ts | 262 ++++++++ packages/status/src/core/config/load.ts | 106 +++ packages/status/src/core/config/problems.ts | 91 +++ packages/status/src/core/config/shape.ts | 166 +++++ packages/status/src/core/custom.ts | 2 +- packages/status/src/core/preview.ts | 2 +- packages/status/src/core/reference.ts | 2 +- packages/status/src/core/segments.ts | 2 +- packages/status/src/core/types.ts | 2 +- packages/status/src/server.ts | 2 +- packages/status/src/tui/index.tsx | 2 +- packages/status/src/tui/state/store.ts | 2 +- packages/status/test/checks.test.ts | 2 +- packages/status/test/config.test.ts | 2 +- packages/status/test/preview.test.ts | 2 +- packages/status/test/setup.test.ts | 2 +- packages/status/test/table.test.ts | 2 +- packages/status/test/turn.test.ts | 2 +- site/engine/bays/status.ts | 2 +- 28 files changed, 683 insertions(+), 639 deletions(-) delete mode 100644 packages/status/src/core/config.ts create mode 100644 packages/status/src/core/config/index.ts create mode 100644 packages/status/src/core/config/lines.ts create mode 100644 packages/status/src/core/config/load.ts create mode 100644 packages/status/src/core/config/problems.ts create mode 100644 packages/status/src/core/config/shape.ts diff --git a/packages/opencode/test/catalog.test.ts b/packages/opencode/test/catalog.test.ts index 25ec5af..9a5d4eb 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/status/package.json b/packages/status/package.json index 32191ad..f53a441 100644 --- a/packages/status/package.json +++ b/packages/status/package.json @@ -33,8 +33,8 @@ "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", diff --git a/packages/status/src/cli/preview.ts b/packages/status/src/cli/preview.ts index 185e6e8..9ca5c91 100644 --- a/packages/status/src/cli/preview.ts +++ b/packages/status/src/cli/preview.ts @@ -16,7 +16,7 @@ import { readFileSync, watch } from "node:fs" import { homedir } from "node:os" import { budgetFile } from "../core/budget.ts" -import { type ResolvedLine, resolveLines, type Surface } from "../core/config.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" diff --git a/packages/status/src/core/authoring.ts b/packages/status/src/core/authoring.ts index dcb2a6a..1fb0097 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/builtins/session.ts b/packages/status/src/core/builtins/session.ts index 574211e..02fa379 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 ba4e96b..7c55d3b 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 014406c..a689293 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" /** diff --git a/packages/status/src/core/config.ts b/packages/status/src/core/config.ts deleted file mode 100644 index d8b8cf3..0000000 --- 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: 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. - */ - -/** - * 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 0000000..1d92cfc --- /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 0000000..2d76ee0 --- /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 0000000..8aeffac --- /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 0000000..64ec2c8 --- /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 0000000..d07eed3 --- /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 9cc465f..191bafa 100644 --- a/packages/status/src/core/custom.ts +++ b/packages/status/src/core/custom.ts @@ -1,6 +1,6 @@ import { homedir } from "node:os" import { basename, isAbsolute, resolve } from "node:path" -import type { SegmentConfig } from "./config.ts" +import type { SegmentConfig } from "./config/index.ts" import type { StatusContext } from "./context.ts" import type { Piece, Run, SegmentDef, Tone } from "./segments.ts" diff --git a/packages/status/src/core/preview.ts b/packages/status/src/core/preview.ts index eb0821a..9c4b537 100644 --- a/packages/status/src/core/preview.ts +++ b/packages/status/src/core/preview.ts @@ -15,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" diff --git a/packages/status/src/core/reference.ts b/packages/status/src/core/reference.ts index ef2382a..2753320 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 10f141c..9fe7d06 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/types.ts b/packages/status/src/core/types.ts index c5c276e..189dc1b 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/server.ts b/packages/status/src/server.ts index 761f75a..9926ce4 100644 --- a/packages/status/src/server.ts +++ b/packages/status/src/server.ts @@ -16,7 +16,7 @@ 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 { statusNotices } from "./core/config/index.ts" import { SETUP_PROMPT, SETUP_SKILL_DIR, SETUP_SLASH } from "./core/setup.ts" const STATUS_PACKAGE = "@opencode-cockpit/status" diff --git a/packages/status/src/tui/index.tsx b/packages/status/src/tui/index.tsx index 7d897dc..c000451 100644 --- a/packages/status/src/tui/index.tsx +++ b/packages/status/src/tui/index.tsx @@ -6,7 +6,7 @@ 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 { 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" diff --git a/packages/status/src/tui/state/store.ts b/packages/status/src/tui/state/store.ts index 1aa410b..9e3b03a 100644 --- a/packages/status/src/tui/state/store.ts +++ b/packages/status/src/tui/state/store.ts @@ -2,7 +2,7 @@ import type { Host } from "@opencode-cockpit/client/host" import { type Accessor, createMemo, createRoot, createSignal } from "solid-js" 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.ts" +import { resolveLines, type StatusConfig } from "../../core/config/index.ts" import type { StatusContext } from "../../core/context.ts" import { branchDiffCommand, diff --git a/packages/status/test/checks.test.ts b/packages/status/test/checks.test.ts index 83a9b00..9e84669 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/config.test.ts b/packages/status/test/config.test.ts index 26b99ba..e097e29 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" diff --git a/packages/status/test/preview.test.ts b/packages/status/test/preview.test.ts index 0e3e748..7dcff8e 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 65e32fd..197aae6 100644 --- a/packages/status/test/setup.test.ts +++ b/packages/status/test/setup.test.ts @@ -11,7 +11,7 @@ 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 { SETUP_PROMPT, SETUP_SKILL, SETUP_SKILL_DIR } from "../src/core/setup.ts" import { createStatusServer } from "../src/server.ts" diff --git a/packages/status/test/table.test.ts b/packages/status/test/table.test.ts index 1257f57..95b9f6f 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 75c680f..6c46ac8 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/site/engine/bays/status.ts b/site/engine/bays/status.ts index b3e6b87..db11ba9 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)) From 3776832da1a6bf20166a0de893d08602d7dff61a Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 19:54:32 -0500 Subject: [PATCH 18/34] =?UTF-8?q?Client:=20setup's=20conventions=20split?= =?UTF-8?q?=20=E2=80=94=20the=20section,=20where=20it=20goes,=20what=20the?= =?UTF-8?q?=20project=20uses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit conventions.ts (403) did three things under three headings: find and write the marked section, locate the instruction files, and scan the project (long-running commands, ticket keys, the repo). Each heading is a file in setup/conventions/; importers name the one they use. No line changed. Co-Authored-By: Claude Opus 5.5 --- packages/client/src/setup/conventions.ts | 403 ------------------ .../src/setup/conventions/instructions.ts | 66 +++ .../client/src/setup/conventions/project.ts | 220 ++++++++++ .../client/src/setup/conventions/section.ts | 122 ++++++ packages/client/src/setup/server.ts | 3 +- packages/client/src/setup/tune.ts | 14 +- packages/client/test/conventions.test.ts | 8 +- packages/client/test/setup.test.ts | 2 +- 8 files changed, 417 insertions(+), 421 deletions(-) delete mode 100644 packages/client/src/setup/conventions.ts create mode 100644 packages/client/src/setup/conventions/instructions.ts create mode 100644 packages/client/src/setup/conventions/project.ts create mode 100644 packages/client/src/setup/conventions/section.ts diff --git a/packages/client/src/setup/conventions.ts b/packages/client/src/setup/conventions.ts deleted file mode 100644 index 9cf4267..0000000 --- a/packages/client/src/setup/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/setup/conventions/instructions.ts b/packages/client/src/setup/conventions/instructions.ts new file mode 100644 index 0000000..1c9e1f0 --- /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 0000000..6ab086d --- /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 0000000..683134d --- /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/server.ts b/packages/client/src/setup/server.ts index d9dc6c9..d734ab9 100644 --- a/packages/client/src/setup/server.ts +++ b/packages/client/src/setup/server.ts @@ -6,7 +6,8 @@ import { dirname } from "node:path" import { tool } from "@opencode-ai/plugin" import { claimedFeatures, claimFeature } from "../feature.ts" import type { ServerHost, ServerParts } from "../opencode/server/index.ts" -import { instructionPaths, writeSection } from "./conventions.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/tune.ts b/packages/client/src/setup/tune.ts index 56aedae..b271da3 100644 --- a/packages/client/src/setup/tune.ts +++ b/packages/client/src/setup/tune.ts @@ -9,17 +9,9 @@ import { homedir } from "node:os" import { BAY_ABOUT, BAY_COMMANDS, DEFAULT_KEYS } from "../settings/catalog.ts" -import { - findSections, - type GitRun, - type InstructionFile, - type ProjectFacts, - projectFacts, - readInstructions, - sectionText, - type WriteAction, - type Written, -} from "./conventions.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/conventions.test.ts b/packages/client/test/conventions.test.ts index 669a31f..e826b7b 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/setup/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/setup.test.ts b/packages/client/test/setup.test.ts index 8ca8244..8089f03 100644 --- a/packages/client/test/setup.test.ts +++ b/packages/client/test/setup.test.ts @@ -5,7 +5,7 @@ import { claimFeature } from "../src/feature.ts" import { silentLog } from "../src/log.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.ts" +import { sectionText, writeSection } from "../src/setup/conventions/section.ts" import { baysOfEntry, briefAgent, From dcee044ee2633f3abd8802ab84ef33b3b62efdc6 Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 19:58:22 -0500 Subject: [PATCH 19/34] =?UTF-8?q?Sidebar:=20one=20measure=20of=20a=20block?= =?UTF-8?q?'s=20width,=20in=20client/sidebar=20=E2=80=94=20four=20bays=20h?= =?UTF-8?q?ad=20it=20pasted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shell, Subagents, Trail and Trust each measured their block the same five lines: the container's width over the block's own, a quarter of the window before the first layout. `blockWidth` and `measureBlock` in client/sidebar now; Shell keeps its narrower guess (30) as an argument, Subagents keeps its debug line. client/sidebar gains the two exports. The row painters stay per bay: they draw different tones, and faint differently (italic in three, the terminal's dim in Updater) — merging them would change what the sidebar looks like. Co-Authored-By: Claude Opus 5.5 --- packages/client/src/opencode/sidebar.ts | 28 +++++++++++++++++++ packages/client/test/sidebar.test.ts | 16 ++++++++++- packages/shell/src/tui/components/sidebar.tsx | 8 ++---- packages/subagents/src/tui/index.tsx | 11 ++------ packages/trail/src/tui/paint.ts | 9 ++---- packages/trust/src/tui/index.tsx | 9 ++---- 6 files changed, 52 insertions(+), 29 deletions(-) diff --git a/packages/client/src/opencode/sidebar.ts b/packages/client/src/opencode/sidebar.ts index 8f92c9b..ee19ed1 100644 --- a/packages/client/src/opencode/sidebar.ts +++ b/packages/client/src/opencode/sidebar.ts @@ -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/test/sidebar.test.ts b/packages/client/test/sidebar.test.ts index 5296f75..a692350 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/opencode/host/index.ts" -import { orderedSidebar } from "../src/opencode/sidebar.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/shell/src/tui/components/sidebar.tsx b/packages/shell/src/tui/components/sidebar.tsx index 70e99c2..8db4b92 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/subagents/src/tui/index.tsx b/packages/subagents/src/tui/index.tsx index 9a77db8..66d80ca 100644 --- a/packages/subagents/src/tui/index.tsx +++ b/packages/subagents/src/tui/index.tsx @@ -4,6 +4,7 @@ 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 { noticeText } from "@opencode-cockpit/client/settings" +import { blockWidth, measureBlock } from "@opencode-cockpit/client/sidebar" import type { BoxRenderable } from "@opentui/core" import { createSignal } from "solid-js" import { loadSubagents, type SubagentsConfig } from "../core/config.ts" @@ -146,18 +147,12 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st */ 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 + const { measured, own, parent } = measureBlock(block) 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)) + return blockWidth(block, api.renderer.width) } const busy = (session: Session) => session.status === "running" || session.status === "starting" const working = () => nodes().some(({ session }) => busy(session) || session.status === "waiting") diff --git a/packages/trail/src/tui/paint.ts b/packages/trail/src/tui/paint.ts index ae0facd..4e49035 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/trust/src/tui/index.tsx b/packages/trust/src/tui/index.tsx index 793fc28..2735d61 100644 --- a/packages/trust/src/tui/index.tsx +++ b/packages/trust/src/tui/index.tsx @@ -14,6 +14,7 @@ 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 { blockWidth } from "@opencode-cockpit/client/sidebar" import type { BoxRenderable } from "@opentui/core" import { createSignal } from "solid-js" import { commandOf, type Seen } from "../core/adapt/seen.ts" @@ -101,13 +102,7 @@ export function createTrustTui({ source = TRUST_PACKAGE }: { source?: string } = 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)) - } + const sidebarWidth = () => blockWidth(block, api.renderer.width) /** * The dialog: the activity `/trust` opens on, and the ledger behind `l`. Each keeps its own cursor From 97093875066468133337d759abec6ab3a3aa42f3 Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 20:01:22 -0500 Subject: [PATCH 20/34] =?UTF-8?q?Catalog:=20each=20bay's=20own=20keys=20in?= =?UTF-8?q?=20a=20file=20of=20its=20own=20=E2=80=94=20settings/keys/.?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit catalog.ts carried every bay's keys inline (Status's alone ran 107 lines). They still live in client — it cannot import the bays, and packages/opencode's test still holds each list to its bay's defaults — but one file per bay, so a bay's keys are found and changed next to nothing else. OWN_KEYS is the same object; the generated reference is unchanged. Co-Authored-By: Claude Opus 5.5 --- packages/client/src/settings/catalog.ts | 246 +----------------- packages/client/src/settings/keys/review.ts | 13 + packages/client/src/settings/keys/shell.ts | 76 ++++++ packages/client/src/settings/keys/status.ts | 111 ++++++++ .../client/src/settings/keys/subagents.ts | 25 ++ packages/client/src/settings/keys/trust.ts | 19 ++ packages/client/src/settings/keys/updater.ts | 12 + 7 files changed, 269 insertions(+), 233 deletions(-) create mode 100644 packages/client/src/settings/keys/review.ts create mode 100644 packages/client/src/settings/keys/shell.ts create mode 100644 packages/client/src/settings/keys/status.ts create mode 100644 packages/client/src/settings/keys/subagents.ts create mode 100644 packages/client/src/settings/keys/trust.ts create mode 100644 packages/client/src/settings/keys/updater.ts diff --git a/packages/client/src/settings/catalog.ts b/packages/client/src/settings/catalog.ts index b8b6bd3..5d4997a 100644 --- a/packages/client/src/settings/catalog.ts +++ b/packages/client/src/settings/catalog.ts @@ -11,6 +11,12 @@ */ 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. */ diff --git a/packages/client/src/settings/keys/review.ts b/packages/client/src/settings/keys/review.ts new file mode 100644 index 0000000..3bb3e58 --- /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 0000000..0f83776 --- /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 0000000..428bb75 --- /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 0000000..c96b8ae --- /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 0000000..48f54ba --- /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 0000000..a1434f2 --- /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", + }, +] From d2164f7d15f1441833e9bee96e4476a3caaaba44 Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 20:04:25 -0500 Subject: [PATCH 21/34] =?UTF-8?q?Trail:=20the=20dialog's=20columns=20in=20?= =?UTF-8?q?a=20file=20of=20their=20own=20=E2=80=94=20core/view/columns.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dialog.ts laid out the whole of `/trail` and every row in it. How wide each column is at a width, and a thing's or a touch's row in those columns, move to columns.ts; dialog.ts keeps the header, the keys, the empty prose and the assembly. No line of logic changed. Co-Authored-By: Claude Opus 5.5 --- packages/trail/src/core/view/columns.ts | 155 ++++++++++++++++++++++++ packages/trail/src/core/view/dialog.ts | 138 +-------------------- 2 files changed, 158 insertions(+), 135 deletions(-) create mode 100644 packages/trail/src/core/view/columns.ts diff --git a/packages/trail/src/core/view/columns.ts b/packages/trail/src/core/view/columns.ts new file mode 100644 index 0000000..5e99cb9 --- /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 f0bf8e3..e4f254a 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) From 28ec8d8443ca1ae9bf041940407179abdaca934e Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 20:06:31 -0500 Subject: [PATCH 22/34] Trail's measurement runs on the shared runner, as Review's and Shell's do packages/trail/measure/agent.ts was written before scripts/measure-agent.ts and kept its own copies: finding OpenCode, the world's config and environment, the turn, reading tool calls and text, the retry loop. It uses the shared ones now. `measure` takes how many runs must pass (Trail asks two of three of a free model) and stops once enough have. Run on 2.0.18 and 1.18.32: both passed. Co-Authored-By: Claude Opus 5.5 --- packages/trail/measure/agent.ts | 144 +++++++------------------------- scripts/measure-agent.ts | 15 +++- 2 files changed, 39 insertions(+), 120 deletions(-) diff --git a/packages/trail/measure/agent.ts b/packages/trail/measure/agent.ts index 2a139a9..13bdd9b 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/scripts/measure-agent.ts b/scripts/measure-agent.ts index 94d497a..b920ef8 100644 --- a/scripts/measure-agent.ts +++ b/scripts/measure-agent.ts @@ -166,16 +166,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 +193,6 @@ export async function measure= pass ? 0 : 1) } From fedbd4685ff770195689ed513de3827202d114c7 Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 20:06:51 -0500 Subject: [PATCH 23/34] =?UTF-8?q?Smoke:=20the=20agent=20turn=20is=20the=20?= =?UTF-8?q?measurements'=20turn=20=E2=80=94=20one=20way=20to=20run=20`open?= =?UTF-8?q?code=20run`=20and=20read=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/smoke/agent.ts spawned OpenCode and parsed its JSON events itself, a third copy of what scripts/measure-agent.ts does. It calls `turn` now, which says whether the turn finished in its five minutes, so the smoke still fails a turn that hung. Co-Authored-By: Claude Opus 5.5 --- scripts/measure-agent.ts | 3 +++ scripts/smoke/agent.ts | 51 ++++++++++------------------------------ 2 files changed, 15 insertions(+), 39 deletions(-) diff --git a/scripts/measure-agent.ts b/scripts/measure-agent.ts index b920ef8..aa733f5 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, } } diff --git a/scripts/smoke/agent.ts b/scripts/smoke/agent.ts index b0834a5..f8bae52 100644 --- a/scripts/smoke/agent.ts +++ b/scripts/smoke/agent.ts @@ -4,6 +4,7 @@ */ import { join } from "node:path" +import { turn } from "../measure-agent.ts" import { opencode, root, v2 } from "./harness.ts" import type { Install } from "./install.ts" @@ -42,46 +43,18 @@ export function agentTurn(install: Install, asks: AgentAsk[]): void { ] : []), ].join(" ") - const args = [opencode, "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: install.project, + const at = { + oc: { bin: opencode, version: "", v2 }, + work: install.project, + project: install.project, + model: "opencode/space-bunny-free", env: install.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) + } + /** 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}`) } From 15721d91cc9cb1ecb196e3a86c584760dc7de3e5 Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 20:07:56 -0500 Subject: [PATCH 24/34] =?UTF-8?q?Changelog:=200.10.0's=20unreleased=20entr?= =?UTF-8?q?ies=20=E2=80=94=20the=20home,=20unknown=20names,=20doctor,=20wh?= =?UTF-8?q?at=20was=20removed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e47773..97f72a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,33 @@ 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 + +- **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. +- **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 From d1c433573bfe79ec6b6b15aabd7a1ce7698cbed6 Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 20:11:36 -0500 Subject: [PATCH 25/34] Smoke: OpenCode 2 configured as the install docs say, and Review's notice a wrong kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On OpenCode 2 every entry goes in opencode.json, which loads both halves — what `opencode plugin add` writes — instead of splitting the halves across opencode.json and cli.json, which no user does and which loaded a bay's interface twice. Review's section carried an old name for its `!` row; old names are unknown names now, said by /cockpit-setup only, so it carries a value of the wrong kind. Co-Authored-By: Claude Opus 5.5 --- scripts/smoke/install.ts | 16 +++++++--------- scripts/smoke/probes/review.ts | 4 ++-- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/scripts/smoke/install.ts b/scripts/smoke/install.ts index e5a734d..01d21e1 100644 --- a/scripts/smoke/install.ts +++ b/scripts/smoke/install.ts @@ -109,15 +109,13 @@ export async function prepare(mode: Mode): Promise { const serverEntries: string[] = [] for (const entry of tuiEntries) if (await hasServer(entry)) serverEntries.push(entry) /** - * 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. The bundle goes where the - * docs put it: in both files on v1, and on v2 in opencode.json alone, which loads both its halves. + * 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", serverEntries], - ["cli.json", "https://opencode.ai/cli.json", "plugins", mode === "bundle" ? [] : tuiEntries], - ] + ? [["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], @@ -163,8 +161,8 @@ export async function prepare(mode: Mode): Promise { ], 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 }, + /** A value of the wrong kind in Review's section: the pane has to say so in a `!` row. */ + review: { source: 5 }, }), ) diff --git a/scripts/smoke/probes/review.ts b/scripts/smoke/probes/review.ts index 7a141ba..73a6841 100644 --- a/scripts/smoke/probes/review.ts +++ b/scripts/smoke/probes/review.ts @@ -26,11 +26,11 @@ export const review: Probe = { 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"'], + ["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 old setting") + 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() { From e35d5ace3a6618e0d5d3203751e36198d3614739 Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 20:15:04 -0500 Subject: [PATCH 26/34] =?UTF-8?q?Trust:=20the=20ledger's=20explorer=20spli?= =?UTF-8?q?ts=20=E2=80=94=20the=20tree,=20the=20card,=20and=20the=20screen?= =?UTF-8?q?=20that=20lays=20them=20out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit explorer.ts was 1021 lines in four marked sections. The tree (families as folds, a row each) is tree.ts, the card (what the selected row is and why, and its buttons) card.ts; explorer.ts keeps the screen. Importers name the file that has what they use. No line of logic changed. Co-Authored-By: Claude Opus 5.5 --- packages/trust/src/cli/preview.ts | 3 +- packages/trust/src/core/view/card.ts | 555 ++++++++++++++++ packages/trust/src/core/view/explorer.ts | 801 +---------------------- packages/trust/src/core/view/tree.ts | 232 +++++++ packages/trust/src/tui/index.tsx | 10 +- packages/trust/test/view.test.ts | 13 +- 6 files changed, 817 insertions(+), 797 deletions(-) create mode 100644 packages/trust/src/core/view/card.ts create mode 100644 packages/trust/src/core/view/tree.ts diff --git a/packages/trust/src/cli/preview.ts b/packages/trust/src/cli/preview.ts index 1ae164c..9ed09cc 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/view/card.ts b/packages/trust/src/core/view/card.ts new file mode 100644 index 0000000..4361c0e --- /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 8c31514..2f72afc 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 0000000..bbfbed4 --- /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/tui/index.tsx b/packages/trust/src/tui/index.tsx index 2735d61..8139ec4 100644 --- a/packages/trust/src/tui/index.tsx +++ b/packages/trust/src/tui/index.tsx @@ -26,18 +26,12 @@ 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 ExplorerView, explorerRows } 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 { ALWAYS_KEY, type Node, nodeTarget, reveal } from "../core/view/tree.ts" import { createJournal } from "./journal.ts" import { createSource } from "./source.ts" import { Dialog } from "./view/dialog.tsx" diff --git a/packages/trust/test/view.test.ts b/packages/trust/test/view.test.ts index e972988..316cee1 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"]>)() From d9a534f6dbca48ff829bf02540e8a620292cec3b Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 20:18:02 -0500 Subject: [PATCH 27/34] =?UTF-8?q?Subagents:=20the=20pane's=20items=20in=20?= =?UTF-8?q?calls.ts=20=E2=80=94=20screen.ts=20stacks=20them,=20header=20to?= =?UTF-8?q?=20footer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit screen.ts (978) drew every item of a subagent's conversation and laid the pane out around them. A call's row or box at each fold, its todo list, a card and the thinking move to calls.ts; screen.ts keeps the body, the header, the footer, the keys and the scroll. No line of logic changed. Co-Authored-By: Claude Opus 5.5 --- packages/subagents/src/core/view/calls.ts | 409 +++++++++++++++++++ packages/subagents/src/core/view/screen.ts | 431 +-------------------- 2 files changed, 426 insertions(+), 414 deletions(-) create mode 100644 packages/subagents/src/core/view/calls.ts diff --git a/packages/subagents/src/core/view/calls.ts b/packages/subagents/src/core/view/calls.ts new file mode 100644 index 0000000..e9797d8 --- /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 c3a511a..5f2b883 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. From 58304d79eaa02de7efe939c079072af7ff4c81e6 Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 20:29:18 -0500 Subject: [PATCH 28/34] =?UTF-8?q?Trust:=20the=20interface=20splits=20from?= =?UTF-8?q?=20one=20closure=20=E2=80=94=20paint,=20requests,=20the=20ledge?= =?UTF-8?q?r=20dialog,=20and=20the=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tui/index.tsx was one 851-line closure. paint.ts draws the sidebar block and the dialog; requests.ts decides and answers what OpenCode asks; ledger.tsx is `/trust` and its keys; index.tsx keeps the ledger file and the wiring — as Trail's was split for 0.9. No line of logic changed: what the closure held is a small shared object now. Co-Authored-By: Claude Opus 5.5 --- packages/trust/src/tui/index.tsx | 720 ++--------------------------- packages/trust/src/tui/ledger.tsx | 400 ++++++++++++++++ packages/trust/src/tui/paint.ts | 209 +++++++++ packages/trust/src/tui/requests.ts | 206 +++++++++ 4 files changed, 852 insertions(+), 683 deletions(-) create mode 100644 packages/trust/src/tui/ledger.tsx create mode 100644 packages/trust/src/tui/paint.ts create mode 100644 packages/trust/src/tui/requests.ts diff --git a/packages/trust/src/tui/index.tsx b/packages/trust/src/tui/index.tsx index 8139ec4..426013c 100644 --- a/packages/trust/src/tui/index.tsx +++ b/packages/trust/src/tui/index.tsx @@ -5,36 +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 { blockWidth } from "@opencode-cockpit/client/sidebar" -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 { type ExplorerView, explorerRows } 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 { ALWAYS_KEY, type Node, nodeTarget, reveal } from "../core/view/tree.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" @@ -45,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 } = {}) { @@ -80,138 +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 = () => blockWidth(block, api.renderer.width) - - /** - * 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 — 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 --------------------------------------------------------------------------- @@ -226,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() }) @@ -235,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"}` + live.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)}` - 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: [ { @@ -783,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", @@ -792,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.", }) }, }, @@ -808,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)), @@ -821,9 +177,9 @@ export function createTrustTui({ source = TRUST_PACKAGE }: { source?: string } = sidebar_content: () => ( { - block = box + painter.setBlock(box) draw() }} /> @@ -832,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 0000000..85507cd --- /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 0000000..490385b --- /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 0000000..99fc671 --- /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() + }, + } +} From 2f0ec514ad9df574d2f23960ce604c0916a8f4f2 Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 20:29:18 -0500 Subject: [PATCH 29/34] =?UTF-8?q?Subagents:=20the=20interface=20splits=20f?= =?UTF-8?q?rom=20one=20closure=20=E2=80=94=20paint,=20the=20pane,=20messag?= =?UTF-8?q?es,=20and=20the=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tui/index.tsx was one 966-line closure. paint.ts draws the sidebar block and the pane; pane.ts is the pane and its keys (open, move, fold, stop, remove, background); messages.ts is typing to a subagent and relaying its answer; surface.ts the pane's state they share; index.tsx follows the conversation and wires the slots. No line of logic changed; the spinners' frame belongs to the painter. Co-Authored-By: Claude Opus 5.5 --- packages/subagents/src/tui/index.tsx | 804 ++----------------------- packages/subagents/src/tui/messages.ts | 182 ++++++ packages/subagents/src/tui/paint.ts | 219 +++++++ packages/subagents/src/tui/pane.ts | 454 ++++++++++++++ packages/subagents/src/tui/surface.ts | 28 + 5 files changed, 945 insertions(+), 742 deletions(-) create mode 100644 packages/subagents/src/tui/messages.ts create mode 100644 packages/subagents/src/tui/paint.ts create mode 100644 packages/subagents/src/tui/pane.ts create mode 100644 packages/subagents/src/tui/surface.ts diff --git a/packages/subagents/src/tui/index.tsx b/packages/subagents/src/tui/index.tsx index 66d80ca..fbb1974 100644 --- a/packages/subagents/src/tui/index.tsx +++ b/packages/subagents/src/tui/index.tsx @@ -1,27 +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 { blockWidth, measureBlock } from "@opencode-cockpit/client/sidebar" -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" @@ -32,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 } = {}) { @@ -110,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 => { @@ -138,144 +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 = () => { - 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) - } 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 -------------------------------------------------------------------------------------- @@ -285,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. */ @@ -352,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: [ { @@ -889,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", @@ -897,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)), @@ -917,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)} /> ), }, @@ -932,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)} /> ), }, @@ -952,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 0000000..a347395 --- /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 0000000..8896819 --- /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 0000000..d6b9e6a --- /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 0000000..403f8e4 --- /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 +} From c23854714fb52733f1c17220047fd0510f6f03d5 Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 20:33:05 -0500 Subject: [PATCH 30/34] =?UTF-8?q?Review:=20the=20panel's=20verbs=20split?= =?UTF-8?q?=20=E2=80=94=20navigation,=20threads,=20and=20the=20table=20tha?= =?UTF-8?q?t=20joins=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit actions.ts (765) held every verb. Moving through the list and the diff is navigation.ts; comments, replies, marking read or viewed, folding and submit are threads.ts, which reads the rows through navigation; actions.ts keeps sources, the base, stats, the viewer and the keys screen, and returns the one table keys.ts binds. No line of logic changed. Co-Authored-By: Claude Opus 5.5 --- packages/review/src/tui/panel/actions.ts | 551 +------------------- packages/review/src/tui/panel/navigation.ts | 219 ++++++++ packages/review/src/tui/panel/threads.ts | 373 +++++++++++++ 3 files changed, 615 insertions(+), 528 deletions(-) create mode 100644 packages/review/src/tui/panel/navigation.ts create mode 100644 packages/review/src/tui/panel/threads.ts diff --git a/packages/review/src/tui/panel/actions.ts b/packages/review/src/tui/panel/actions.ts index 4bd70e5..f467621 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 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 { 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 { 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 0000000..b11f232 --- /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 0000000..cc5ea07 --- /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, + } +} From 9df81b3d313cfdbbf00587872664ba284ba1a0a2 Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 20:49:12 -0500 Subject: [PATCH 31/34] =?UTF-8?q?Trust=20and=20the=20updater=20load=20on?= =?UTF-8?q?=20OpenCode=202=20when=20installed=20alone=20=E2=80=94=20each?= =?UTF-8?q?=20gets=20an=20empty=20agent=20half?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode 2 sets up a plugin's interface only once its agent half has loaded; one with no `./server` is skipped on both sides without a word in either log (measured on 2.0.18). These two had none, so `opencode plugin add @opencode-cockpit/trust` (or `/updater`), as the install docs say, loaded nothing. The smoke never saw it: it put interfaces in cli.json, which no install does. Each now publishes `./server` — `dualServer(id, async () => ({}))`, which also brings `/cockpit-setup`. Doctor lists them as EMPTY_SERVER_BAYS: on OpenCode 1 the interface alone is the whole bay, so a tui.json-only install is not told to add an agent half. Smoke: trust, updater, bays and bundle pass on 2.0.18; trust and updater on 1.18.32. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++++ packages/trust/package.json | 5 +++++ packages/trust/server.js | 6 ++++++ packages/trust/src/server.ts | 12 ++++++++++++ packages/updater/package.json | 5 +++++ packages/updater/server.js | 6 ++++++ packages/updater/src/doctor/checks.ts | 5 +++++ packages/updater/src/server.ts | 12 ++++++++++++ packages/updater/test/doctor.test.ts | 6 +++--- 9 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 packages/trust/server.js create mode 100644 packages/trust/src/server.ts create mode 100644 packages/updater/server.js create mode 100644 packages/updater/src/server.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 97f72a3..dbcfc22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,11 @@ All notable changes to this project are documented here. The format follows ### 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. diff --git a/packages/trust/package.json b/packages/trust/package.json index af1c815..3f010f7 100644 --- a/packages/trust/package.json +++ b/packages/trust/package.json @@ -23,6 +23,10 @@ "terminal" ], "exports": { + "./server": { + "types": "./types/server.d.ts", + "default": "./dist/server.js" + }, "./tui": { "types": "./types/tui/index.d.ts", "default": "./dist/tui/index.js" @@ -33,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 0000000..10f25fa --- /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/server.ts b/packages/trust/src/server.ts new file mode 100644 index 0000000..49e9d91 --- /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/updater/package.json b/packages/updater/package.json index 669b11d..c331d02 100644 --- a/packages/updater/package.json +++ b/packages/updater/package.json @@ -21,6 +21,10 @@ "plugins" ], "exports": { + "./server": { + "types": "./types/server.d.ts", + "default": "./dist/server.js" + }, "./tui": { "types": "./types/tui/index.d.ts", "default": "./dist/tui/index.js" @@ -35,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 0000000..10f25fa --- /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/doctor/checks.ts b/packages/updater/src/doctor/checks.ts index a42c8f9..bf077af 100644 --- a/packages/updater/src/doctor/checks.ts +++ b/packages/updater/src/doctor/checks.ts @@ -26,6 +26,11 @@ 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", "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" diff --git a/packages/updater/src/server.ts b/packages/updater/src/server.ts new file mode 100644 index 0000000..10a88a5 --- /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/test/doctor.test.ts b/packages/updater/test/doctor.test.ts index b069746..c612169 100644 --- a/packages/updater/test/doctor.test.ts +++ b/packages/updater/test/doctor.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" 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" @@ -628,7 +628,7 @@ 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"), ) @@ -645,6 +645,6 @@ describe("the bays doctor knows", () => { const manifest = JSON.parse(readFileSync(join(packages, dir, "package.json"), "utf8")) return Object.keys(manifest.exports ?? {}).includes("./server") }) - expect([...SERVER_BAYS].sort()).toEqual(withServer.sort()) + expect([...SERVER_BAYS, ...EMPTY_SERVER_BAYS].sort()).toEqual(withServer.sort()) }) }) From 22092fbf0af3a9d95241e34f71d4d206baf24ca7 Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 20:55:03 -0500 Subject: [PATCH 32/34] Review fixes: the site engine resolves through exports, pack-check loads the new agent halves, docs say what doctor lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the independent review of the branch: - site/scripts/engine.ts mapped `@opencode-cockpit//` to `src/.ts`, which the client's new folders broke for every subpath but `.`; it reads the package's `exports` now (output unchanged). - pack-check imports `trust/server` and `updater/server` from an install of each alone, and checks OpenCode 2's door is shipped — the fix this release makes had no pack-time check. - Two site pages said doctor lists every name from before 0.9; it lists top-level ones, and `/cockpit-setup` names a key a bay does not read in its own section. - A smoke comment still said Trust and the updater have no agent side. Co-Authored-By: Claude Opus 5.5 --- scripts/pack-check.ts | 18 +++++++++++++++++- scripts/smoke/install.ts | 2 +- site/scripts/engine.ts | 14 ++++++++++---- site/src/content/docs/configuration.md | 4 ++-- site/src/content/docs/help/doctor.md | 7 ++++--- 5 files changed, 34 insertions(+), 11 deletions(-) diff --git a/scripts/pack-check.ts b/scripts/pack-check.ts index d6f0fc5..8121917 100644 --- a/scripts/pack-check.ts +++ b/scripts/pack-check.ts @@ -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/install.ts b/scripts/smoke/install.ts index 01d21e1..8b5a4ed 100644 --- a/scripts/smoke/install.ts +++ b/scripts/smoke/install.ts @@ -101,7 +101,7 @@ export async function prepare(mode: Mode): Promise { 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 and the updater have none. + * `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 diff --git a/site/scripts/engine.ts b/site/scripts/engine.ts index 1db0ab8..c31d198 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 6a0f4cb..b393b73 100644 --- a/site/src/content/docs/configuration.md +++ b/site/src/content/docs/configuration.md @@ -238,8 +238,8 @@ 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**, and since -0.10 they are names like any other Cockpit does not know: [doctor](/help/doctor/) and -`/cockpit-setup` list each one, and a top-level one is a `!` row at the top of Status's column: +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 not a setting: did you mean "status"? diff --git a/site/src/content/docs/help/doctor.md b/site/src/content/docs/help/doctor.md index dae30c7..7250892 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 From af5759b22fbee1313e521fe84d8e4c91d252f4b0 Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 21:17:23 -0500 Subject: [PATCH 33/34] Status's statusline-design skill folder is gone, as it said it would be in 0.10; the client preview shows a real notice The folder was kept in 0.9 for anyone who had copied it; its own description said "removed in 0.10". status-setup carries the same design rules. The client preview's sample settings used `shell.sidebarOrder` to show a `!` row, which an unknown key in a section no longer draws; it uses a `sidebar` list in Shell's section, which does. Both from the independent review. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + packages/client/src/cli/preview.ts | 2 +- .../status/skills/statusline-design/SKILL.md | 143 ------------------ packages/status/test/setup.test.ts | 5 - site/src/content/docs/status/drawing.md | 3 +- 5 files changed, 4 insertions(+), 151 deletions(-) delete mode 100644 packages/status/skills/statusline-design/SKILL.md diff --git a/CHANGELOG.md b/CHANGELOG.md index dbcfc22..4d77ca8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ All notable changes to this project are documented here. The format follows ### 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; diff --git a/packages/client/src/cli/preview.ts b/packages/client/src/cli/preview.ts index c3fc424..a0d1688 100644 --- a/packages/client/src/cli/preview.ts +++ b/packages/client/src/cli/preview.ts @@ -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/status/skills/statusline-design/SKILL.md b/packages/status/skills/statusline-design/SKILL.md deleted file mode 100644 index 9002eb5..0000000 --- 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. 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/test/setup.test.ts b/packages/status/test/setup.test.ts index 197aae6..a9c81f9 100644 --- a/packages/status/test/setup.test.ts +++ b/packages/status/test/setup.test.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", () => { diff --git a/site/src/content/docs/status/drawing.md b/site/src/content/docs/status/drawing.md index 300060e..d64a9d8 100644 --- a/site/src/content/docs/status/drawing.md +++ b/site/src/content/docs/status/drawing.md @@ -117,8 +117,7 @@ segment, the design rules this bay learned the expensive way, and the preview to 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 From 46c38d696143eab6220135d8e7b40ac542bd15d7 Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 22:56:47 -0500 Subject: [PATCH 34/34] =?UTF-8?q?Review:=20up=20to=201,000=20files,=20read?= =?UTF-8?q?=20eight=20at=20a=20time=20=E2=80=94=20and=20a=20notice=20no=20?= =?UTF-8?q?longer=20hides=20the=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pane stopped at 200 files, and this release's own branch (222) was cut short. Files were read one after another, 6 ms each; eight at a time, 200 take 390 ms instead of 1.3 s. A paint measured 4 ms at 200 files, 9 ms at 1,000 and 117 ms at 2,000, so the cap moves to 1,000, where a paint still fits in a frame. The footer's notice took the keys' row. "More than N files changed" stays for the whole review, so every key — the way out included — was gone. Trouble now takes the rule's row above the keys, and the footer is still two rows. Co-Authored-By: Claude Opus 5.5 --- packages/review/src/core/git/changes.ts | 11 ++++- packages/review/src/core/view/chrome.ts | 38 +++++++++------- packages/review/src/io/git.ts | 56 ++++++++++++++---------- packages/review/test/watch/stats.test.ts | 15 ++++--- 4 files changed, 74 insertions(+), 46 deletions(-) diff --git a/packages/review/src/core/git/changes.ts b/packages/review/src/core/git/changes.ts index 0dec1fe..9c85c04 100644 --- a/packages/review/src/core/git/changes.ts +++ b/packages/review/src/core/git/changes.ts @@ -16,8 +16,15 @@ export interface GitResult { base?: string } -/** More than this and the pane is not the right tool — and reading them all would stall the TUI. */ -export const MAX_FILES = 200 +/** + * 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 /** diff --git a/packages/review/src/core/view/chrome.ts b/packages/review/src/core/view/chrome.ts index 516f0b2..9354445 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/io/git.ts b/packages/review/src/io/git.ts index 4dcc0fa..57e9bbb 100644 --- a/packages/review/src/io/git.ts +++ b/packages/review/src/io/git.ts @@ -146,30 +146,49 @@ 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 decide(path, before, await readWorking(cwd, path), statusChange(code, from), from, "HEAD") + }) +} + +/** 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 + +/** + * 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. + */ +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) + } } return { files, errors } } @@ -284,19 +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 } } diff --git a/packages/review/test/watch/stats.test.ts b/packages/review/test/watch/stats.test.ts index 8aeaa5b..b4c37d1 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") }) })