From b5526696582a7596d2dc21edd090287eb6041961 Mon Sep 17 00:00:00 2001 From: Codestz Date: Sat, 3 Oct 2026 09:14:10 -0500 Subject: [PATCH 01/90] client/settings: one JSONC loader, a section per bay, one sidebar order Shared keys (enabled, keybinds, sidebar, sidebarRows, hideWhenEmpty) spelled once, defaults per bay, type-checked with a notice. The top-level sidebar list is the only order (default status, subagents, shell, trail, trust at 110-150, between OpenCode 1's Context and MCP). Old pre-0.9 names are detected, not read: each is a notice saying to run /cockpit-setup. Unknown sidebar entries name the closest bay. Never throws. Co-Authored-By: Claude Opus 5.5 --- packages/client/package.json | 8 + packages/client/src/jsonc.ts | 75 +++ packages/client/src/settings.ts | 701 ++++++++++++++++++++++++++ packages/client/src/sidebar.ts | 57 +-- packages/client/test/settings.test.ts | 246 +++++++++ packages/client/test/sidebar.test.ts | 33 +- 6 files changed, 1064 insertions(+), 56 deletions(-) create mode 100644 packages/client/src/jsonc.ts create mode 100644 packages/client/src/settings.ts create mode 100644 packages/client/test/settings.test.ts diff --git a/packages/client/package.json b/packages/client/package.json index 855ce5bf..255b6219 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -47,6 +47,14 @@ "./design": { "types": "./types/design.d.ts", "default": "./dist/design.js" + }, + "./elements": { + "types": "./types/elements.d.ts", + "default": "./dist/elements.js" + }, + "./settings": { + "types": "./types/settings.d.ts", + "default": "./dist/settings.js" } }, "files": [ diff --git a/packages/client/src/jsonc.ts b/packages/client/src/jsonc.ts new file mode 100644 index 00000000..185abf3c --- /dev/null +++ b/packages/client/src/jsonc.ts @@ -0,0 +1,75 @@ +/** + * JSON plus comments and trailing commas, which is what people write in a config file. + * + * Every bay but the Updater read its settings with `JSON.parse` inside a `catch {}`, so one `//` line + * or a trailing comma dropped the whole file without a word — while doctor, which parsed JSONC, said + * the file was fine. One parser for the loader and doctor is what keeps them agreeing. + * + * Read-only on purpose: Cockpit never writes a config file, so a parser that throws the comments + * away is all this needs. Node APIs only — doctor runs it under `npx`. + */ + +export type JsoncResult = { ok: true; value: unknown } | { ok: false; message: string } + +export function parseJsonc(text: string): JsoncResult { + try { + return { ok: true, value: JSON.parse(withoutTrailingCommas(withoutComments(text))) } + } catch (err) { + return { ok: false, message: err instanceof Error ? err.message : String(err) } + } +} + +/** Index just past the string starting at `i`, escapes included, so `"http://x"` stays a string. */ +function stringEnd(text: string, i: number): number { + let j = i + 1 + while (j < text.length && text[j] !== '"') j += text[j] === "\\" ? 2 : 1 + return j + 1 +} + +function withoutComments(text: string): string { + let out = "" + let i = 0 + while (i < text.length) { + const ch = text[i] as string + const next = text[i + 1] + if (ch === '"') { + const end = stringEnd(text, i) + out += text.slice(i, end) + i = end + } else if (ch === "/" && next === "/") { + while (i < text.length && text[i] !== "\n") i++ + } else if (ch === "/" && next === "*") { + const end = text.indexOf("*/", i + 2) + i = end === -1 ? text.length : end + 2 + } else { + out += ch + i++ + } + } + return out +} + +function withoutTrailingCommas(text: string): string { + let out = "" + let i = 0 + while (i < text.length) { + const ch = text[i] as string + if (ch === '"') { + const end = stringEnd(text, i) + out += text.slice(i, end) + i = end + continue + } + if (ch === ",") { + let j = i + 1 + while (j < text.length && /\s/.test(text[j] as string)) j++ + if (text[j] === "}" || text[j] === "]") { + i++ + continue + } + } + out += ch + i++ + } + return out +} diff --git a/packages/client/src/settings.ts b/packages/client/src/settings.ts new file mode 100644 index 00000000..c3eda786 --- /dev/null +++ b/packages/client/src/settings.ts @@ -0,0 +1,701 @@ +/** + * Cockpit's settings: one loader every bay reads through, so the files mean the same thing to all of + * them, to doctor and to `/cockpit-setup`. + * + * ~/.config/opencode-cockpit/config.json (or $XDG_CONFIG_HOME/…) + * /.cockpit.json wins over the global file + * plugin-entry options win over both + * + * Each bay grew its own copy of that merge, and no two read the file the same way: Shell's keys sat at + * the file's root, Status read the root as its own when it had no section, Subagents and Review read + * no file at all, and every copy but the Updater's dropped a file with one comment in it without a + * word (docs/opencode/settings-and-commands.md). Here: + * + * - **JSONC everywhere.** Comments and trailing commas are fine. + * - **One section per bay** (`status`, `subagents`, `shell`, `trail`, `trust`, `review`, `updater`), + * merged key by key, nested objects included; a list replaces the one before it. A file without a + * bay's section says nothing about that bay — its root is never read as anyone's settings. + * - **The same shared keys in every bay**, flat and spelled once: `enabled`, `keybinds`, `sidebar` + * (draw the block, a boolean), `sidebarRows`, `hideWhenEmpty`. Time keys carry their unit + * (`hideFinishedAfterMinutes`, `hideNestedAfterSeconds`). + * - **One order**: the top-level `sidebar` list, and nothing else. + * - **Old names are not read.** They are recognised, so each one is a notice — the bay draws it as a + * `!` row, doctor prints it, `/cockpit-setup` fixes it — and its value is ignored. + * - **It never throws.** An unreadable file, a wrong type, an unknown name: a notice, and the + * defaults. A typo in a config should never cost you the interface. + * + * Read once, at start — never in a draw path. Node APIs only: doctor runs this under `npx`. + */ + +import { readFileSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" +import { parseJsonc } from "./jsonc.ts" + +// ── Names ────────────────────────────────────────────────────────────────────────────────────── + +/** Bays that draw a sidebar block, in the default order. The `sidebar` list is made of these. */ +export const SIDEBAR_BAYS = ["status", "subagents", "shell", "trail", "trust"] as const +export type SidebarBay = (typeof SIDEBAR_BAYS)[number] + +/** Every bay with a section in the file. */ +export const BAYS = [...SIDEBAR_BAYS, "review", "updater"] as const +export type Bay = (typeof BAYS)[number] + +export const isBay = (name: unknown): name is Bay => (BAYS as readonly unknown[]).includes(name) +export const isSidebarBay = (name: unknown): name is SidebarBay => + (SIDEBAR_BAYS as readonly unknown[]).includes(name) + +/** The files, by where they are. */ +export const GLOBAL_FILE = "config.json" +export const PROJECT_FILE = ".cockpit.json" + +/** Where a notice from plugin-entry options says it came from. */ +export const OPTIONS_SOURCE = "plugin options" + +/** What every notice about an old name tells you to do. */ +export const SETUP_COMMAND = "/cockpit-setup" + +// ── The shape ────────────────────────────────────────────────────────────────────────────────── + +/** Spelled the same in every bay. */ +export interface SharedSettings { + /** The bay's off switch, in every file. `features.: false` still works too. */ + enabled: boolean + keybinds: Record + /** Draw the bay's sidebar block. A boolean here; the top-level `sidebar` is the order. */ + sidebar: boolean + /** Rows before the rest fold into `+ N more`. */ + sidebarRows: number + /** Draw nothing at all when there is nothing to list. Default false: the heading and `none yet`. */ + hideWhenEmpty: boolean +} + +/** + * What a bay starts from before any file is read. Every block is present by default except Trust's, + * which is opt-in (the palette toggles it per session); Trail is a core piece and shows. + */ +export const SHARED_DEFAULTS: Readonly> = { + status: { enabled: true, keybinds: {}, sidebar: true, sidebarRows: 8, hideWhenEmpty: false }, + subagents: { enabled: true, keybinds: {}, sidebar: true, sidebarRows: 6, hideWhenEmpty: false }, + shell: { enabled: true, keybinds: {}, sidebar: true, sidebarRows: 5, hideWhenEmpty: false }, + trail: { enabled: true, keybinds: {}, sidebar: true, sidebarRows: 5, hideWhenEmpty: false }, + trust: { enabled: true, keybinds: {}, sidebar: false, sidebarRows: 3, hideWhenEmpty: false }, + review: { enabled: true, keybinds: {}, sidebar: false, sidebarRows: 0, hideWhenEmpty: false }, + updater: { enabled: true, keybinds: {}, sidebar: false, sidebarRows: 0, hideWhenEmpty: false }, +} + +type Kind = "boolean" | "number" | "string" | "object" | "array" + +const SHARED_KIND: Readonly> = { + enabled: "boolean", + keybinds: "object", + sidebar: "boolean", + sidebarRows: "number", + hideWhenEmpty: "boolean", +} + +/** The whole file, as documented. Each bay types its own keys; these are the ones they share. */ +export interface CockpitSettings { + /** Top to bottom. Default: status, subagents, shell, trail, trust. */ + sidebar?: SidebarBay[] + /** The bundle's switches, read from the files too. */ + features?: Partial> + status?: Partial & Record + subagents?: Partial & Record + shell?: Partial & Record + trail?: Partial & Record + trust?: Partial & Record + review?: Partial & Record + updater?: { updateCheck?: boolean } +} + +// ── Notices ──────────────────────────────────────────────────────────────────────────────────── + +export type NoticeKind = + /** A name from before 0.9. Not read; `new` says what to write instead. */ + | "old" + /** Not read: an unknown name, or a key in the wrong place. */ + | "unread" + /** The right name with the wrong kind of value; the default is used. */ + | "invalid" + /** A file that is not JSON(C); the whole file is ignored. */ + | "unreadable" + +/** + * Something about the settings worth fixing. `old` is the name as written, `new` the one to write + * instead (when there is one). `text` says it in a sentence — `"statusline" is no longer read — run + * /cockpit-setup` — for a bay's `!` row (`noticeText`) and doctor's fix line (`${file}: ${text}`). + */ +export interface SettingsNotice { + /** The bay whose block should say it. `cockpit` belongs to no one bay: a file, the top level. */ + bay: Bay | "cockpit" + /** The file it was found in, or `plugin options`. */ + file: string + kind: NoticeKind + old?: string + new?: string + text: string +} + +/** The row a bay draws for a notice, after a `!` in the warning tone. */ +export const noticeText = (notice: SettingsNotice): string => `settings: ${notice.text}` + +// ── Old names ────────────────────────────────────────────────────────────────────────────────── +// +// Detection only, and removed in 0.10. 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. + +/** 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. Removed in 0.10. */ +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.`. Removed in 0.10. */ +const OLD_UI: Readonly> = { + historyMinutes: "shell.hideFinishedAfterMinutes", + updateCheck: "updater.updateCheck", + sidebarOrder: "sidebar", +} + +/** Every old name the loader recognises, and what to write instead — for `/cockpit-setup`'s brief. */ +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}` + +// ── Small helpers ────────────────────────────────────────────────────────────────────────────── + +type Section = Record + +const isObject = (value: unknown): value is Section => + typeof value === "object" && value !== null && !Array.isArray(value) + +function kindOf(value: unknown): Kind | undefined { + if (Array.isArray(value)) return "array" + if (isObject(value)) return "object" + if (typeof value === "number") return Number.isFinite(value) ? "number" : undefined + if (typeof value === "boolean" || typeof value === "string") return typeof value as Kind + return undefined +} + +const article = (kind: Kind) => (kind === "array" ? "a list" : kind === "object" ? "an object" : `a ${kind}`) + +/** Key by key, nested objects included; anything else — a list too — replaces what was there. */ +export function mergeSections(base: Section, over: Section): Section { + const out: Section = { ...base } + for (const [key, value] of Object.entries(over)) { + if (value === undefined) continue + const before = out[key] + out[key] = isObject(value) && isObject(before) ? mergeSections(before, value) : value + } + return out +} + +function distance(a: string, b: string): number { + const row = Array.from({ length: b.length + 1 }, (_, i) => i) + for (let i = 1; i <= a.length; i++) { + let diagonal = row[0] as number + row[0] = i + for (let j = 1; j <= b.length; j++) { + const above = row[j] as number + row[j] = Math.min(above + 1, (row[j - 1] as number) + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1)) + diagonal = above + } + } + return row[b.length] as number +} + +/** The valid name someone most likely meant: `shells` → `shell`, `statusline` → `status`. */ +export function closestName(name: string, valid: readonly string[]): string | undefined { + const lower = name.toLowerCase() + const exact = valid.find((each) => each === lower) + if (exact) return exact + const prefix = valid.find((each) => lower.startsWith(each) || each.startsWith(lower)) + if (prefix && lower.length >= 3) return prefix + let best: { name: string; cost: number } | undefined + for (const each of valid) { + const cost = distance(lower, each) + if (!best || cost < best.cost) best = { name: each, cost } + } + return best && best.cost <= 2 ? best.name : undefined +} + +// ── Reading the files ────────────────────────────────────────────────────────────────────────── + +export interface SettingsWhere { + /** The project directory, for its `.cockpit.json`. */ + directory?: string + env?: Readonly> + home?: string + /** A file's text, or undefined when there is none. Doctor hands its own disk. */ + read?: (path: string) => string | undefined +} + +export interface SettingsFile { + path: string + scope: "global" | "project" + found: boolean + /** Why it could not be used. The whole file is then ignored. */ + error?: string +} + +/** One file that was read: its sections, old names already taken out. */ +export interface SettingsLayer { + path: string + scope: "global" | "project" + sections: Partial> + /** Its `sidebar` list, valid names only; undefined when it sets none. */ + sidebar?: SidebarBay[] + features: Partial> +} + +export interface Settings { + /** Every file looked for, global first. */ + files: SettingsFile[] + /** The files that were read, global first. */ + layers: SettingsLayer[] + /** Each bay's section, global then project, before plugin options and defaults. */ + sections: Record + /** The list as the user wrote it (project replaces global); undefined when neither sets one. */ + sidebarList?: SidebarBay[] + /** The order the blocks draw in: the user's list, then every bay it left out, in default order. */ + sidebar: SidebarBay[] + features: Partial> + /** Everything to fix, from every file. A bay's own are also in `baySettings(…).notices`. */ + notices: SettingsNotice[] +} + +export function settingsPaths(where: SettingsWhere = {}): { global: string; project?: string } { + const env = where.env ?? process.env + const base = env.XDG_CONFIG_HOME || join(where.home ?? homedir(), ".config") + return { + global: join(base, "opencode-cockpit", GLOBAL_FILE), + ...(where.directory ? { project: join(where.directory, PROJECT_FILE) } : {}), + } +} + +function readText(path: string): string | undefined { + try { + return readFileSync(path, "utf8") + } catch { + return undefined + } +} + +/** + * One bay's section with its old keys taken out, each one noted. `prefix` is how a key is named in + * a notice: `shell.` in a file, nothing in plugin options (where the keys are the bay's own). + */ +function withoutOld( + bay: Bay, + section: Section, + file: string, + prefix: string, + notes: SettingsNotice[], +): Section { + const out: Section = {} + const old = (name: string, now: string) => + notes.push({ bay, file, kind: "old", old: name, new: now, text: oldText(name) }) + for (const [key, value] of Object.entries(section)) { + 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 if (key === "sidebar" && Array.isArray(value)) { + notes.push({ + bay, + file, + kind: "unread", + old: `${prefix}${key}`, + new: "sidebar", + text: `"${prefix}${key}" is a switch, true or false: the order is the top-level "sidebar" list`, + }) + } else out[key] = value + } + return out +} + +/** The shared keys of one section, type-checked: a wrong kind is dropped with a notice. */ +function checkShared( + bay: Bay, + section: Section, + file: string, + prefix: string, + notes: SettingsNotice[], +): Section { + const out: Section = {} + for (const [key, value] of Object.entries(section)) { + const want = SHARED_KIND[key as keyof SharedSettings] + if (want && kindOf(value) !== want) { + notes.push({ + bay, + file, + kind: "invalid", + old: `${prefix}${key}`, + text: `"${prefix}${key}" should be ${article(want)}; the default is used`, + }) + continue + } + out[key] = value + } + return out +} + +const TOP = ["sidebar", "features", "$schema", ...BAYS] as const + +function readSidebarList(value: unknown, file: string, notes: SettingsNotice[]): SidebarBay[] | undefined { + if (value === undefined) return undefined + const valid = SIDEBAR_BAYS.join(", ") + if (!Array.isArray(value)) { + notes.push({ + bay: "cockpit", + file, + kind: "invalid", + old: "sidebar", + text: `"sidebar" is the order of the blocks, a list: ["${SIDEBAR_BAYS.join('", "')}"]`, + }) + return undefined + } + const list: SidebarBay[] = [] + for (const name of value) { + if (isSidebarBay(name)) { + if (!list.includes(name)) list.push(name) + continue + } + const shown = typeof name === "string" ? name : JSON.stringify(name) + if (isBay(name)) { + notes.push({ + bay: name, + file, + kind: "unread", + old: `sidebar: "${shown}"`, + text: `"${shown}" in "sidebar" has no sidebar block (${valid})`, + }) + continue + } + const meant = typeof name === "string" ? closestName(name, SIDEBAR_BAYS) : undefined + notes.push({ + bay: (meant as SidebarBay | undefined) ?? "cockpit", + file, + kind: "unread", + old: `sidebar: "${shown}"`, + ...(meant ? { new: meant } : {}), + text: `"${shown}" in "sidebar" is not a bay${meant ? `: did you mean "${meant}"?` : ""} (${valid})`, + }) + } + return list +} + +function readFeatures(value: unknown, file: string, notes: SettingsNotice[]): Partial> { + const out: Partial> = {} + if (value === undefined) return out + if (!isObject(value)) { + notes.push({ + bay: "cockpit", + file, + kind: "invalid", + old: "features", + text: `"features" should be an object, like { "trust": false }`, + }) + return out + } + for (const [name, on] of Object.entries(value)) { + if (isBay(name) && typeof on === "boolean") { + out[name] = on + continue + } + const meant = isBay(name) ? undefined : closestName(name, BAYS) + notes.push({ + bay: isBay(name) ? name : "cockpit", + file, + kind: isBay(name) ? "invalid" : "unread", + old: `features.${name}`, + ...(meant ? { new: `features.${meant}` } : {}), + text: isBay(name) + ? `"features.${name}" should be true or false` + : `"features.${name}" is not a bay${meant ? `: did you mean "${meant}"?` : ""}`, + }) + } + return out +} + +/** One parsed file into its sections; every old name and stray key noted, and left out. */ +function readLayer(raw: Section, file: string, scope: SettingsLayer["scope"], notes: SettingsNotice[]) { + const note = (notice: Omit) => notes.push({ file, ...notice }) + const sections: Partial> = {} + + for (const [key, value] of Object.entries(raw)) { + if (key === "sidebar" || key === "features" || key === "$schema") continue + if (isBay(key)) { + if (isObject(value)) { + const section = withoutOld(key, value, file, `${key}.`, notes) + sections[key] = checkShared(key, section, file, `${key}.`, notes) + } else note({ bay: key, kind: "invalid", old: key, text: `"${key}" should be an object of settings` }) + } else if (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 { + const meant = closestName(key, TOP) + note({ + bay: meant && isBay(meant) ? meant : "cockpit", + kind: "unread", + old: key, + ...(meant ? { new: meant } : {}), + text: `"${key}" is not a setting${meant ? `: did you mean "${meant}"?` : ""}`, + }) + } + } + + const sidebar = readSidebarList(raw.sidebar, file, notes) + return { + path: file, + scope, + sections, + ...(sidebar ? { sidebar } : {}), + features: readFeatures(raw.features, file, notes), + } satisfies SettingsLayer +} + +/** + * Both files, read and merged. Never throws: a file that cannot be read is a notice and is ignored + * whole — and doctor, reading through this same function, says the same. + */ +export function loadSettings(where: SettingsWhere = {}): Settings { + const read = where.read ?? readText + const paths = settingsPaths(where) + const files: SettingsFile[] = [] + const layers: SettingsLayer[] = [] + const notices: SettingsNotice[] = [] + const wanted: [string, SettingsFile["scope"]][] = [[paths.global, "global"]] + if (paths.project) wanted.push([paths.project, "project"]) + + for (const [path, scope] of wanted) { + const text = read(path) + if (text === undefined) { + files.push({ path, scope, found: false }) + continue + } + const parsed = parseJsonc(text) + const value = parsed.ok ? parsed.value : undefined + if (!isObject(value)) { + const error = parsed.ok ? "not a JSON object" : parsed.message + files.push({ path, scope, found: true, error }) + notices.push({ + bay: "cockpit", + file: path, + kind: "unreadable", + text: `${error} — the whole file is ignored`, + }) + continue + } + files.push({ path, scope, found: true }) + layers.push(readLayer(value, path, scope, notices)) + } + + const sections = Object.fromEntries( + BAYS.map((bay) => [ + bay, + layers.reduce((merged, layer) => mergeSections(merged, layer.sections[bay] ?? {}), {} as Section), + ]), + ) as Record + /** A project's list replaces the global one rather than merging: it is an order, not a set. */ + const sidebarList = layers.reduce( + (list, layer) => layer.sidebar ?? list, + undefined, + ) + const features: Partial> = Object.assign({}, ...layers.map((layer) => layer.features)) + return { + files, + layers, + sections, + ...(sidebarList ? { sidebarList } : {}), + sidebar: [...(sidebarList ?? []), ...SIDEBAR_BAYS.filter((bay) => !sidebarList?.includes(bay))], + features, + notices, + } +} + +/** Notices that belong to no one bay: a file that could not be read, an unknown top-level name. */ +export const cockpitNotices = (settings: Settings): SettingsNotice[] => + settings.notices.filter((notice) => notice.bay === "cockpit") + +// ── The order ────────────────────────────────────────────────────────────────────────────────── + +/** + * The numbers the list hands out: 110, 120, … 150. OpenCode 1 sorts its own blocks by the same + * numbers — Context 100, MCP 200, LSP 300, Todo 400, Modified files 500 — so Cockpit's blocks sit + * together, under Context and above the rest. OpenCode 2 ignores the number and draws blocks in the + * order they register, which `orderedSidebar` makes this same order. + */ +export const SIDEBAR_FIRST = 110 +export const SIDEBAR_STEP = 10 + +/** Where a bay's block sits: its place in the list, and nothing else. */ +export function orderOf(settings: Pick, bay: SidebarBay): number { + return SIDEBAR_FIRST + Math.max(0, settings.sidebar.indexOf(bay)) * SIDEBAR_STEP +} + +// ── One bay ──────────────────────────────────────────────────────────────────────────────────── + +export interface BaySettings { + /** Defaults, then the global file, the project's, and plugin options. */ + config: SharedSettings & T + /** What was written, merged, without defaults. For a bay that merges a key its own way. */ + written: Section + /** The block's `order` for `api.slots.register`. Meaningless for a bay without a block. */ + order: number + /** This bay's notices, from the files and from its plugin options: one `!` row each. */ + notices: SettingsNotice[] + settings: Settings +} + +export interface BayInput { + /** The plugin entry's options: the bay's own keys, or a whole config with a section for it. */ + options?: unknown + /** Already loaded — the bundle can load once for every bay. */ + settings?: Settings + where?: SettingsWhere +} + +type Widen = { + [K in keyof T]: T[K] extends boolean + ? boolean + : T[K] extends number + ? number + : T[K] extends string + ? string + : T[K] +} + +/** + * A bay's settings in one call: its defaults, every file, its plugin options, and every value whose + * kind differs from its default's dropped (with a notice). A key whose default is undefined passes as + * written; a key with no default at all passes too, so a bay can read what it does not type. + * + * const { config, order, notices } = baySettings("shell", { dockHeight: 14, colors: true }, { + * options, where: { directory: api.state.path.directory }, + * }) + */ +export function baySettings>( + bay: Bay, + defaults?: T, + input: BayInput = {}, +): BaySettings> { + const settings = input.settings ?? loadSettings(input.where) + const notices = settings.notices.filter((notice) => notice.bay === bay) + const base: Section = { ...SHARED_DEFAULTS[bay], ...(defaults as Section | undefined) } + + /** Each source checked on its own, so a notice names the file it is in. */ + const sources = settings.layers.map((layer) => ({ + file: layer.path, + prefix: `${bay}.`, + section: layer.sections[bay] ?? {}, + })) + const options = optionsSection(bay, input.options) + if (options) { + const section = withoutOld(bay, options, OPTIONS_SOURCE, "", notices) + sources.push({ + file: OPTIONS_SOURCE, + prefix: "", + section: checkShared(bay, section, OPTIONS_SOURCE, "", notices), + }) + } + + let written: Section = {} + for (const source of sources) { + const checked: Section = {} + for (const [key, value] of Object.entries(source.section)) { + const want = kindOf(base[key]) + if (want && !(key in SHARED_KIND) && kindOf(value) !== want) { + notices.push({ + bay, + file: source.file, + kind: "invalid", + old: `${source.prefix}${key}`, + text: `"${source.prefix}${key}" should be ${article(want)}; the default is used`, + }) + continue + } + checked[key] = value + } + written = mergeSections(written, checked) + } + + const config = mergeSections(base, written) as SharedSettings & Widen + config.sidebarRows = Math.max(0, Math.floor(config.sidebarRows)) + if (settings.features[bay] === false) config.enabled = false + return { + config, + written, + order: isSidebarBay(bay) ? orderOf(settings, bay) : 0, + notices, + settings, + } +} + +/** + * Plugin options as one bay's section: a whole cockpit config's section for it, else the options + * themselves (a standalone entry carries its own keys). Only for options — a *file* without a section + * says nothing about the bay; reading the whole file as its settings was Status's trap. + */ +function optionsSection(bay: Bay, options: unknown): Section | undefined { + if (!isObject(options)) return undefined + const own = options[bay] + return isObject(own) ? own : options +} diff --git a/packages/client/src/sidebar.ts b/packages/client/src/sidebar.ts index 661347ac..7a3d7228 100644 --- a/packages/client/src/sidebar.ts +++ b/packages/client/src/sidebar.ts @@ -1,63 +1,42 @@ -import { readFileSync } from "node:fs" -import { homedir } from "node:os" -import { join } from "node:path" import type { Host } from "./host.ts" +import { isSidebarBay, loadSettings, orderOf, type SettingsWhere } from "./settings.ts" /** * Where a bay's block sits in OpenCode's sidebar. * * Every bay used to take its place from its own setting (Shell's `ui.sidebarOrder`, Status's * `statusline.sidebarOrder`…), so putting the statusline first meant knowing three numbers. Now one - * list in Cockpit's config orders them all: + * list in Cockpit's config orders them all, and it is the only order: * - * { "sidebar": ["status", "subagents", "shell"] } + * { "sidebar": ["status", "subagents", "shell", "trail", "trust"] } * - * in `~/.config/opencode-cockpit/config.json`, or a project's `.cockpit.json` (which wins). A bay's - * own explicit number still beats the list, and a bay the list leaves out keeps its default. + * in `~/.config/opencode-cockpit/config.json`, or a project's `.cockpit.json` (which replaces it). + * That list is also the default. A bay the list leaves out follows the ones it names, in default + * order. A bay's own `sidebarOrder` number is no longer read (`orderOf` in settings.ts, which also + * says where the numbers sit among OpenCode's own blocks). * - * Read once, at start — never in a draw path. + * New code takes `order` from `baySettings`; the two functions here are kept for the bays that have + * not moved to it yet. Read once, at start — never in a draw path. */ -/** Positions the list hands out: before every default (Status 140, Subagents 150, Shell 170). */ -const FIRST = 100 -const STEP = 10 +export type SidebarWhere = SettingsWhere -function readList(file: string): string[] | undefined { - try { - const parsed = JSON.parse(readFileSync(file, "utf8")) as { sidebar?: unknown } - return Array.isArray(parsed.sidebar) - ? parsed.sidebar.filter((name): name is string => typeof name === "string") - : undefined - } catch { - return undefined - } -} - -export interface SidebarWhere { - /** The project directory, for its `.cockpit.json`. */ - directory?: string - env?: Record - home?: string -} - -/** The configured order, project over global; undefined when neither sets one. */ +/** The configured order, project over global, valid names only; undefined when neither sets one. */ export function sidebarList(where: SidebarWhere = {}): string[] | undefined { - const env = where.env ?? process.env - const home = where.home ?? homedir() - const global = join(env.XDG_CONFIG_HOME ?? join(home, ".config"), "opencode-cockpit", "config.json") - const project = where.directory ? readList(join(where.directory, ".cockpit.json")) : undefined - return project ?? readList(global) + return loadSettings(where).sidebarList } +/** + * @deprecated `baySettings(bay, …).order`. `explicit` — a bay's old `sidebarOrder` — is ignored now; + * `fallback` only places a name that is not a sidebar bay. + */ export function sidebarOrder( bay: string, fallback: number, - explicit: number | undefined, + _explicit?: number, where: SidebarWhere = {}, ): number { - if (typeof explicit === "number") return explicit - const at = sidebarList(where)?.indexOf(bay) ?? -1 - return at >= 0 ? FIRST + at * STEP : fallback + return isSidebarBay(bay) ? orderOf(loadSettings(where), bay) : fallback } type Register = Host["slots"]["register"] diff --git a/packages/client/test/settings.test.ts b/packages/client/test/settings.test.ts new file mode 100644 index 00000000..82eae1be --- /dev/null +++ b/packages/client/test/settings.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, test } from "bun:test" +import { + baySettings, + closestName, + loadSettings, + OLD_NAMES, + orderOf, + type SettingsWhere, +} from "../src/settings.ts" + +/** + * One loader for every bay: the files mean the same thing to all of them, a comment never drops a + * file, an old name says it is not read, and nothing a user writes can throw. + */ + +const GLOBAL = "/home/me/.config/opencode-cockpit/config.json" +const PROJECT = "/work/project/.cockpit.json" + +function where(files: Record): SettingsWhere { + return { + directory: "/work/project", + env: {}, + home: "/home/me", + read: (path) => { + const file = files[path] + return file === undefined ? undefined : typeof file === "string" ? file : JSON.stringify(file) + }, + } +} + +describe("reading the files", () => { + test("comments and trailing commas are fine", () => { + const settings = loadSettings( + where({ + [GLOBAL]: `{ + // mine + "trust": { "threshold": 5, /* five */ }, + "sidebar": ["shell", "status",], + }`, + }), + ) + expect(settings.notices).toEqual([]) + expect(settings.sections.trust).toEqual({ threshold: 5 }) + expect(settings.sidebarList).toEqual(["shell", "status"]) + }) + + test("global, then project, then plugin options; nested objects merge key by key", () => { + const loaded = baySettings( + "shell", + { dockHeight: 14, lifecycle: { onExit: "stopMine", orphanAfterMinutes: 60 } }, + { + where: where({ + [GLOBAL]: { shell: { dockHeight: 16, sidebarRows: 9, lifecycle: { onExit: "keep" } } }, + [PROJECT]: { shell: { dockHeight: 20, keybinds: { "cockpit.shells.dock": "d" } } }, + }), + options: { sidebarRows: 2 }, + }, + ) + expect(loaded.config).toMatchObject({ + dockHeight: 20, + sidebarRows: 2, + enabled: true, + sidebar: true, + hideWhenEmpty: false, + keybinds: { "cockpit.shells.dock": "d" }, + lifecycle: { onExit: "keep", orphanAfterMinutes: 60 }, + }) + }) + + test("plugin options may be a whole config with a section for the bay", () => { + const loaded = baySettings("trust", {}, { where: where({}), options: { trust: { sidebarRows: 7 } } }) + expect(loaded.config.sidebarRows).toBe(7) + }) + + test("a file that cannot be read is a notice, and costs nothing else", () => { + const settings = loadSettings(where({ [GLOBAL]: "{ nope", [PROJECT]: { trust: { threshold: 4 } } })) + expect(settings.files.find((file) => file.path === GLOBAL)?.error).toBeDefined() + expect(settings.notices).toEqual([ + expect.objectContaining({ bay: "cockpit", file: GLOBAL, kind: "unreadable" }), + ]) + expect(settings.sections.trust).toEqual({ threshold: 4 }) + expect(() => loadSettings(where({ [GLOBAL]: "[1, 2]", [PROJECT]: "null" }))).not.toThrow() + }) + + test("a value of the wrong kind falls back to the default, and says where it was", () => { + const loaded = baySettings( + "shell", + { dockHeight: 14 }, + { where: where({ [GLOBAL]: { shell: { dockHeight: "16", sidebarRows: "8", hideWhenEmpty: 1 } } }) }, + ) + expect(loaded.config.dockHeight).toBe(14) + expect(loaded.config.sidebarRows).toBe(5) + expect(loaded.config.hideWhenEmpty).toBe(false) + expect(loaded.notices.map((notice) => notice.text)).toEqual([ + '"shell.sidebarRows" should be a number; the default is used', + '"shell.hideWhenEmpty" should be a boolean; the default is used', + '"shell.dockHeight" should be a number; the default is used', + ]) + expect(loaded.notices.every((notice) => notice.file === GLOBAL)).toBe(true) + }) + + test("the shared defaults: every block present, Trust's opt-in, nothing hidden when empty", () => { + const none = where({}) + expect(baySettings("subagents", {}, { where: none }).config).toEqual({ + enabled: true, + keybinds: {}, + sidebar: true, + sidebarRows: 6, + hideWhenEmpty: false, + }) + expect(baySettings("trail", {}, { where: none }).config.sidebar).toBe(true) + expect(baySettings("trust", {}, { where: none }).config.sidebar).toBe(false) + }) + + test("features.: false in a file turns the bay off", () => { + const loaded = baySettings("review", {}, { where: where({ [GLOBAL]: { features: { review: false } } }) }) + expect(loaded.config.enabled).toBe(false) + }) + + test("a file's root is no bay's settings: Status reads nothing without its section", () => { + const loaded = baySettings( + "status", + { debug: false }, + { where: where({ [GLOBAL]: { enabled: false, debug: true } }) }, + ) + expect(loaded.config.enabled).toBe(true) + expect(loaded.config.debug).toBe(false) + expect(loaded.notices.map((notice) => notice.text)).toEqual([ + '"enabled" at the top level is not read: it belongs in "status"', + '"debug" at the top level is not read: it belongs in "status"', + ]) + }) +}) + +describe("old names", () => { + /** Detection only: the value under an old name is ignored, the default applies, and it says so. */ + test("each one is a notice, its value ignored and the default used", () => { + const loaded = (bay: Parameters[0], file: object, defaults = {}) => + baySettings(bay, defaults, { where: where({ [GLOBAL]: file }) }) + + const status = loaded("status", { statusline: { maxRows: 3, debug: true } }, { debug: false }) + expect(status.config.debug).toBe(false) + expect(status.notices).toEqual([ + { + bay: "status", + file: GLOBAL, + kind: "old", + old: "statusline", + new: "status", + text: '"statusline" is no longer read — run /cockpit-setup', + }, + ]) + + const shell = loaded( + "shell", + { watch: { auto: true }, ui: { dockHeight: 30, sidebarOrder: 1 } }, + { dockHeight: 14 }, + ) + expect(shell.config.dockHeight).toBe(14) + expect(shell.config).not.toHaveProperty("watch") + expect(shell.notices.map((notice) => [notice.old, notice.new])).toEqual([ + ["watch", "shell.watch"], + ["ui.dockHeight", "shell.dockHeight"], + ["ui.sidebarOrder", "sidebar"], + ]) + + const subagents = loaded("subagents", { + subagents: { hideFinishedAfter: 5, hideNestedAfter: 10, sidebarOrder: 3 }, + }) + expect(subagents.config).not.toHaveProperty("hideFinishedAfterMinutes") + expect(subagents.notices.map((notice) => notice.new)).toEqual([ + "subagents.hideFinishedAfterMinutes", + "subagents.hideNestedAfterSeconds", + "sidebar", + ]) + + const maxRows = loaded("status", { status: { maxRows: 3 } }) + expect(maxRows.config.sidebarRows).toBe(8) + expect(maxRows.notices[0]?.new).toBe("status.sidebarRows") + + const updater = loaded("updater", { ui: { updateCheck: false } }) + expect(updater.notices[0]).toMatchObject({ old: "ui.updateCheck", new: "updater.updateCheck" }) + }) + + test("old names in plugin options too", () => { + const loaded = baySettings("subagents", {}, { where: where({}), options: { hideFinishedAfter: 5 } }) + expect(loaded.notices).toEqual([ + expect.objectContaining({ file: "plugin options", old: "hideFinishedAfter", kind: "old" }), + ]) + expect(loaded.written).toEqual({}) + }) + + test("the list is there for /cockpit-setup's brief", () => { + expect(OLD_NAMES).toContainEqual({ old: "statusline", new: "status" }) + expect(OLD_NAMES).toContainEqual({ old: ".sidebarOrder", new: "sidebar" }) + }) +}) + +describe("the sidebar order", () => { + test("default: status, subagents, shell, trail, trust — between Context (100) and MCP (200)", () => { + const settings = loadSettings(where({})) + expect(settings.sidebar).toEqual(["status", "subagents", "shell", "trail", "trust"]) + const orders = settings.sidebar.map((bay) => orderOf(settings, bay)) + expect(orders).toEqual([110, 120, 130, 140, 150]) + }) + + test("the list is the only order; a bay it leaves out follows, in default order", () => { + const settings = loadSettings(where({ [GLOBAL]: { sidebar: ["trust", "shell"] } })) + expect(settings.sidebar).toEqual(["trust", "shell", "status", "subagents", "trail"]) + }) + + test("a bay's old sidebarOrder no longer moves it", () => { + const loaded = baySettings("trust", {}, { where: where({ [GLOBAL]: { trust: { sidebarOrder: 1 } } }) }) + expect(loaded.order).toBe(150) + expect(loaded.notices[0]).toMatchObject({ old: "trust.sidebarOrder", new: "sidebar", kind: "old" }) + }) + + test("a project's list replaces the global one", () => { + const settings = loadSettings( + where({ [GLOBAL]: { sidebar: ["shell", "status"] }, [PROJECT]: { sidebar: ["status"] } }), + ) + expect(settings.sidebarList).toEqual(["status"]) + }) + + test("an unknown entry names the closest bay, on that bay's block", () => { + const settings = loadSettings(where({ [GLOBAL]: { sidebar: ["status", "shells", "review", "xyz"] } })) + expect(settings.sidebarList).toEqual(["status"]) + expect(settings.notices.map((notice) => [notice.bay, notice.text])).toEqual([ + [ + "shell", + '"shells" in "sidebar" is not a bay: did you mean "shell"? (status, subagents, shell, trail, trust)', + ], + ["review", '"review" in "sidebar" has no sidebar block (status, subagents, shell, trail, trust)'], + ["cockpit", '"xyz" in "sidebar" is not a bay (status, subagents, shell, trail, trust)'], + ]) + }) + + test("closest names", () => { + const bays = ["status", "subagents", "shell", "trail", "trust"] + expect(closestName("shells", bays)).toBe("shell") + expect(closestName("statusline", bays)).toBe("status") + expect(closestName("Subagent", bays)).toBe("subagents") + expect(closestName("trails", bays)).toBe("trail") + expect(closestName("qqqqqq", bays)).toBeUndefined() + }) +}) diff --git a/packages/client/test/sidebar.test.ts b/packages/client/test/sidebar.test.ts index 285e0382..617ccda0 100644 --- a/packages/client/test/sidebar.test.ts +++ b/packages/client/test/sidebar.test.ts @@ -2,9 +2,9 @@ import { afterEach, describe, expect, test } from "bun:test" import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" import { join } from "node:path" import type { Host } from "../src/host.ts" -import { orderedSidebar, sidebarOrder } from "../src/sidebar.ts" +import { orderedSidebar, sidebarList, sidebarOrder } from "../src/sidebar.ts" -/** One list orders every bay's sidebar block; a bay's own number still wins; a project beats global. */ +/** One list orders every bay's sidebar block, and nothing else does; a project's list beats global. */ const dirs: string[] = [] afterEach(() => { @@ -22,29 +22,28 @@ function setup(global?: unknown, project?: unknown) { return { directory, env: { XDG_CONFIG_HOME: config } } } -describe("the sidebar order", () => { - test("the list puts the statusline first, ahead of every default", () => { - const where = setup({ sidebar: ["status", "subagents", "shell"] }) - const status = sidebarOrder("status", 200, undefined, where) - const subagents = sidebarOrder("subagents", 160, undefined, where) - const shell = sidebarOrder("shell", 150, undefined, where) - expect(status).toBeLessThan(subagents) - expect(subagents).toBeLessThan(shell) - expect(shell).toBeLessThan(150) +describe("the sidebar order, for bays that have not moved to baySettings", () => { + test("with no list, the default order: status, subagents, shell, trail, trust", () => { + const where = setup() + const order = ["status", "subagents", "shell", "trail", "trust"].map((bay) => + sidebarOrder(bay, 999, undefined, where), + ) + expect(order).toEqual([...order].sort((a, b) => a - b)) + expect(order.every((at) => at > 100 && at < 200)).toBe(true) }) - test("a bay's own number beats the list; a bay the list leaves out keeps its default", () => { - const where = setup({ sidebar: ["status"] }) - expect(sidebarOrder("status", 200, 999, where)).toBe(999) - expect(sidebarOrder("shell", 150, undefined, where)).toBe(150) + test("the list decides, and a bay's own number no longer does", () => { + const where = setup({ sidebar: ["trust", "status"] }) + expect(sidebarOrder("trust", 160, undefined, where)).toBeLessThan(sidebarOrder("status", 140, 1, where)) + expect(sidebarList(where)).toEqual(["trust", "status"]) }) - test("a project's list beats the global one; no list, no change", () => { + test("a project's list beats the global one; a name that is not a sidebar bay keeps its fallback", () => { const where = setup({ sidebar: ["shell", "status"] }, { sidebar: ["status", "shell"] }) expect(sidebarOrder("status", 200, undefined, where)).toBeLessThan( sidebarOrder("shell", 150, undefined, where), ) - expect(sidebarOrder("status", 200, undefined, setup())).toBe(200) + expect(sidebarOrder("review", 777, undefined, where)).toBe(777) }) }) From 2956587c4880db2183999c6fa0edf18564e21177 Mon Sep 17 00:00:00 2001 From: Codestz Date: Sat, 3 Oct 2026 09:14:16 -0500 Subject: [PATCH 02/90] client/design: an empty block is its heading and none yet Presence over silence: the muted row sits in the first item's slot, so empty to one item keeps the height; hideWhenEmpty draws nothing. EmptyRow element, and a preview of empty blocks and settings notices. Co-Authored-By: Claude Opus 5.5 --- packages/client/src/cli/preview.ts | 81 +++++++++++++++++++++++++++++ packages/client/src/design.ts | 48 +++++++++++++++++ packages/client/src/elements.tsx | 14 +++++ packages/client/test/design.test.ts | 42 +++++++++++++++ 4 files changed, 185 insertions(+) create mode 100644 packages/client/src/cli/preview.ts diff --git a/packages/client/src/cli/preview.ts b/packages/client/src/cli/preview.ts new file mode 100644 index 00000000..ac9ea4b1 --- /dev/null +++ b/packages/client/src/cli/preview.ts @@ -0,0 +1,81 @@ +#!/usr/bin/env bun + +/** + * The see-it loop for what every bay shares: an empty sidebar block, and the `!` row a settings + * notice draws, at a narrow and a wide column. Colours from OpenCode's default theme. + * + * bun packages/client/src/cli/preview.ts + * bun packages/client/src/cli/preview.ts --width 28 + * bun packages/client/src/cli/preview.ts --config ./my.json notices for a config file of yours + */ + +import { readFileSync } from "node:fs" +import { emptyBlock, GLYPH, type ToneRun } from "../design.ts" +import { loadSettings, noticeText, type SettingsNotice } from "../settings.ts" + +const HEX: Record = { + text: "#eeeeee", + muted: "#808080", + accent: "#9d7cd8", + warning: "#f5a742", + error: "#e06c75", +} +const color = process.stdout.isTTY && !process.env.NO_COLOR +const rgb = (hex: string) => [1, 3, 5].map((i) => Number.parseInt(hex.slice(i, i + 2), 16)).join(";") + +function paint(row: readonly ToneRun[]): string { + if (!color) return row.map((run) => run.text).join("") + return row + .map( + (run) => + `\x1b[38;2;${rgb(HEX[run.tone ?? "text"] ?? "#eeeeee")}${run.bold ? ";1" : ""}m${run.text}\x1b[0m`, + ) + .join("") +} + +/** A notice as a bay draws it: `!` in the warning tone, then the sentence, cut to the column. */ +function noticeRow(notice: SettingsNotice, width: number): ToneRun[] { + const said = noticeText(notice) + const room = width - 2 + const text = said.length > room ? `${said.slice(0, Math.max(0, room - 1))}${GLYPH.more}` : said.padEnd(room) + return [ + { text: `${GLYPH.warn} `, tone: "warning" }, + { text, tone: "muted" }, + ] +} + +const arg = (name: string) => { + const at = process.argv.indexOf(name) + return at > 0 ? process.argv[at + 1] : undefined +} +const widths = arg("--width") ? [Number(arg("--width"))] : [24, 36] +const config = arg("--config") +const sample = { + statusline: { preset: "sidebar" }, + shell: { sidebarOrder: 170 }, + sidebar: ["status", "shells", "trust"], +} +const settings = loadSettings({ + env: {}, + home: "/nowhere", + directory: "/project", + read: (path) => + path.endsWith(".cockpit.json") + ? config + ? readFileSync(config, "utf8") + : JSON.stringify(sample) + : undefined, +}) + +for (const width of widths) { + const ruler = `── ${width} columns `.padEnd(width, "─") + console.log(ruler) + for (const title of ["Subagents", "Shells", "Trail"]) { + for (const row of emptyBlock(title, width)) console.log(`${paint(row)}│`) + console.log("") + } + console.log(`(hideWhenEmpty: ${emptyBlock("Shells", width, true).length} rows)`) + console.log("") + for (const notice of settings.notices) console.log(`${paint(noticeRow(notice, width))}│`) + console.log("") +} diff --git a/packages/client/src/design.ts b/packages/client/src/design.ts index c2cd24cb..463fe339 100644 --- a/packages/client/src/design.ts +++ b/packages/client/src/design.ts @@ -228,6 +228,54 @@ export const moreText = (count: number): string => `+ ${count} more` /** What an expanded block offers to fold back. */ export const FEWER_TEXT = "− fewer" +/** + * What a block with nothing to list says, muted, in the row its first item will take. + * + * Presence over silence. "Say nothing when there is nothing" was the rule, and it left a new user + * unable to tell whether Shells and Subagents were installed at all — so a block draws its heading + * and this one row from the start, and `hideWhenEmpty: true` brings the silence back for anyone who + * wants it. The row sits where the first item goes, so empty → one item replaces it instead of + * pushing every block below down a row (measured on both OpenCodes, docs/opencode/trail-interface.md). + */ +export const EMPTY_TEXT = "none yet" + +const cells = (runs: readonly ToneRun[]) => runs.reduce((sum, run) => sum + run.text.length, 0) + +/** + * A block's heading, exactly `width` wide, then its `HEADING_GAP` rows of air: the name bold on the + * left, the summary flush right. A summary with no room left beside the name is left out, and a name + * wider than the column is cut with `…`. + */ +export function headingRows(title: string, summary: readonly ToneRun[], width: number): ToneRun[][] { + const room = Math.max(0, width) + const name = title.length > room ? `${title.slice(0, Math.max(0, room - 1))}…`.slice(0, room) : title + const right = cells(summary) + 1 <= room - name.length ? summary : [] + const gap = room - name.length - cells(right) + const heading: ToneRun[] = [ + { text: name, ...HEADING }, + ...(gap > 0 ? [{ text: " ".repeat(gap) }] : []), + ...right, + ] + return [heading, ...Array.from({ length: HEADING_GAP }, () => [{ text: " ".repeat(room) }])] +} + +/** + * A block with nothing in it: its heading and one muted `none yet` row — as tall as the block with + * one single-row item — or no rows at all when the user asked for `hideWhenEmpty`. + */ +export function emptyBlock(title: string, width: number, hideWhenEmpty = false): ToneRun[][] { + if (hideWhenEmpty) return [] + const room = Math.max(0, width) + const text = EMPTY_TEXT.slice(0, room) + return [ + ...headingRows(title, [], room), + [{ text, tone: "muted" }, ...(room > text.length ? [{ text: " ".repeat(room - text.length) }] : [])], + ] +} + +/** Whether a block draws at all: always, unless it is empty and asked to hide then. */ +export const blockShown = (count: number, hideWhenEmpty = false): boolean => count > 0 || !hideWhenEmpty + /** * How long something ran, in the fewest characters that still read: `4s`, `51s`, `2m04s`, `1h12m`. * diff --git a/packages/client/src/elements.tsx b/packages/client/src/elements.tsx index 88c611ea..1c9030b6 100644 --- a/packages/client/src/elements.tsx +++ b/packages/client/src/elements.tsx @@ -1,5 +1,7 @@ /** @jsxImportSource @opentui/solid */ +import type { ColorInput } from "@opentui/core" import type { JSX } from "solid-js" +import { EMPTY_TEXT } from "./design.ts" /** * Plain text as an element OpenCode 1 can place in one of its dialogs. @@ -10,3 +12,15 @@ import type { JSX } from "solid-js" * dialog, the first to pass a plain description. */ export const textElement = (text: string) => (): JSX.Element => {text} + +/** + * The `none yet` row of an empty sidebar block (`EMPTY_TEXT` in design.ts says why it is there), in + * the theme's muted colour. Draw it in the slot of the first item — as the fallback of the block's + * `` over its rows — so the first item takes its place and the blocks below do not move. One + * row, never wrapped: a wrapped empty line would be taller than the item that replaces it. + */ +export const EmptyRow = (props: { fg: ColorInput }): JSX.Element => ( + + {EMPTY_TEXT} + +) diff --git a/packages/client/test/design.test.ts b/packages/client/test/design.test.ts index 259e7abe..78e0d3e6 100644 --- a/packages/client/test/design.test.ts +++ b/packages/client/test/design.test.ts @@ -1,12 +1,16 @@ import { describe, expect, test } from "bun:test" import { + blockShown, checkbox, closeHint, duration, + EMPTY_TEXT, + emptyBlock, fitHints, gaugeTone, type Hint, type HintRun, + headingRows, hintRuns, keyName, labelCase, @@ -172,3 +176,41 @@ describe("a block's heading", () => { expect(said(counts, 5)).toBe("1 needs you") }) }) + +/** + * Presence over silence: an empty block draws its heading and `none yet` in the slot its first item + * takes, so the first item replaces the line and the blocks below stay where they are. + */ +describe("an empty sidebar block", () => { + const lines = (rows: readonly (readonly HintRun[])[]) => rows.map((each) => text(each)) + + test("is the heading and one muted row, every row exactly the column's width", () => { + const rows = emptyBlock("Shells", 30) + expect(lines(rows)).toEqual(["Shells".padEnd(30), " ".repeat(30), EMPTY_TEXT.padEnd(30)]) + expect(rows[0]?.[0]).toMatchObject({ text: "Shells", bold: true }) + expect(rows.at(-1)?.[0]).toMatchObject({ text: "none yet", tone: "muted" }) + }) + + test("is as tall as the same block with one single-row item", () => { + const withOne = [ + ...headingRows("Shells", [{ text: "1 running", tone: "accent" }], 30), + [{ text: "⠹ dev" }], + ] + expect(emptyBlock("Shells", 30)).toHaveLength(withOne.length) + }) + + test("draws nothing when asked to hide, and only when empty", () => { + expect(emptyBlock("Shells", 30, true)).toEqual([]) + expect(blockShown(0, true)).toBe(false) + expect(blockShown(0)).toBe(true) + expect(blockShown(1, true)).toBe(true) + }) + + test("a heading keeps its summary only while there is room, and never runs past the column", () => { + const summary = [{ text: "2 running", tone: "accent" as const }] + expect(lines(headingRows("Subagents", summary, 24))[0]).toBe("Subagents 2 running") + expect(lines(headingRows("Subagents", summary, 18))[0]).toBe("Subagents ") + expect(lines(headingRows("Subagents", [], 6))[0]).toBe("Subag…") + expect(lines(emptyBlock("Trail", 4)).every((each) => each.length === 4)).toBe(true) + }) +}) From 34d29379681ba6aa558f6b462f75da23a1595668 Mon Sep 17 00:00:00 2001 From: Codestz Date: Sat, 3 Oct 2026 09:14:16 -0500 Subject: [PATCH 03/90] client/server: toolAfter, the same finished call on both OpenCodes v1 tool.execute.after with MCP output read from content[]; v2 tool.hook("execute.after") from content[].text, skipping Code Mode's outer execute call. A listener's failure is logged and never reaches the call. Co-Authored-By: Claude Opus 5.5 --- packages/client/src/server.ts | 141 +++++++++++++++++++++++++++- packages/client/test/server.test.ts | 128 +++++++++++++++++++++++++ 2 files changed, 267 insertions(+), 2 deletions(-) diff --git a/packages/client/src/server.ts b/packages/client/src/server.ts index 2a2c4028..fda2a792 100644 --- a/packages/client/src/server.ts +++ b/packages/client/src/server.ts @@ -70,10 +70,29 @@ export interface ServerHost { readonly log: Log } +/** A tool call that finished, as `toolAfter` hears of it on either version. */ +export interface ToolCall { + sessionID: string + /** As the host names it: `bash`/`shell`, a plugin's `trail_add`, an MCP server's `_`. */ + tool: string + callID: string + /** What the model sent. */ + args: unknown + /** What the tool answered, as text — whichever field the host put it in. */ + output: string + /** The agent that made the call (`general`, `explore`…). OpenCode 2 only. */ + agent?: string +} + export interface ServerParts { tools?: Record /** Added to the system prompt of each model request. The session is unknown on some v1 requests. */ system?: (sessionID: string | undefined) => Promise + /** + * 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 @@ -104,6 +123,22 @@ export function composeParts(parts: ServerParts[]): ServerParts { }, } : {}), + ...(any("toolAfter") + ? { + /** One feature's failure does not keep the call from the next; the first is reported. */ + toolAfter: async (call: ToolCall) => { + let failed: { error: unknown } | undefined + for (const part of parts) { + try { + await part.toolAfter?.(call) + } catch (error) { + failed ??= { error } + } + } + if (failed) throw failed.error + }, + } + : {}), ...(any("sessionDeleted") ? { sessionDeleted: async (sessionID: string) => { @@ -197,9 +232,43 @@ export function serverFromV1(input: PluginInput, log: Log = silentLog): ServerHo } } +/** + * What a v1 tool answered, as text. A built-in or plugin tool answers `{ title, output, metadata }`; + * an MCP tool answers `{ content: [{ type: "text", text }] }` with no `output` at all, so reading + * `output` alone missed every MCP call (docs/opencode/trail-server.md). + */ +export function v1ToolText(output: unknown): string { + const answer = output as { output?: unknown; content?: unknown } | undefined + if (typeof answer?.output === "string") return answer.output + return contentText(answer?.content) +} + +/** `[{ type: "text", text }, …]` as one string; anything that is not text is left out. */ +function contentText(content: unknown): string { + if (!Array.isArray(content)) return "" + return content + .flatMap((part) => + typeof (part as { text?: unknown })?.text === "string" ? [(part as { text: string }).text] : [], + ) + .join("\n") +} + export function partsToV1Hooks(parts: ServerParts): Hooks { return { ...(parts.tools ? { tool: parts.tools } : {}), + ...(parts.toolAfter + ? { + "tool.execute.after": async (input, output) => { + await parts.toolAfter?.({ + sessionID: input.sessionID, + tool: input.tool, + callID: input.callID, + args: input.args, + output: v1ToolText(output), + }) + }, + } + : {}), ...(parts.system ? { "experimental.chat.system.transform": async (input, output) => { @@ -226,7 +295,11 @@ export function partsToV1Hooks(parts: ServerParts): Hooks { export interface V2ServerContext { options?: unknown location?: { directory: string } - tool?: { transform(edit: (editor: V2ToolEditor) => void): Promise } + tool?: { + transform(edit: (editor: V2ToolEditor) => void): Promise + /** 2.0.18: after every tool call, Code Mode's inner calls included (docs/opencode/trail-server.md). */ + hook?(name: "execute.after", run: (event: V2ToolAfter) => unknown): Promise + } session: { get(input: { sessionID: string @@ -244,6 +317,45 @@ export interface V2Event { data?: { sessionID?: string } } +/** What v2's `execute.after` hands a plugin, measured on 2.0.18. */ +export interface V2ToolAfter { + tool: string + sessionID: string + agent?: string + messageID?: string + /** The call's id. A Code Mode call fires twice with the same one: the inner tool, then `execute`. */ + id: string + input?: unknown + status?: string + /** `content[].text` is the one field every tool has; `output` is a string for MCP, an object for `shell`. */ + result?: { output?: unknown; content?: unknown } + error?: unknown +} + +/** + * Code Mode's outer call. On v2 a plugin or MCP tool is called from inside `execute`, and the hook + * fires for both with the same id — the outer one carrying everything the code printed, `search(…)` + * results (the tool catalog, URLs and all) included. Only the inner call is delivered. + */ +const CODE_MODE = "execute" + +/** A v2 `execute.after` as a `ToolCall`, or undefined for Code Mode's outer call and a call that failed. */ +export function v2ToolCall(event: V2ToolAfter): ToolCall | undefined { + if (event.tool === CODE_MODE) return undefined + if (event.error !== undefined || (event.status !== undefined && event.status !== "completed")) + return undefined + const text = contentText(event.result?.content) + const output = text || (typeof event.result?.output === "string" ? event.result.output : "") + return { + sessionID: event.sessionID, + tool: event.tool, + callID: event.id, + args: event.input, + output, + ...(event.agent ? { agent: event.agent } : {}), + } +} + /** A tool as v2's editor takes one. */ export interface V2Tool { name: string @@ -414,7 +526,23 @@ async function begin( host.log.info("start", { entry: id, opencode: host.version, cockpit: cockpitVersion() }) try { const parts = await start(host, options) - return { ...parts, tools: loggedTools(parts.tools, host.log) } + const { toolAfter } = parts + return { + ...parts, + tools: loggedTools(parts.tools, host.log), + /** Listening to a call must never break it: a failure is logged and goes no further. */ + ...(toolAfter + ? { + toolAfter: async (call: ToolCall) => { + try { + await toolAfter(call) + } catch (error) { + host.log.warn("toolAfter failed", { tool: call.tool, error }) + } + }, + } + : {}), + } } catch (error) { host.log.error("start failed", { entry: id, error }) throw error @@ -444,6 +572,15 @@ export function dualServer(id: string, start: ServerStart) { for (const [name, def] of tools) editor.add(toolToV2(name, def, host.directory)) }) } + if (parts.toolAfter) { + const after = parts.toolAfter + if (ctx.tool.hook) { + await ctx.tool.hook("execute.after", async (event) => { + const call = v2ToolCall(event) + if (call) await after(call) + }) + } else host.log.warn("this OpenCode has no execute.after hook; tool output is not followed") + } if (parts.system) { const system = parts.system await ctx.session.hook("context", async (event) => { diff --git a/packages/client/test/server.test.ts b/packages/client/test/server.test.ts index dae141b3..15e56f43 100644 --- a/packages/client/test/server.test.ts +++ b/packages/client/test/server.test.ts @@ -9,7 +9,10 @@ import { type ServerParts, serverFromV1, serverFromV2, + type ToolCall, toolToV2, + v1ToolText, + v2ToolCall, } from "../src/server.ts" /** @@ -261,3 +264,128 @@ describe("messaging a session that may be busy", () => { ]) }) }) + +/** + * A finished tool call, heard the same on both versions (docs/opencode/trail-server.md): v1's MCP + * output sits in another field, and v2 fires twice for a Code Mode call — only the inner one counts. + */ +describe("toolAfter", () => { + test("v1: a built-in's output, and an MCP tool's content, both arrive as text", async () => { + const calls: ToolCall[] = [] + const hooks = partsToV1Hooks({ toolAfter: (call) => void calls.push(call) }) + const after = hooks["tool.execute.after"] + const input = { tool: "bash", sessionID: "ses_1", callID: "call_1", args: { command: "gh pr create" } } + await after?.(input, { title: "", output: "https://github.com/a/b/pull/33\n", metadata: {} }) + await after?.({ ...input, tool: "spike_open_pr", callID: "call_2", args: {} }, { + content: [{ type: "text", text: "Created pull request: x/77" }, { type: "image" }], + } as never) + expect(calls).toEqual([ + { + sessionID: "ses_1", + tool: "bash", + callID: "call_1", + args: input.args, + output: "https://github.com/a/b/pull/33\n", + }, + { + sessionID: "ses_1", + tool: "spike_open_pr", + callID: "call_2", + args: {}, + output: "Created pull request: x/77", + }, + ]) + }) + + test("v1 text, whichever field it is in", () => { + expect(v1ToolText({ output: "a" })).toBe("a") + expect( + v1ToolText({ + content: [ + { type: "text", text: "a" }, + { type: "text", text: "b" }, + ], + }), + ).toBe("a\nb") + expect(v1ToolText(undefined)).toBe("") + }) + + test("v2: the inner call is delivered, Code Mode's outer `execute` and failures are not", () => { + const inner = { + tool: "spike_open_pr", + sessionID: "ses_1", + agent: "general", + id: "call_9", + input: { title: "x" }, + status: "completed", + result: { + output: "Created pull request: x/77", + content: [{ type: "text", text: "Created pull request: x/77" }], + }, + } + expect(v2ToolCall(inner)).toEqual({ + sessionID: "ses_1", + tool: "spike_open_pr", + callID: "call_9", + args: { title: "x" }, + output: "Created pull request: x/77", + agent: "general", + }) + expect(v2ToolCall({ ...inner, tool: "execute" })).toBeUndefined() + expect(v2ToolCall({ ...inner, status: "error", error: "boom" })).toBeUndefined() + /** `shell`'s `output` is an object; `content` carries its text. */ + const shell = { + ...inner, + tool: "shell", + result: { output: { exit: 0 }, content: [{ type: "text", text: "ok" }] }, + } + expect(v2ToolCall(shell)?.output).toBe("ok") + expect(v2ToolCall({ ...inner, result: { output: "only output" } })?.output).toBe("only output") + }) + + test("v2's setup registers execute.after, and a feature's failure never reaches the call", async () => { + const seen: string[] = [] + let run: ((event: unknown) => unknown) | undefined + const fake = fakeV2() + const ctx = { + ...fake.ctx, + tool: { + ...fake.ctx.tool, + hook: async (name: string, handler: (event: unknown) => unknown) => { + seen.push(name) + run = handler + }, + }, + } + const calls: string[] = [] + const entry = dualServer("cockpit.test", async () => ({ + toolAfter: (call) => { + calls.push(call.tool) + if (call.tool === "bad") throw new Error("listener broke") + }, + })) + await entry.setup(ctx as never) + expect(seen).toEqual(["execute.after"]) + const event = (tool: string) => ({ tool, sessionID: "s", id: "c", result: { content: [] } }) + await run?.(event("spike_open_pr")) + await run?.(event("execute")) + await run?.(event("bad")) + expect(calls).toEqual(["spike_open_pr", "bad"]) + }) + + test("composed: every feature hears the call, even after one fails", async () => { + const heard: string[] = [] + const parts = composeParts([ + { + toolAfter: () => { + heard.push("a") + throw new Error("a broke") + }, + }, + { toolAfter: () => void heard.push("b") }, + ]) + const call = { sessionID: "s", tool: "t", callID: "c", args: {}, output: "" } + await expect(parts.toolAfter?.(call) ?? Promise.resolve()).rejects.toThrow("a broke") + expect(heard).toEqual(["a", "b"]) + }) +}) From fa4ac24e4989441fb7a6161c3f6c91c5964708ec Mon Sep 17 00:00:00 2001 From: Codestz Date: Sat, 3 Oct 2026 09:14:16 -0500 Subject: [PATCH 04/90] Doctor and Trust read settings through the shared loader Doctor prints every settings notice as a fix line, and a file it calls fine is one the bays can use. Trust takes its section and order from the loader. Co-Authored-By: Claude Opus 5.5 --- packages/trust/src/core/config.ts | 55 ++++++++++-------------- packages/trust/src/tui/index.tsx | 10 ++--- packages/updater/src/doctor/checks.ts | 17 ++++++-- packages/updater/src/doctor/gather.ts | 34 +++++++++------ packages/updater/test/doctor.test.ts | 62 ++++++++++++++++++++++++++- 5 files changed, 122 insertions(+), 56 deletions(-) diff --git a/packages/trust/src/core/config.ts b/packages/trust/src/core/config.ts index bc1de8b7..3eed445e 100644 --- a/packages/trust/src/core/config.ts +++ b/packages/trust/src/core/config.ts @@ -1,19 +1,17 @@ /** - * Trust's settings, through the same merge every bay uses (docs/building/a-new-bay.md): + * Trust's settings, through the loader every bay shares (`@opencode-cockpit/client/settings`): * * ~/.config/opencode-cockpit/config.json → /.cockpit.json → plugin-entry options * * Only the `trust` section of a file is read. A file without one says nothing about Trust — reading * the whole file as Trust's settings is the trap that page warns about. An unreadable or invalid file - * is ignored rather than fatal: a typo in a config should never cost you the interface. + * is ignored rather than fatal, with a notice: a typo in a config should never cost you the interface. * * This is Cockpit's config, not OpenCode's. What OpenCode allows, denies and asks about is read from * OpenCode itself (`rules.ts`), and never written by Trust. */ -import { readFile } from "node:fs/promises" -import { homedir } from "node:os" -import { join } from "node:path" +import { baySettings, type SettingsNotice } from "@opencode-cockpit/client/settings" export interface TrustConfig { /** Off switch for this bay, wherever it is written. The bundle also has `features.trust: false`. */ @@ -28,12 +26,11 @@ export interface TrustConfig { * Whether Trust draws a block in the sidebar. Default false: the sidebar already carries the * statusline, subagents and shells, and Trust works the same without it — `/trust` opens the * ledger, and the palette's "Show or hide Trust in the sidebar" brings the block back for the session. + * Where it sits is the top-level `sidebar` list's to say. */ sidebar?: boolean /** Answers by Trust listed in the sidebar. Default 3. */ sidebarRows?: number - /** Where the block sits among sidebar blocks; lower draws first. */ - sidebarOrder?: number keybinds?: Record } @@ -55,9 +52,6 @@ export const DEFAULTS: TrustSettings = { sidebarRows: 3, } -export const CONFIG_FILE = "config.json" -export const PROJECT_FILE = ".cockpit.json" - const KEYS = [ "enabled", "threshold", @@ -65,15 +59,9 @@ const KEYS = [ "expireDays", "sidebar", "sidebarRows", - "sidebarOrder", "keybinds", ] as const -export function globalConfigPath(env: Record = process.env): string { - const base = env.XDG_CONFIG_HOME ?? join(env.HOME ?? homedir(), ".config") - return join(base, "opencode-cockpit", CONFIG_FILE) -} - /** The `trust` section of a config file. */ export function trustSection(raw: unknown): TrustConfig { if (!raw || typeof raw !== "object") return {} @@ -98,31 +86,34 @@ function pick(raw: Record): TrustConfig { return own } -export function mergeTrust(base: TrustConfig, over: TrustConfig): TrustConfig { - const merged: TrustConfig = { ...base, ...over } - if (base.keybinds || over.keybinds) merged.keybinds = { ...base.keybinds, ...over.keybinds } - return merged +export interface LoadedTrust { + /** What was written, every source merged; `resolveSettings` fills the gaps. */ + config: TrustConfig + /** The block's place, from the top-level `sidebar` list. */ + order: number + /** Settings to fix, for a `!` row. */ + notices: SettingsNotice[] } -async function readSection(path: string): Promise { - try { - return trustSection(JSON.parse(await readFile(path, "utf8"))) - } catch { - return {} - } +/** Reads and merges every source. Never throws. */ +export async function loadTrust( + directory: string, + options?: unknown, + env: Record = process.env, +): Promise { + const loaded = baySettings("trust", DEFAULTS, { options, where: { directory, env } }) + /** `features.trust: false` in a file turns Trust off as `enabled: false` does. */ + const off = loaded.config.enabled ? {} : { enabled: false } + return { config: { ...pick(loaded.written), ...off }, order: loaded.order, notices: loaded.notices } } -/** Reads and merges every source, without blocking the interface thread. */ +/** Every source merged, as written. */ export async function loadTrustConfig( directory: string, options?: unknown, env: Record = process.env, ): Promise { - const [global, project] = await Promise.all([ - readSection(globalConfigPath(env)), - readSection(join(directory, PROJECT_FILE)), - ]) - return mergeTrust(mergeTrust(global, project), asTrustConfig(options)) + return (await loadTrust(directory, options, env)).config } const whole = (value: unknown, fallback: number, least: number): number => diff --git a/packages/trust/src/tui/index.tsx b/packages/trust/src/tui/index.tsx index 17664fde..0e8139ad 100644 --- a/packages/trust/src/tui/index.tsx +++ b/packages/trust/src/tui/index.tsx @@ -11,11 +11,10 @@ import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client/feature" import { bindingLookup, dualTui, type Host, type Layer } from "@opencode-cockpit/client/host" -import { sidebarOrder } from "@opencode-cockpit/client/sidebar" import type { BoxRenderable } from "@opentui/core" import { createSignal } from "solid-js" import { commandOf, type Seen } from "../core/adapt/seen.ts" -import { loadTrustConfig, resolveSettings, type TrustConfig } from "../core/config.ts" +import { loadTrust, resolveSettings, type TrustConfig } from "../core/config.ts" import { createEngine } from "../core/engine.ts" import type { Request } from "../core/keys.ts" import type { Event } from "../core/ledger.ts" @@ -75,7 +74,8 @@ export function createTrustTui({ source = TRUST_PACKAGE }: { source?: string } = api.lifecycle.onDispose(() => claim.release()) const directory = api.state.path.directory - const config = await loadTrustConfig(directory, rawOptions) + const { config, order, notices } = await loadTrust(directory, rawOptions) + for (const notice of notices) log.warn("settings", { file: notice.file, notice: notice.text }) const settings = resolveSettings(config) if (!settings.enabled) { log.info("off by config", { directory }) @@ -819,8 +819,8 @@ export function createTrustTui({ source = TRUST_PACKAGE }: { source?: string } = }) api.slots.register({ - /** Between Subagents (150) and the shells (170) by default; lower draws first. */ - order: sidebarOrder("trust", 160, config.sidebarOrder, { directory }), + /** Last of Cockpit's blocks by default; the top-level `sidebar` list moves it. */ + order, slots: { sidebar_content: () => ( 0 + for (const notice of settings.notices ?? []) fix.push(`${notice.file}: ${notice.text}`) + const noted = fix.length > 0 for (const module of settings.modules) { if (!module.exists) fix.push(`statusline module ${module.path} does not exist`) } @@ -389,11 +394,15 @@ export function checkSettings(facts: Facts): Check { return { title: "Settings", state: fix.length ? "warn" : "ok", - summary: fix.length + summary: broken ? "a settings file Cockpit cannot use" - : read === 0 - ? "defaults (no settings file)" - : `${read} file${read === 1 ? "" : "s"}${settings.modules.length ? `, ${settings.modules.length} statusline module${settings.modules.length === 1 ? "" : "s"}` : ""}`, + : noted + ? "settings that are not read as written" + : fix.length + ? "a statusline module is missing" + : read === 0 + ? "defaults (no settings file)" + : `${read} file${read === 1 ? "" : "s"}${settings.modules.length ? `, ${settings.modules.length} statusline module${settings.modules.length === 1 ? "" : "s"}` : ""}`, detail: settings.files.map((file) => file.path), ...(fix.length ? { fix } : {}), } diff --git a/packages/updater/src/doctor/gather.ts b/packages/updater/src/doctor/gather.ts index 7fd645fb..9f2712b4 100644 --- a/packages/updater/src/doctor/gather.ts +++ b/packages/updater/src/doctor/gather.ts @@ -7,6 +7,7 @@ */ import { join, resolve } from "node:path" +import { loadSettings } from "@opencode-cockpit/client/settings" import { globalConfigDir } from "../core/configs.ts" import type { Disk } from "../core/disk.ts" import { parseJsonc } from "../core/jsonc.ts" @@ -208,22 +209,27 @@ function readDaemon(io: DoctorIo, home: string): DaemonFacts { } } +/** + * Cockpit's own settings, read by the loader every bay reads them with — so a file doctor calls fine + * is a file the bays can use, and every notice a bay would draw is a line here. + */ function readSettings(io: DoctorIo): Facts["settings"] { - const configDir = join(io.env.XDG_CONFIG_HOME ?? join(io.home, ".config"), "opencode-cockpit") const project = io.worktree ?? io.cwd - const files: Facts["settings"]["files"] = [] + const settings = loadSettings({ + directory: project, + env: io.env, + home: io.home, + read: (path) => io.disk.read(path), + }) + const files: Facts["settings"]["files"] = settings.files + .filter((file) => file.found) + .map((file) => (file.error ? { path: file.path, error: file.error } : { path: file.path })) + const notices = settings.notices + .filter((notice) => notice.kind !== "unreadable") + .map((notice) => ({ file: notice.file, text: notice.text })) const modules: Facts["settings"]["modules"] = [] - for (const path of [join(configDir, "config.json"), join(project, ".cockpit.json")]) { - const text = io.disk.read(path) - if (text === undefined) continue - const parsed = parseJsonc(text) - if (!parsed.ok) { - files.push({ path, error: parsed.message }) - continue - } - files.push({ path }) - const config = parsed.value as { statusline?: { modules?: unknown }; modules?: unknown } | null - const list = config?.statusline?.modules ?? config?.modules + for (const layer of settings.layers) { + const list = layer.sections.status?.modules if (!Array.isArray(list)) continue for (const module of list) { if (typeof module !== "string") continue @@ -232,7 +238,7 @@ function readSettings(io: DoctorIo): Facts["settings"] { modules.push({ path: module, exists: io.exists(full) }) } } - return { files, modules } + return { files, modules, notices } } /** diff --git a/packages/updater/test/doctor.test.ts b/packages/updater/test/doctor.test.ts index 769453f2..7393b3dd 100644 --- a/packages/updater/test/doctor.test.ts +++ b/packages/updater/test/doctor.test.ts @@ -328,7 +328,7 @@ describe("the rest", () => { opencode: "2.0.15", files: { [`${HOME}/.config/opencode-cockpit/config.json`]: json({ - statusline: { modules: ["~/.config/opencode-cockpit/modules/gone.ts"] }, + status: { modules: ["~/.config/opencode-cockpit/modules/gone.ts"] }, }), }, }) @@ -337,6 +337,66 @@ describe("the rest", () => { }) }) +/** + * Doctor reads Cockpit's settings through the loader the bays use, so the two cannot disagree: a file + * with a comment was "fine" to doctor while every bay dropped it whole. + */ +describe("Cockpit's settings", () => { + const GLOBAL = `${HOME}/.config/opencode-cockpit/config.json` + const PROJECT = "/work/project/.cockpit.json" + + test("comments and trailing commas: fine to doctor, and read by the bays", async () => { + const files = { [GLOBAL]: `{\n // mine\n "trust": { "threshold": 5, },\n}` } + const found = await checks({ opencode: "2.0.18", files }) + expect(found.Settings?.state).toBe("ok") + expect(found.Settings?.summary).toBe("1 file") + const { baySettings } = await import("@opencode-cockpit/client/settings") + const trust = baySettings( + "trust", + { threshold: 3 }, + { + where: { + env: {}, + home: HOME, + directory: "/work/project", + read: (path) => memoryDisk(files).read(path), + }, + }, + ) + expect(trust.config.threshold).toBe(5) + }) + + test("a file the bays cannot parse is not fine", async () => { + const found = await checks({ opencode: "2.0.18", files: { [PROJECT]: `{ "trust": { "threshold": 5 ` } }) + expect(found.Settings?.state).toBe("warn") + expect(found.Settings?.summary).toBe("a settings file Cockpit cannot use") + expect(found.Settings?.fix?.[0]).toStartWith(`${PROJECT}: `) + expect(found.Settings?.fix?.[0]).toEndWith("the whole file is ignored") + }) + + test("every old name and unknown sidebar entry is a fix line", async () => { + const found = await checks({ + opencode: "2.0.18", + files: { + [GLOBAL]: json({ + statusline: { preset: "sidebar" }, + ui: { dockHeight: 20 }, + sidebar: ["shells", "status"], + }), + [PROJECT]: json({ trust: { sidebarOrder: 1 } }), + }, + }) + expect(found.Settings?.state).toBe("warn") + expect(found.Settings?.summary).toBe("settings that are not read as written") + expect(found.Settings?.fix).toEqual([ + `${GLOBAL}: "statusline" is no longer read — run /cockpit-setup`, + `${GLOBAL}: "ui.dockHeight" is no longer read — run /cockpit-setup`, + `${GLOBAL}: "shells" in "sidebar" is not a bay: did you mean "shell"? (status, subagents, shell, trail, trust)`, + `${PROJECT}: "trust.sidebarOrder" is no longer read — run /cockpit-setup`, + ]) + }) +}) + describe("background subagents (the 0.8 load test)", () => { const bundle = { [`${CONFIG}/opencode.json`]: json({ plugin: ["opencode-cockpit@0.6.0"] }) } From a20212e23e5fab22ff7f3b8a842d9df8cd365998 Mon Sep 17 00:00:00 2001 From: Codestz Date: Sat, 3 Oct 2026 09:16:40 -0500 Subject: [PATCH 05/90] Trail core: the store, safe links, trail_add's checks and what the agent reads A new package, @opencode-cockpit/trail, version-agnostic and with no OpenCode adapter yet: an append-only events.ndjson per project outside the repo (Trust's scheme: torn last line, several writers, reconcile on load), one record per thing merged by url else ref, deleted conversations marked, links cleaned of secrets, the system read from the host, grouping by what a record is for, and the tool descriptions, guidance and per-request lines. Co-Authored-By: Claude Opus 5.5 --- packages/trail/LICENSE | 21 ++ packages/trail/package.json | 52 +++++ packages/trail/src/core/add.ts | 154 ++++++++++++++ packages/trail/src/core/index.ts | 12 ++ packages/trail/src/core/journal.ts | 84 ++++++++ packages/trail/src/core/links.ts | 230 +++++++++++++++++++++ packages/trail/src/core/model.ts | 310 ++++++++++++++++++++++++++++ packages/trail/src/core/paths.ts | 50 +++++ packages/trail/src/core/sample.ts | 194 +++++++++++++++++ packages/trail/src/core/store.ts | 292 ++++++++++++++++++++++++++ packages/trail/src/core/text.ts | 235 +++++++++++++++++++++ packages/trail/src/core/tools.ts | 47 +++++ packages/trail/test/add.test.ts | 138 +++++++++++++ packages/trail/test/journal.test.ts | 96 +++++++++ packages/trail/test/links.test.ts | 128 ++++++++++++ packages/trail/test/model.test.ts | 166 +++++++++++++++ packages/trail/test/store.test.ts | 163 +++++++++++++++ packages/trail/test/text.test.ts | 181 ++++++++++++++++ packages/trail/tsconfig.json | 14 ++ tsconfig.json | 3 + 20 files changed, 2570 insertions(+) create mode 100644 packages/trail/LICENSE create mode 100644 packages/trail/package.json create mode 100644 packages/trail/src/core/add.ts create mode 100644 packages/trail/src/core/index.ts create mode 100644 packages/trail/src/core/journal.ts create mode 100644 packages/trail/src/core/links.ts create mode 100644 packages/trail/src/core/model.ts create mode 100644 packages/trail/src/core/paths.ts create mode 100644 packages/trail/src/core/sample.ts create mode 100644 packages/trail/src/core/store.ts create mode 100644 packages/trail/src/core/text.ts create mode 100644 packages/trail/src/core/tools.ts create mode 100644 packages/trail/test/add.test.ts create mode 100644 packages/trail/test/journal.test.ts create mode 100644 packages/trail/test/links.test.ts create mode 100644 packages/trail/test/model.test.ts create mode 100644 packages/trail/test/store.test.ts create mode 100644 packages/trail/test/text.test.ts create mode 100644 packages/trail/tsconfig.json diff --git a/packages/trail/LICENSE b/packages/trail/LICENSE new file mode 100644 index 00000000..e48d0fa8 --- /dev/null +++ b/packages/trail/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Codestz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/trail/package.json b/packages/trail/package.json new file mode 100644 index 00000000..9ee989ec --- /dev/null +++ b/packages/trail/package.json @@ -0,0 +1,52 @@ +{ + "name": "@opencode-cockpit/trail", + "version": "0.8.0", + "description": "What a conversation made: the PRs, tickets and pages the agent created or changed, kept, ordered and one click away", + "type": "module", + "license": "MIT", + "author": "Codestz", + "repository": { + "type": "git", + "url": "git+https://github.com/Codestz/opencode-cockpit.git", + "directory": "packages/trail" + }, + "homepage": "https://github.com/Codestz/opencode-cockpit#readme", + "bugs": { + "url": "https://github.com/Codestz/opencode-cockpit/issues" + }, + "keywords": [ + "opencode", + "opencode-plugin", + "trail", + "pull-requests", + "tui", + "terminal" + ], + "exports": { + "./core": { + "types": "./types/core/index.d.ts", + "default": "./dist/core/index.js" + } + }, + "engines": { + "opencode": ">=1.18.0", + "bun": ">=1.3.5" + }, + "files": [ + "dist", + "types", + "README.md", + "LICENSE" + ], + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@opencode-cockpit/client": "workspace:*", + "@opencode-ai/plugin": "1.18.31" + }, + "bin": { + "opencode-trail": "./dist/cli/preview.js", + "trail": "./dist/cli/preview.js" + } +} diff --git a/packages/trail/src/core/add.ts b/packages/trail/src/core/add.ts new file mode 100644 index 00000000..cd218f38 --- /dev/null +++ b/packages/trail/src/core/add.ts @@ -0,0 +1,154 @@ +/** + * `trail_add`'s arguments, checked and cleaned into an event — the one door into the trail, for the + * agent's tool and for a person's `/link` and `a` alike. + * + * The shape is generic on purpose (docs/roadmap/v0.9/trail.md): nothing but a `title` and one of + * `url` / `ref` is required, and everything else is free text in the caller's own words. So the + * checks are few, and each failure is written for a model to act on (principles.md, rule 3): it + * says what was wrong, that nothing was recorded, and what to send instead. + */ + +import { cleanUrl, httpUrl, workOf } from "./links.ts" +import { type By, type Event, type Fields, matchRecord, newId, type State } from "./store.ts" + +/** The longest each field is kept; longer is cut with `…`, not refused. */ +export const LIMITS = { title: 200, url: 2000, ref: 120, kind: 60, action: 60, for: 200, note: 300 } as const + +const FIELDS = ["title", "url", "ref", "kind", "action", "for", "note"] as const +type Field = (typeof FIELDS)[number] + +export type Checked = + | { + ok: true + /** `action` left out when the caller left it out: it depends on what the trail already holds. */ + fields: Omit & { action?: string } + /** Query parameters taken out of the link because they looked like secrets. */ + stripped: string[] + } + | { ok: false; error: string } + +const NOTHING = "Nothing was recorded." + +/** One line of text: whitespace runs (newlines included) become one space; too long is cut. */ +function line(text: string, limit: number): string { + const flat = text.replace(/\s+/g, " ").trim() + return flat.length > limit ? `${flat.slice(0, limit - 1)}…` : flat +} + +/** `github.com/o/r/pull/1` is a link someone forgot the scheme of; `file:` and the like are not links. */ +function withScheme(text: string): string { + if (/^[a-z][a-z0-9+.-]*:/i.test(text)) return text + return /^[\w-]+(\.[\w-]+)+(:\d+)?(\/|$)/.test(text) ? `https://${text}` : text +} + +export function checkAdd(args: unknown): Checked { + if (!args || typeof args !== "object" || Array.isArray(args)) + return { + ok: false, + error: `trail_add takes an object: { title, url?, ref?, kind?, action?, for?, note? }. ${NOTHING}`, + } + const raw = args as { [key: string]: unknown } + const given: Partial<{ [K in Field]: string }> = {} + for (const field of FIELDS) { + const value = raw[field] + if (value === undefined || value === null) continue + if (typeof value === "number") given[field] = String(value) + else if (typeof value === "string") given[field] = value + else + return { + ok: false, + error: `trail_add: \`${field}\` must be text, got ${Array.isArray(value) ? "a list" : typeof value}. ${NOTHING} Send one record per call.`, + } + } + const fields: Partial<{ [K in Field]: string }> = {} + for (const field of FIELDS) { + const value = given[field] + if (value === undefined) continue + const cleaned = line(value, LIMITS[field]) + if (cleaned) fields[field] = cleaned + } + + if (!fields.title) + return { + ok: false, + error: `trail_add needs a \`title\`: the thing's own name, as a person would recognise it — the PR's title, the page's title, the ticket's summary. ${NOTHING} Call it again with a title.`, + } + + let stripped: string[] = [] + if (fields.url) { + const url = httpUrl(withScheme(fields.url)) + if (!url) + return { + ok: false, + error: `trail_add: \`url\` must be an http(s) link, and "${fields.url}" is not one. ${NOTHING} For something with no web page — a commit, a local file — leave url out and pass \`ref\` (a commit hash, a path).`, + } + const cleaned = cleanUrl(url) + fields.url = cleaned.url + stripped = cleaned.stripped + } + if (!fields.url && !fields.ref) + return { + ok: false, + error: `trail_add needs a \`url\` (the link to open it) or a \`ref\` (the name people search for: "COM-1736", "owner/repo#33", a commit hash). ${NOTHING} Call it again with at least one.`, + } + /** A PR or issue link names itself: `owner/repo#33`, unless the caller named it already. */ + if (fields.url && !fields.ref) { + const work = workOf(fields.url) + if (work) fields.ref = work.ref + } + return { + ok: true, + fields: fields as Omit & { action?: string }, + stripped, + } +} + +export interface Who { + /** The session the call was made in. */ + session: string + /** The conversation: the root session. */ + rootSession: string + sessionTitle?: string + by: By + subagent?: string + at: number + id?: string +} + +/** + * The event a checked call becomes. Unsaid, the action is `created` for a thing new to this + * conversation and `updated` for one it has recorded already. + */ +export function recordedEvent( + fields: Omit & { action?: string }, + who: Who, + state: State, +): Extract { + const known = matchRecord(state, who.rootSession, fields.url, fields.ref) !== undefined + return { + v: 1, + at: who.at, + id: who.id ?? newId(), + type: "recorded", + rootSession: who.rootSession, + session: who.session, + ...(who.sessionTitle ? { sessionTitle: who.sessionTitle } : {}), + by: who.by, + ...(who.subagent ? { subagent: who.subagent } : {}), + ...fields, + action: fields.action ?? (known ? "updated" : "created"), + } +} + +export interface ListArgs { + all: boolean + query: string +} + +/** `trail_list`'s arguments: both optional, and forgiving — `"all": "true"` means what it says. */ +export function checkList(args: unknown): ListArgs { + const raw = (args && typeof args === "object" ? args : {}) as { [key: string]: unknown } + const all = raw.all === true || raw.all === "true" || raw.all === "all" + const query = typeof raw.query === "string" ? line(raw.query, 200) : "" + return { all, query } +} diff --git a/packages/trail/src/core/index.ts b/packages/trail/src/core/index.ts new file mode 100644 index 00000000..c41d6fed --- /dev/null +++ b/packages/trail/src/core/index.ts @@ -0,0 +1,12 @@ +/** Published entry point: `@opencode-cockpit/trail/core` — the pure half, no OpenCode, no terminal. */ +export * from "./add.ts" +export * from "./journal.ts" +export * from "./links.ts" +export * from "./model.ts" +export * from "./paths.ts" +export * from "./store.ts" +export * from "./text.ts" +export * from "./tools.ts" +export * from "./view/dialog.ts" +export { type Fill, type Row, type Run, rowText, TINTS, type Tone, widthOf } from "./view/rows.ts" +export * from "./view/sidebar.ts" diff --git a/packages/trail/src/core/journal.ts b/packages/trail/src/core/journal.ts new file mode 100644 index 00000000..258d6ce5 --- /dev/null +++ b/packages/trail/src/core/journal.ts @@ -0,0 +1,84 @@ +/** + * The trail file, read and appended without ever blocking the interface thread — Trust's journal + * (`trust/src/tui/journal.ts`), here in core because both halves write: the agent half on + * `trail_add`, the interface on `a` and `x`. + * + * Every read and every append goes through one chain, so this reader's writes and reads never overlap + * (unserialised appends dropped lines in Review's trace). Other windows — and on OpenCode 1, another + * agent instance per directory — append to the same file between our reads; `read` takes whatever + * whole lines arrived since the last one and leaves a line still being written for next time. + * + * `sync` is the reconcile: the events since the last read folded into the caller's state, the whole + * file folded again when it was replaced. Event ids make a line read twice harmless. + */ + +import { appendFile, mkdir, open } from "node:fs/promises" +import type { TrailPaths } from "./paths.ts" +import { applyAll, type Event, emptyState, parseLines, type State, serialize } from "./store.ts" + +export interface Journal { + /** Events added since the last read, by anyone, in file order. `reset`: the file was replaced. */ + read: () => Promise<{ events: Event[]; reset: boolean }> + append: (events: readonly Event[]) => Promise + /** `state` brought up to the file: new events applied, or a fresh fold after a reset. */ + sync: (state: State) => Promise +} + +export function createJournal(paths: TrailPaths): Journal { + let offset = 0 + let chain: Promise = Promise.resolve() + let made = false + const decoder = new TextDecoder() + + const queue = (task: () => Promise): Promise => { + const run = chain.then(task, task) + chain = run.catch(() => {}) + return run + } + + const read = async (): Promise<{ events: Event[]; reset: boolean }> => { + let handle: Awaited> | undefined + try { + handle = await open(paths.events, "r") + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { events: [], reset: false } + throw error + } + try { + const { size } = await handle.stat() + /** Smaller than what we read: someone replaced the file. Start again from its first line. */ + const reset = size < offset + if (reset) offset = 0 + if (size === offset) return { events: [], reset } + const buffer = new Uint8Array(size - offset) + const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset) + const bytes = buffer.subarray(0, bytesRead) + const end = bytes.lastIndexOf(10) // the last newline: what follows is still being written + if (end < 0) return { events: [], reset } + const { events } = parseLines(decoder.decode(bytes.subarray(0, end + 1))) + offset += end + 1 + return { events, reset } + } finally { + await handle.close() + } + } + + return { + read: () => queue(read), + append: (events) => + queue(async () => { + if (events.length === 0) return + if (!made) { + await mkdir(paths.dir, { recursive: true }) + made = true + } + /** One write for the batch: whole lines, appended, never interleaved with another window's. */ + await appendFile(paths.events, events.map(serialize).join("")) + }), + sync: (state) => + queue(async () => { + const { events, reset } = await read() + return applyAll(reset ? emptyState() : state, events) + }), + } +} diff --git a/packages/trail/src/core/links.ts b/packages/trail/src/core/links.ts new file mode 100644 index 00000000..36076bf9 --- /dev/null +++ b/packages/trail/src/core/links.ts @@ -0,0 +1,230 @@ +/** + * What a link says about itself: whether it is safe to keep and to open, which system it belongs to, + * and the name people know the thing by. + * + * **The system comes from the link, not from the agent's `kind`.** A fixed list of kinds broke on the + * first Confluence page; a host does not. `SYSTEMS` is the whole table — a row per host (and path, + * where one host serves two products) — and anything not in it is shown as its bare domain, so a new + * tool needs no change here to be readable and searchable. + * + * **Safe links.** Only `http(s)` is ever opened. A link is cleaned before it is stored: credentials in + * it (`user:pass@`) and query parameters that look like secrets (`token`, `sig`, `X-Amz-Signature`…) + * are dropped, because the trail lives on disk in plain text and is copied into PR descriptions. + */ + +export interface System { + /** The host, or a suffix of it when it starts with a dot (`.atlassian.net`). */ + host: string + /** Only links whose path starts with this. */ + path?: string + name: string +} + +/** First match wins: a row with a path goes before the row for the same host without one. */ +export const SYSTEMS: readonly System[] = [ + { host: "github.com", name: "GitHub" }, + { host: "gist.github.com", name: "GitHub" }, + { host: "gitlab.com", name: "GitLab" }, + { host: ".atlassian.net", path: "/wiki", name: "Confluence" }, + { host: ".atlassian.net", path: "/browse/", name: "Jira" }, + { host: ".atlassian.net", path: "/jira/", name: "Jira" }, + { host: "claude.ai", name: "Claude" }, + { host: "linear.app", name: "Linear" }, +] + +/** `www.` says nothing; the rest of the host is the name of the place. */ +const bare = (host: string) => host.replace(/^www\./, "") + +/** A parsed `http(s)` link, or nothing. */ +export function httpUrl(text: string | undefined): URL | undefined { + if (!text) return undefined + try { + const url = new URL(text) + return url.protocol === "http:" || url.protocol === "https:" ? url : undefined + } catch { + return undefined + } +} + +/** Only these are ever handed to the system opener. */ +export const openable = (url: string | undefined): boolean => httpUrl(url) !== undefined + +/** The system a link belongs to — `GitHub`, `Jira`, or its bare domain. Nothing for no link. */ +export function systemOf(link: string | undefined, table: readonly System[] = SYSTEMS): string | undefined { + const url = httpUrl(link) + if (!url) return undefined + const host = url.hostname.toLowerCase() + for (const row of table) { + const hit = row.host.startsWith(".") ? host.endsWith(row.host) : host === row.host + if (hit && (row.path === undefined || url.pathname.startsWith(row.path))) return row.name + } + return bare(host) +} + +/* ─── secrets ────────────────────────────────────────────────────────────────────────────────── */ + +/** + * Query parameter names that carry a credential or a signature. Matched whole and case-blind; + * `X-Amz-*` / `X-Goog-*` (signed cloud links) by prefix. Some are short and ordinary-looking (`sig`, + * `key`, `code`) — a link that loses one still names the thing, and one that keeps it can leak it. + */ +const SECRET_NAMES = new Set([ + "token", + "access_token", + "accesstoken", + "id_token", + "refresh_token", + "auth", + "auth_token", + "authorization", + "jwt", + "sig", + "signature", + "key", + "api_key", + "apikey", + "secret", + "client_secret", + "password", + "passwd", + "pwd", + "code", + "credential", + "credentials", + "session", + "sessionid", + "private_token", + "key-pair-id", + "policy", +]) +const SECRET_PREFIXES = ["x-amz-", "x-goog-"] +/** `..._token`, `...Secret`, `...-signature`: a name built on one of the words. */ +const SECRET_PARTS = /(?:^|[_-])(token|secret|signature|password)$|(?:Token|Secret|Signature|Password)$/ + +export function secretParam(name: string): boolean { + const lower = name.toLowerCase() + return ( + SECRET_NAMES.has(lower) || + SECRET_PREFIXES.some((prefix) => lower.startsWith(prefix)) || + SECRET_PARTS.test(name) + ) +} + +export interface Cleaned { + url: string + /** The query parameters taken out, by name, so the tool can say so. */ + stripped: string[] +} + +/** The link as it is safe to store: no credentials, no secret-looking query parameters. */ +export function cleanUrl(url: URL): Cleaned { + const out = new URL(url.href) + const stripped: string[] = [] + if (out.username || out.password) { + stripped.push("credentials") + out.username = "" + out.password = "" + } + for (const name of [...new Set(out.searchParams.keys())]) + if (secretParam(name)) { + stripped.push(name) + out.searchParams.delete(name) + } + return { url: out.href, stripped } +} + +/* ─── work items: PRs and issues ─────────────────────────────────────────────────────────────── */ + +export interface Work { + /** The thing's own address, nothing after it: `https://github.com/o/r/pull/33`. */ + url: string + /** The name people search for: `o/r#33`, `group/project!12`, `COM-1736`, `ENG-42`. */ + ref: string + /** How a row names it: `PR #33`, `issue #12`, `MR !12`, `COM-1736`. */ + label: string +} + +/** Characters a URL in prose is commonly followed by and never ends with. */ +const TRAILING = /[).,;:!?'"\]>}]+$/ + +/** + * A PR, merge request or issue, read from its link: GitHub pull and issues, GitLab merge requests and + * issues (any host — GitLab is self-hosted as often as not), Jira's `/browse/KEY-1`, Linear's + * `/team/issue/KEY-1`. Anything else is not a work item. + */ +export function workOf(link: string | undefined): Work | undefined { + const url = httpUrl(link?.replace(TRAILING, "")) + if (!url) return undefined + const host = url.hostname.toLowerCase() + const origin = `${url.protocol}//${url.host}` + const parts = url.pathname.split("/").filter(Boolean) + if (host === "github.com" && parts.length >= 4) { + const [owner, repo, what, number] = parts as [string, string, string, string] + if ((what === "pull" || what === "issues") && /^\d+$/.test(number)) + return { + url: `${origin}/${owner}/${repo}/${what}/${number}`, + ref: `${owner}/${repo}#${number}`, + label: what === "pull" ? `PR #${number}` : `issue #${number}`, + } + } + const dash = parts.indexOf("-") + if (dash > 0 && parts.length >= dash + 3) { + const what = parts[dash + 1] + const number = parts[dash + 2] as string + if ((what === "merge_requests" || what === "issues") && /^\d+$/.test(number)) { + const project = parts.slice(0, dash).join("/") + const mark = what === "merge_requests" ? "!" : "#" + return { + url: `${origin}/${project}/-/${what}/${number}`, + ref: `${project}${mark}${number}`, + label: what === "merge_requests" ? `MR !${number}` : `issue #${number}`, + } + } + } + if ( + host.endsWith(".atlassian.net") && + parts[0] === "browse" && + parts[1] && + /^[A-Z][A-Z0-9_]*-\d+$/.test(parts[1]) + ) + return { url: `${origin}/browse/${parts[1]}`, ref: parts[1], label: parts[1] } + if (host === "linear.app" && parts[1] === "issue" && parts[2] && /^[A-Z][A-Z0-9]*-\d+$/i.test(parts[2])) { + const key = parts[2].toUpperCase() + return { url: `${origin}/${parts[0]}/issue/${parts[2]}`, ref: key, label: key } + } + return undefined +} + +/** + * What two links are compared by: a work item by its own address (so `/pull/33/files` and + * `/pull/33#discussion` are the PR), anything else by host and path, case-blind on the host, without + * a trailing slash or a fragment. The query stays — it can be what tells two pages apart. + */ +export function linkKey(link: string): string { + const work = workOf(link) + if (work) return work.url.toLowerCase() + const url = httpUrl(link) + if (!url) return link.trim() + const path = url.pathname.replace(/\/+$/, "") + return `${url.protocol}//${url.host.toLowerCase()}${path}${url.search}` +} + +/** Every `http(s)` link in some text, in order, once each. */ +export function linksIn(text: string): string[] { + const out: string[] = [] + for (const match of text.matchAll(/https?:\/\/[^\s<>"'`]+/g)) { + const link = match[0].replace(TRAILING, "") + if (!out.includes(link)) out.push(link) + } + return out +} + +/** The PRs and issues in some text — a command's output — by their own address, once each. */ +export function workIn(text: string): Work[] { + const out: Work[] = [] + for (const link of linksIn(text)) { + const work = workOf(link) + if (work && !out.some((each) => each.url === work.url)) out.push(work) + } + return out +} diff --git a/packages/trail/src/core/model.ts b/packages/trail/src/core/model.ts new file mode 100644 index 00000000..1f15c084 --- /dev/null +++ b/packages/trail/src/core/model.ts @@ -0,0 +1,310 @@ +/** + * What every surface reads — the sidebar, `/trail`, `trail_list`, the system lines and the markdown + * copy: the things a conversation (or the whole project) made, each once, in one order. + * + * **One truth** (principles.md, rule 2): the dialog and the tool are two drawings of `arrange`'s + * result, never two queries that could disagree. + * + * **Ordered by the work, not by the tool.** A record names what it is `for` — a ticket heads the PRs + * made for it; inside a group, newest first. Groups come first, newest work first; things that belong + * to nothing stand alone after them, newest first. A `for` nothing here records (a ticket the agent + * never touched) still heads its group, by name. + * + * **All conversations** is one row per thing, by its link (else its ref), listing every conversation + * that touched it — so a ticket worked on in three conversations is one row with three jumps. + */ + +import { httpUrl, linkKey, openable, systemOf, workIn, workOf } from "./links.ts" +import type { By, Entry, State, Step } from "./store.ts" + +/** One conversation's part in a thing. */ +export interface Touch { + session: string + /** The conversation's title, as written down when it recorded. */ + title?: string + deleted: boolean + /** The record's key: what `x` removes. */ + record: string + history: Step[] + by: By + subagent?: string + lastAt: number +} + +export interface Thing { + /** The record's key, or in All conversations the link (else ref) every touch shares. */ + key: string + title: string + url?: string + ref?: string + kind?: string + for?: string + note?: string + /** From the link: `GitHub`, `Jira`, `notion.so`. */ + system?: string + /** How a row names it: `PR #33`, `COM-1736`, else the ref, else the kind. */ + label?: string + /** Has an `http(s)` link: `enter` and a click open it. */ + openable: boolean + /** Every action, oldest first, across the touches. */ + history: Step[] + firstAt: number + lastAt: number + /** The conversations that touched it, the latest first. One in This conversation. */ + touches: Touch[] +} + +/** `PR #33` for a PR link, the ref as given, the kind when there is neither. */ +export function labelOf(entry: Pick): string | undefined { + const work = workOf(entry.url) + if (work) return work.label + return entry.ref ?? entry.kind +} + +function touchOf(state: State, record: Entry): Touch { + const conversation = state.conversations.get(record.session) + return { + session: record.session, + ...(conversation?.title ? { title: conversation.title } : {}), + deleted: conversation?.deletedAt !== undefined, + record: record.key, + history: record.history, + by: record.by, + ...(record.subagent ? { subagent: record.subagent } : {}), + lastAt: record.lastAt, + } +} + +function thingOf(key: string, records: readonly Entry[], state: State): Thing { + const sorted = [...records].sort((a, b) => b.lastAt - a.lastAt) + const latest = sorted[0] as Entry + /** The latest record's words, and any field only an older one gave. */ + const pick = (field: K) => + sorted.find((record) => record[field] !== undefined)?.[field] + const url = pick("url") + const ref = pick("ref") + const kind = pick("kind") + const forWhat = pick("for") + const note = pick("note") + const system = systemOf(url) + const label = labelOf({ url, ref, kind }) + return { + key, + title: latest.title, + ...(url ? { url } : {}), + ...(ref ? { ref } : {}), + ...(kind ? { kind } : {}), + ...(forWhat ? { for: forWhat } : {}), + ...(note ? { note } : {}), + ...(system ? { system } : {}), + ...(label ? { label } : {}), + openable: openable(url), + history: sorted.flatMap((record) => record.history).sort((a, b) => a.at - b.at), + firstAt: Math.min(...sorted.map((record) => record.firstAt)), + lastAt: latest.lastAt, + touches: sorted.map((record) => touchOf(state, record)), + } +} + +/** What one conversation made, one thing per record. */ +export function conversationThings(state: State, session: string): Thing[] { + return [...state.records.values()] + .filter((record) => record.session === session) + .map((record) => thingOf(record.key, [record], state)) +} + +/** The same thing across conversations: its link, else its ref. */ +export const thingKey = (entry: Pick): string => + entry.url ? linkKey(entry.url) : entry.ref ? `ref:${entry.ref.trim().toLowerCase()}` : entry.key + +/** What the project's conversations made, one thing per link. */ +export function projectThings(state: State): Thing[] { + const by = new Map() + for (const record of state.records.values()) { + const key = thingKey(record) + by.set(key, [...(by.get(key) ?? []), record]) + } + return [...by].map(([key, records]) => thingOf(key, records, state)) +} + +/* ─── search ─────────────────────────────────────────────────────────────────────────────────── */ + +/** Every word of `query` somewhere in the thing's title, ref, kind or system (or how it is named). */ +export function matches(thing: Thing, query: string): boolean { + const words = query.toLowerCase().split(/\s+/).filter(Boolean) + if (words.length === 0) return true + const hay = [thing.title, thing.ref, thing.label, thing.kind, thing.system, thing.for] + .filter(Boolean) + .join("\n") + .toLowerCase() + return words.every((word) => hay.includes(word)) +} + +/* ─── order ──────────────────────────────────────────────────────────────────────────────────── */ + +export interface Group { + /** The thing the others are for — or, when nothing here records it, its name. */ + head: Thing | string + children: Thing[] + lastAt: number +} + +export interface Arranged { + groups: Group[] + /** Things that belong to nothing here, and head nothing. */ + alone: Thing[] + /** How many things, before a query. */ + total: number + /** How many the query kept. */ + shown: number +} + +/** Does `target` name this thing — its ref, its link, or how it is labelled? */ +function names(thing: Thing, target: string): boolean { + const wanted = target.trim().toLowerCase() + if (!wanted) return false + if (thing.ref?.toLowerCase() === wanted || thing.label?.toLowerCase() === wanted) return true + return thing.url !== undefined && httpUrl(target) !== undefined && linkKey(thing.url) === linkKey(target) +} + +/** A `for` nothing records, named the way a row would name it: a ticket link is its key. */ +function forName(target: string): string { + const work = workOf(target) + return work ? work.label : target +} + +const newestFirst = (a: Thing, b: Thing) => b.lastAt - a.lastAt + +export function arrange(things: readonly Thing[], query = ""): Arranged { + /** + * Each thing's head, walked to the top so a chain is one group. A chain that ends on a name nothing + * records is headed by that name. One that loops is headed by its oldest thing, which heads nothing + * itself — so every thing is drawn once. + */ + const headOf = new Map() + for (const thing of things) { + let at: Thing = thing + let head: Thing | string | undefined + const passed: Thing[] = [thing] + for (let hop = 0; hop < 8 && at.for; hop++) { + const target = at.for + const found = things.find((other) => other !== at && names(other, target)) + if (!found) { + head = forName(target) + break + } + if (passed.includes(found)) { + const loop = passed.slice(passed.indexOf(found)) + const oldest = loop.reduce((a, b) => (b.firstAt < a.firstAt ? b : a)) + head = oldest === thing ? undefined : oldest + break + } + head = found + passed.push(found) + at = found + } + if (head !== undefined) headOf.set(thing, head) + } + + const kept = new Set(things.filter((thing) => matches(thing, query))) + const groups = new Map() + /** A virtual head is keyed by its name, case-blind, so two spellings of COM-1736 are one group. */ + const virtual = new Map() + for (const thing of things) { + let head = headOf.get(thing) + if (head === undefined) continue + if (typeof head === "string") { + const key = head.toLowerCase() + head = virtual.get(key) ?? head + virtual.set(key, head) + } + if (!groups.has(head)) groups.set(head, []) + if (kept.has(thing)) (groups.get(head) as Thing[]).push(thing) + } + + const out: Group[] = [] + for (const [head, children] of groups) { + const headKept = typeof head !== "string" && kept.has(head) + /** A query keeps a group when it found the head or a child; the head stays as context. */ + if (children.length === 0 && !headKept) continue + children.sort(newestFirst) + const times = [...children.map((child) => child.lastAt), typeof head === "string" ? 0 : head.lastAt] + out.push({ head, children, lastAt: Math.max(...times) }) + } + out.sort((a, b) => b.lastAt - a.lastAt) + + const heads = new Set([...groups.keys()].filter((head): head is Thing => typeof head !== "string")) + const alone = things.filter((thing) => kept.has(thing) && !headOf.has(thing) && !heads.has(thing)) + alone.sort(newestFirst) + const shown = new Set([ + ...out.flatMap((group) => [ + ...group.children, + ...(typeof group.head !== "string" && kept.has(group.head) ? [group.head] : []), + ]), + ...alone, + ]).size + return { groups: out, alone, total: things.length, shown } +} + +/** One row of an arranged trail: a thing (under its head, or not), or a head nothing records. */ +export type Line = + | { kind: "thing"; thing: Thing; depth: 0 | 1 } + | { kind: "head"; name: string; children: number } + +export function linesOf(arranged: Arranged): Line[] { + const lines: Line[] = [] + for (const group of arranged.groups) { + lines.push( + typeof group.head === "string" + ? { kind: "head", name: group.head, children: group.children.length } + : { kind: "thing", thing: group.head, depth: 0 }, + ) + for (const child of group.children) lines.push({ kind: "thing", thing: child, depth: 1 }) + } + for (const thing of arranged.alone) lines.push({ kind: "thing", thing, depth: 0 }) + return lines +} + +/* ─── found, not recorded ────────────────────────────────────────────────────────────────────── */ + +/** A PR or issue link seen in the output of something the agent ran. */ +export interface Found { + url: string + ref: string + label: string + system?: string + /** When it was first seen. */ + at: number +} + +/** The PRs and issues in a command's output, as finds. */ +export function foundIn(text: string, at: number): Found[] { + return workIn(text).map((work) => { + const system = systemOf(work.url) + return { url: work.url, ref: work.ref, label: work.label, ...(system ? { system } : {}), at } + }) +} + +/** + * The finds this conversation has not recorded, newest first, once each — the safety net's whole + * output. A record matches by link, or by the ref the link derives (`owner/repo#33`). + */ +export function notRecorded(state: State, session: string, seen: readonly Found[]): Found[] { + const records = [...state.records.values()].filter((record) => record.session === session) + const links = new Set(records.flatMap((record) => (record.url ? [linkKey(record.url)] : []))) + const refs = new Set(records.flatMap((record) => (record.ref ? [record.ref.toLowerCase()] : []))) + const out = new Map() + for (const found of seen) { + const key = linkKey(found.url) + if (links.has(key) || refs.has(found.ref.toLowerCase())) continue + const had = out.get(key) + if (!had || had.at > found.at) out.set(key, found) + } + return [...out.values()].sort((a, b) => b.at - a.at) +} + +/** The last thing done to it, and when. */ +export const lastOf = (thing: Pick): { action: string; at: number } => { + const step = thing.history.at(-1) + return { action: step?.action ?? "recorded", at: step?.at ?? thing.lastAt } +} diff --git a/packages/trail/src/core/paths.ts b/packages/trail/src/core/paths.ts new file mode 100644 index 00000000..9f022bcb --- /dev/null +++ b/packages/trail/src/core/paths.ts @@ -0,0 +1,50 @@ +/** + * Where a project's trail lives on disk — Trust's scheme (`trust/src/core/paths.ts`), its own folder. + * + * Pure: creates nothing, reads nothing, and takes its environment as an argument. + * + * **Outside the project.** What your conversations made is about you and your sessions, not the + * work; a trail in the repo would turn up in `git status` and travel to everyone who clones it. + * + * **Keyed by the project's full path**: the last two segments to read, a short hash of the whole to + * be certain (`~/a/web` and `~/b/web` are two trails). + */ + +import { createHash } from "node:crypto" +import { homedir } from "node:os" +import { join } from "node:path" + +export interface TrailPaths { + dir: string + /** The append-only trail: one event per line. */ + events: string +} + +/** Anything that is not plainly a filename becomes a dash. */ +export function slug(text: string): string { + const cleaned = text + .replace(/[^a-zA-Z0-9._-]+/g, "-") + .replace(/^[-.]+|[-.]+$/g, "") + .slice(0, 60) + return cleaned.length > 0 ? cleaned : "unnamed" +} + +export function projectSlug(directory: string): string { + const parts = directory.split("/").filter(Boolean) + return slug(parts.slice(-2).join("-")) +} + +export function shortHash(text: string): string { + return createHash("sha256").update(text).digest("hex").slice(0, 8) +} + +export function trailPaths( + directory: string, + env: { [name: string]: string | undefined } = process.env, +): TrailPaths { + const base = + env.COCKPIT_HOME ?? + join(env.XDG_DATA_HOME ?? join(env.HOME ?? homedir(), ".local", "share"), "opencode-cockpit") + const dir = join(base, "trail", `${projectSlug(directory)}-${shortHash(directory)}`) + return { dir, events: join(dir, "events.ndjson") } +} diff --git a/packages/trail/src/core/sample.ts b/packages/trail/src/core/sample.ts new file mode 100644 index 00000000..5460ad25 --- /dev/null +++ b/packages/trail/src/core/sample.ts @@ -0,0 +1,194 @@ +/** + * Sample trails for the preview and the tests: the states a design gets wrong (testing.md) — none, + * one, a busy conversation grouped by ticket, titles too long for any column, a project of several + * conversations with one deleted, and links the agent printed but never recorded. + * + * Built through `runAdd`, the tool's own path, so a sample cannot hold a record the tool would not + * have made. + */ + +import { type Found, foundIn } from "./model.ts" +import { apply, emptyState, type State } from "./store.ts" +import { runAdd } from "./tools.ts" + +export const SAMPLE_NOW = Date.UTC(2026, 9, 3, 15, 0, 0) +export const SAMPLE_SESSION = "ses_main" +export const SAMPLE_PROJECT = "opencode-cockpit" +const MIN = 60_000 +const HOUR = 60 * MIN + +export interface Sample { + state: State + session: string + found: Found[] +} + +interface Add { + args: { [key: string]: string } + ago: number + session?: string + title?: string + subagent?: string + by?: "agent" | "you" +} + +function build(adds: readonly Add[], found: Found[] = []): Sample { + const state = emptyState() + /** Ids by position, so a sample is the same trail every time it is built. */ + let counter = 0 + for (const add of adds) { + const session = add.session ?? SAMPLE_SESSION + const out = runAdd(state, add.args, { + session: add.subagent ? `${session}_child` : session, + rootSession: session, + sessionTitle: add.title ?? "Fix the bundle desync", + by: add.by ?? "agent", + ...(add.subagent ? { subagent: add.subagent } : {}), + at: SAMPLE_NOW - add.ago, + id: `ev_${++counter}`, + }) + if (!out.ok) throw new Error(`sample: ${out.text}`) + } + return { state, session: SAMPLE_SESSION, found } +} + +const PR33 = "https://github.com/Codestz/opencode-cockpit/pull/33" +const PR12 = "https://github.com/Codestz/opencode-cockpit-site/pull/12" +const TICKET = "https://acme.atlassian.net/browse/COM-1736" + +const busy: Add[] = [ + { + args: { title: "Bundle desync", url: TICKET, kind: "ticket", action: "updated" }, + ago: 2 * HOUR + 5 * MIN, + }, + { args: { title: "Landing: Trust section", url: PR12, action: "created", for: "COM-1736" }, ago: 2 * HOUR }, + { + args: { title: "0.8: Trust, one design system", url: PR33, action: "created", for: "COM-1736" }, + ago: 70 * MIN, + }, + { args: { title: "0.8: Trust, one design system, and the sidebar order", url: PR33 }, ago: 62 * MIN }, + { + args: { + title: "Release notes for 0.8", + url: "https://acme.atlassian.net/wiki/x/AbC123", + kind: "Confluence page", + }, + ago: 40 * MIN, + }, + { + args: { title: "Rollout checklist", url: "https://claude.ai/public/artifacts/9f2c", kind: "artifact" }, + ago: 30 * MIN, + subagent: "docs", + }, + { + args: { + title: "staging · web-portal", + ref: "deploy 2026-10-03.4", + kind: "deploy", + note: "behind the flag", + }, + ago: 25 * MIN, + }, + { + args: { + title: "Retry the socket after sleep", + url: "https://linear.app/acme/issue/ENG-42/retry-the-socket", + for: "COM-1801", + }, + ago: 15 * MIN, + }, + { + args: { title: "Bump the protocol version", ref: "a1b2c3d", kind: "commit", for: "COM-1801" }, + ago: 12 * MIN, + }, + { + args: { + title: "Status page incident", + url: "https://status.acme.dev/incidents/88?token=abc123&view=full", + action: "published", + }, + ago: 5 * MIN, + by: "you", + }, +] + +/** Printed by a command and never recorded: one PR the agent opened, one it only looked at. */ +const FOUND: Found[] = [ + ...foundIn( + "Created pull request: https://github.com/acme/web/pull/40\nSee also https://acme.atlassian.net/browse/COM-1800.", + SAMPLE_NOW - 3 * MIN, + ), + ...foundIn(`Already recorded: ${PR33}`, SAMPLE_NOW - 2 * MIN), +] + +const LONG = "A very long title that a person wrote because the change touched everything at once and said so" + +/** The same PR in two conversations, a ticket in three, and one conversation since deleted. */ +function project(): Sample { + const sample = build([ + ...busy, + { + args: { title: "Bundle desync", url: TICKET, action: "commented" }, + ago: 3 * 24 * HOUR, + session: "ses_older", + title: "Investigate the reconnect bug", + }, + { + args: { + title: "Reconnect spike", + url: "https://github.com/Codestz/opencode-cockpit/pull/31", + for: "COM-1736", + }, + ago: 3 * 24 * HOUR - HOUR, + session: "ses_older", + title: "Investigate the reconnect bug", + }, + { + args: { title: "0.8: Trust, one design system", url: PR33, action: "reviewed" }, + ago: 20 * MIN, + session: "ses_review", + title: "Review the 0.8 branch", + }, + { + args: { title: "Old dashboard", url: "https://grafana.acme.dev/d/xyz", kind: "dashboard" }, + ago: 9 * 24 * HOUR, + session: "ses_gone", + title: "Set up the latency dashboard", + }, + ]) + apply(sample.state, { + v: 1, + at: SAMPLE_NOW - 24 * HOUR, + id: "ev_deleted", + type: "deleted", + rootSession: "ses_gone", + }) + return sample +} + +export const SAMPLES: { [name: string]: () => Sample } = { + empty: () => build([]), + one: () => build([{ args: { title: "0.8: Trust, one design system", url: PR33 }, ago: 4 * MIN }]), + busy: () => build(busy, FOUND), + long: () => + build([ + { args: { title: LONG, url: PR33, for: "COM-1736-WITH-A-VERY-LONG-KEY" }, ago: 50 * MIN }, + { + args: { + title: LONG, + ref: "a-reference-that-goes-on-and-on-and-on", + kind: "a kind described at great length", + action: "created, then reopened, then merged", + }, + ago: 3 * 24 * HOUR, + }, + { + args: { + title: "最終リリースノート — 日本語�タイトル", + url: "https://acme.atlassian.net/wiki/x/JP1", + }, + ago: 9 * MIN, + }, + ]), + project, +} diff --git a/packages/trail/src/core/store.ts b/packages/trail/src/core/store.ts new file mode 100644 index 00000000..371792d2 --- /dev/null +++ b/packages/trail/src/core/store.ts @@ -0,0 +1,292 @@ +/** + * The trail: what each conversation made, one event per line, and what it adds up to. + * + * Append-only, for Trust's reasons (`trust/src/core/ledger.ts`): several OpenCode windows — and on + * OpenCode 1 one agent instance per directory — write to the same file, and a whole line appended in + * one write is the only update that needs no lock. The records are never stored; they are a fold over + * the events, so a record's history (`created → updated`) is there to read, and every reader that + * has seen the same lines holds the same trail. + * + * **One thing, one record.** A conversation that records the same link twice — or, with no link, the + * same ref — has one record whose actions are a history, never two rows. The later title wins: the + * same url again with a better title is how a record is corrected. + * + * **Each record keeps its conversation's title.** A deleted conversation vanishes from OpenCode, and + * OpenCode 2 does not even say what it was called (docs/opencode/trail-interface.md), so the title + * is written down when the record is, and a `deleted` event marks the records as orphans rather than + * removing them. + */ + +import { linkKey } from "./links.ts" + +export type By = "agent" | "you" + +/** Every event: when, and an id so two readers of one line never apply it twice. */ +interface Base { + v: 1 + at: number + id: string + /** The conversation it belongs to: the root session, whichever subagent made it. */ + rootSession: string +} + +/** The fields of a thing, as the agent or a person gave them (cleaned by `add.ts`). */ +export interface Fields { + title: string + url?: string + ref?: string + kind?: string + action: string + for?: string + note?: string +} + +export type Event = + | (Base & + Fields & { + type: "recorded" + /** The session the call was made in: a subagent's own, or the conversation itself. */ + session: string + sessionTitle?: string + by: By + /** The subagent that made it, when one did. */ + subagent?: string + }) + /** You took a record out of the trail (`x`). */ + | (Base & { type: "removed"; record: string }) + /** The conversation was deleted in OpenCode; its records stay, marked. */ + | (Base & { type: "deleted"; sessionTitle?: string }) + +export interface Step { + action: string + at: number + by: By + subagent?: string +} + +export interface Entry { + /** The id of the event that first recorded it: stable for every reader of the file. */ + key: string + /** The conversation (root session). */ + session: string + title: string + url?: string + ref?: string + kind?: string + for?: string + note?: string + /** Who first recorded it. */ + by: By + subagent?: string + /** Everything this conversation did to it, oldest first. */ + history: Step[] + firstAt: number + lastAt: number +} + +export interface Conversation { + session: string + title?: string + /** When it was deleted in OpenCode, if it was. */ + deletedAt?: number + lastAt: number +} + +export interface State { + records: Map + conversations: Map + /** Event ids already applied. */ + seen: Set +} + +export const emptyState = (): State => ({ records: new Map(), conversations: new Map(), seen: new Set() }) + +const refKey = (ref: string) => ref.trim().toLowerCase() + +/** + * The record in `session` that a new mention of `url` / `ref` is about: the same link first, else the + * same ref. Nothing when it is new. + */ +export function matchRecord( + state: State, + session: string, + url: string | undefined, + ref: string | undefined, +): Entry | undefined { + const records = [...state.records.values()].filter((record) => record.session === session) + if (url) { + const key = linkKey(url) + const hit = records.find((record) => record.url !== undefined && linkKey(record.url) === key) + if (hit) return hit + } + if (ref) { + const key = refKey(ref) + return records.find((record) => record.ref !== undefined && refKey(record.ref) === key) + } + return undefined +} + +function conversation(state: State, session: string, at: number): Conversation { + let found = state.conversations.get(session) + if (!found) { + found = { session, lastAt: at } + state.conversations.set(session, found) + } + found.lastAt = Math.max(found.lastAt, at) + return found +} + +export function apply(state: State, event: Event): State { + if (state.seen.has(event.id)) return state + state.seen.add(event.id) + const where = conversation(state, event.rootSession, event.at) + switch (event.type) { + case "recorded": { + if (event.sessionTitle) where.title = event.sessionTitle + const step: Step = { + action: event.action, + at: event.at, + by: event.by, + ...(event.subagent ? { subagent: event.subagent } : {}), + } + const found = matchRecord(state, event.rootSession, event.url, event.ref) + if (!found) { + state.records.set(event.id, { + key: event.id, + session: event.rootSession, + title: event.title, + ...(event.url ? { url: event.url } : {}), + ...(event.ref ? { ref: event.ref } : {}), + ...(event.kind ? { kind: event.kind } : {}), + ...(event.for ? { for: event.for } : {}), + ...(event.note ? { note: event.note } : {}), + by: event.by, + ...(event.subagent ? { subagent: event.subagent } : {}), + history: [step], + firstAt: event.at, + lastAt: event.at, + }) + return state + } + /** What was said again replaces what was said before; what was left out is kept. */ + found.title = event.title + for (const field of ["url", "ref", "kind", "for", "note"] as const) { + const value = event[field] + if (value) found[field] = value + } + found.history.push(step) + found.history.sort((a, b) => a.at - b.at) + found.lastAt = Math.max(found.lastAt, event.at) + return state + } + case "removed": + state.records.delete(event.record) + return state + case "deleted": + if (event.sessionTitle && !where.title) where.title = event.sessionTitle + where.deletedAt ??= event.at + return state + } +} + +export function applyAll(state: State, events: readonly Event[]): State { + for (const event of events) apply(state, event) + return state +} + +/** A record's actions as a person reads them: `created → updated`, a repeat said once. */ +export function historyText(record: Pick, joiner = " → "): string { + const said: string[] = [] + for (const step of record.history) if (said.at(-1) !== step.action) said.push(step.action) + return said.join(joiner) +} + +/** The last thing this conversation did to it. */ +export const lastStep = (record: Pick): Step => + record.history.at(-1) ?? { action: "recorded", at: 0, by: "agent" } + +/** + * Conversations that have records but no longer exist in OpenCode: what a reader that just listed the + * sessions should mark `deleted`. Only roots the host was asked about can be judged, so the caller + * passes every live session it knows of. + */ +export function vanished(state: State, live: ReadonlySet): Conversation[] { + const out: Conversation[] = [] + for (const conversation of state.conversations.values()) { + if (conversation.deletedAt !== undefined || live.has(conversation.session)) continue + if ([...state.records.values()].some((record) => record.session === conversation.session)) + out.push(conversation) + } + return out +} + +/** A fresh event id: unique across windows without coordinating. */ +export function newId(): string { + return `tr_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}` +} + +/* ─── the file ───────────────────────────────────────────────────────────────────────────────── */ + +const str = (value: unknown) => typeof value === "string" +const optStr = (value: unknown) => value === undefined || typeof value === "string" + +function asEvent(value: unknown): Event | undefined { + if (!value || typeof value !== "object") return undefined + const raw = value as { [key: string]: unknown } + if (raw.v !== 1 || typeof raw.at !== "number" || !str(raw.id) || !str(raw.rootSession)) return undefined + switch (raw.type) { + case "recorded": + return str(raw.session) && + str(raw.title) && + str(raw.action) && + (raw.by === "agent" || raw.by === "you") && + (raw.url !== undefined || raw.ref !== undefined) && + ["url", "ref", "kind", "for", "note", "sessionTitle", "subagent"].every((field) => optStr(raw[field])) + ? (raw as unknown as Event) + : undefined + case "removed": + return str(raw.record) ? (raw as unknown as Event) : undefined + case "deleted": + return optStr(raw.sessionTitle) ? (raw as unknown as Event) : undefined + default: + return undefined + } +} + +function parseLine(line: string): Event | undefined { + try { + return asEvent(JSON.parse(line)) + } catch { + /** + * A writer that died mid-line leaves no newline, so the next event is appended onto the remains: + * `{"v":1,"at":17…{"v":1,"at":18…}`. The last event start in the line is the whole one. + */ + const at = line.lastIndexOf('{"v":1') + if (at <= 0) return undefined + try { + return asEvent(JSON.parse(line.slice(at))) + } catch { + return undefined + } + } +} + +/** + * Events in `text`, and what is left after the last newline. The rest is not an error: it is a line + * another window is still writing, read again next time. + */ +export function parseLines(text: string): { events: Event[]; rest: string } { + const end = text.lastIndexOf("\n") + const whole = end < 0 ? "" : text.slice(0, end) + const rest = end < 0 ? text : text.slice(end + 1) + const events: Event[] = [] + for (const line of whole.split("\n")) { + if (line.trim() === "") continue + const event = parseLine(line) + if (event) events.push(event) + } + return { events, rest } +} + +/** One line of the file. Never contains a newline, whatever a title holds. */ +export const serialize = (event: Event): string => `${JSON.stringify(event)}\n` diff --git a/packages/trail/src/core/text.ts b/packages/trail/src/core/text.ts new file mode 100644 index 00000000..e5011839 --- /dev/null +++ b/packages/trail/src/core/text.ts @@ -0,0 +1,235 @@ +/** + * Everything the agent reads from Trail: the tools' descriptions, the guidance in its system prompt, + * the two per-conversation lines added on every request, and what `trail_add` and `trail_list` + * answer. Written to be read by a model (principles.md, rule 3) — what changed first, then what to + * do next — and drawn from the same `arrange` as the dialog (rule 2). + * + * Why the guidance carries the examples: OpenCode 2 shows a plugin tool's description cut to about + * 115 characters in its Code Mode catalog (docs/opencode/trail-server.md), so the description says + * *when* to call in its first sentence, and the examples live in the system prompt, which every + * request carries whole. It names `tools.trail_add`, so a model in Code Mode can call it without + * searching the catalog first. + */ + +import type { Checked } from "./add.ts" +import { + type Arranged, + arrange, + conversationThings, + type Found, + lastOf, + linesOf, + projectThings, + type Thing, +} from "./model.ts" +import { historyText, type State } from "./store.ts" +import { ago, cut } from "./view/rows.ts" + +/* ─── the tools ──────────────────────────────────────────────────────────────────────────────── */ + +/** When to call, first: on OpenCode 2 the catalog shows only the first ~115 characters. */ +export const ADD_DESCRIPTION = `Call right after you create or change something outside this repo — a PR, ticket, doc page, deploy — to record it. \ +The trail is how the user finds, later, what this conversation made, and which conversation made it. \ +One call per thing; calling again with the same url updates its record (a better title fixes it). \ +Required: title, and url or ref.` + +export const ADD_ARGS = { + title: "The thing's own title, as a person would recognise it: the PR's title, the page's title. Required.", + url: "The link that opens it (http/https). Give url, ref, or both.", + ref: 'The name people search for: "COM-1736", "owner/repo#33", a commit hash. Read from GitHub PR and issue links when left out.', + kind: 'What it is, in your words: "pull request", "Confluence page", "deploy".', + action: + "What you did: created, updated, merged, published… Defaults to created, or updated when it is already in the trail.", + for: 'The ref or url of what this belongs to — the ticket a PR is for: "COM-1736".', + note: "One optional line worth keeping.", +} as const + +export const LIST_DESCRIPTION = `What this conversation recorded with trail_add (PRs, tickets, pages, deploys) — or, with all, every conversation in this project, and which one made each. \ +query filters by title, ref, kind or system (GitHub, Jira…). Use it to answer "what did we make for COM-1736?" or "which conversation opened the auth PR?".` + +export const LIST_ARGS = { + all: "true: every conversation in this project, not only this one.", + query: 'Words that must all appear in the title, ref, kind or system, e.g. "COM-1736" or "jira".', +} as const + +/** The system-prompt guidance: always loaded, subagents included. */ +export const GUIDANCE = `## Trail (opencode-cockpit) +When you create or change something outside this repository's files — a pull request, a ticket, a doc page, an artifact, a deploy — record it right away with trail_add (in Code Mode: \`await tools.trail_add({...})\`). Use the thing's own title and its link; it is how the user finds later what this conversation made. + tools.trail_add({ title: "Fix bundle desync on reconnect", url: "https://github.com/acme/web/pull/33", action: "created", for: "COM-1736" }) + tools.trail_add({ title: "Release notes for 0.8", url: "https://acme.atlassian.net/wiki/x/AbC123", kind: "Confluence page", action: "updated" }) +Not for files in this repository, or for links you only read or printed. trail_list (all, query) answers "what did we make for COM-1736?".` + +/* ─── naming a thing ─────────────────────────────────────────────────────────────────────────── */ + +/** `PR #33 "Fix bundle desync"` — how a sentence names a thing. */ +export function named(thing: Pick, room = 80): string { + const title = `"${cut(thing.title, room)}"` + return thing.label ? `${thing.label} ${title}` : title +} + +/** `by subagent explore`, `added by you`, or nothing for the agent itself. */ +function whoText(touch: { by: string; subagent?: string }): string { + if (touch.by === "you") return "added by you" + return touch.subagent ? `by subagent ${touch.subagent}` : "" +} + +/* ─── trail_add's answer ─────────────────────────────────────────────────────────────────────── */ + +export interface Added { + thing: Thing + /** It was already in this conversation's trail. */ + merged: boolean + /** How many things this conversation's trail holds now. */ + count: number + stripped: string[] + /** The `for` names nothing this conversation recorded. */ + forMissing?: string +} + +/** What `trail_add` answers: what changed, then anything to know. */ +export function addedText(added: Added): string { + const { thing } = added + const where = thing.system ? ` (${thing.system})` : "" + const lines = [ + added.merged + ? `Updated the existing record of ${named(thing)}${where}: ${historyText(thing)}. Recording the same url again never makes a second row.` + : `Recorded ${named(thing)}${where} — ${lastOf(thing).action}.`, + ] + lines.push( + `This conversation's trail holds ${added.count} ${added.count === 1 ? "thing" : "things"}; the user sees it in the sidebar and /trail.`, + ) + if (added.stripped.length > 0) + lines.push( + `Removed from the link before storing, because it looked like a secret: ${added.stripped.join(", ")}.`, + ) + if (added.forMissing) + lines.push( + `"${added.forMissing}" is not in this conversation's trail itself; if this conversation created or changed it, record it too.`, + ) + return lines.join("\n") +} + +/** A refused call, as `trail_add` answers it. */ +export const refusedText = (checked: Extract): string => checked.error + +/* ─── trail_list's answer ────────────────────────────────────────────────────────────────────── */ + +export interface ListInput { + state: State + /** The conversation asking. */ + session: string + all: boolean + query: string + now: number +} + +function thingLine(thing: Thing, depth: number, input: ListInput): string[] { + const last = lastOf(thing) + const facts = [ + named(thing, 120), + ...(thing.ref && thing.ref !== thing.label ? [thing.ref] : []), + ...(thing.system ? [thing.system] : []), + ...(thing.kind && thing.kind !== thing.label ? [thing.kind] : []), + ] + const indent = " ".repeat(depth) + const lines = [`${indent}- ${facts.join(" · ")}`] + if (!input.all) { + const touch = thing.touches[0] + const who = touch ? whoText(touch) : "" + lines[0] += ` · ${historyText(thing)} ${ago(input.now - last.at)}${who ? ` · ${who}` : ""}` + } + if (thing.url) lines.push(`${indent} ${thing.url}`) + if (thing.note) lines.push(`${indent} note: ${thing.note}`) + if (input.all) + for (const touch of thing.touches) { + const title = touch.title ? `"${cut(touch.title, 60)}"` : "untitled" + const marks = [touch.session, ...(touch.deleted ? ["deleted"] : [])].join(", ") + const who = whoText(touch) + lines.push( + `${indent} in ${title} (${marks}) — ${historyText(touch)} ${ago(input.now - (touch.history.at(-1)?.at ?? touch.lastAt))}${who ? ` · ${who}` : ""}${touch.session === input.session ? " — this conversation" : ""}`, + ) + } + return lines +} + +/** The trail as `trail_list` answers it: the same groups and order as `/trail`. */ +export function listText(input: ListInput): string { + const things = input.all ? projectThings(input.state) : conversationThings(input.state, input.session) + const arranged = arrange(things, input.query) + const scope = input.all ? "this project's conversations" : "this conversation" + if (arranged.total === 0) + return input.all + ? "Nothing is in the trail for this project yet. Record what you create or change outside the repository with trail_add." + : "Nothing is in this conversation's trail yet. If it created or changed something outside the repository — a PR, a ticket, a page — record it with trail_add. trail_list with all: true looks across the project's other conversations." + if (arranged.shown === 0) + return `Nothing in ${scope} matches "${input.query}" (searched title, ref, kind and system). ${input.all ? "Try fewer words." : "Try all: true for every conversation, or fewer words."}` + const count = input.query + ? `${arranged.shown} of ${arranged.total} things` + : `${arranged.total} ${arranged.total === 1 ? "thing" : "things"}` + const order = arranged.shown > 1 ? ", grouped by what they are for, newest work first" : "" + const head = `${count} in ${scope}${input.query ? ` matching "${input.query}"` : ""}${order}:` + return [head, ...bodyLines(arranged, input)].join("\n") +} + +function bodyLines(arranged: Arranged, input: ListInput): string[] { + const out: string[] = [] + for (const line of linesOf(arranged)) + if (line.kind === "head") out.push(`- ${line.name} (not recorded itself)`) + else out.push(...thingLine(line.thing, line.depth, input)) + return out +} + +/* ─── the lines added to every request ───────────────────────────────────────────────────────── */ + +/** How many things the "produced" line names before `+ N more`. */ +export const PRODUCED_LIMIT = 6 + +/** + * What this conversation produced, in one line, rebuilt from the trail on every request — so it + * survives compaction (a summary once said trail_add was never called when it had been). Nothing + * when nothing is recorded: the guidance already says what to do. + */ +export function producedLine(state: State, session: string): string | undefined { + const lines = linesOf(arrange(conversationThings(state, session))).flatMap((line) => + line.kind === "thing" ? [line.thing] : [], + ) + if (lines.length === 0) return undefined + const shown = lines.slice(0, PRODUCED_LIMIT).map((thing) => `${named(thing, 50)} (${historyText(thing)})`) + const more = lines.length - shown.length + return `Trail — this conversation produced: ${shown.join("; ")}${more > 0 ? `; + ${more} more (trail_list)` : ""}.` +} + +/** How many links the reminder names at most. */ +export const SEEN_LIMIT = 5 + +/** + * The safety net's reminder: PR and issue links in the output of something the agent ran, not yet + * recorded. Worded as a choice — both models declined to record a link they had only printed, and + * that is right; the trail is what the conversation made. + */ +export function seenLine(found: readonly Found[]): string | undefined { + if (found.length === 0) return undefined + const urls = found.slice(0, SEEN_LIMIT).map((each) => each.url) + const more = found.length - urls.length + return `Seen in output — record it with tools.trail_add if you created or changed it: ${urls.join(", ")}${more > 0 ? ` (+ ${more} more)` : ""}` +} + +/* ─── copy as markdown ───────────────────────────────────────────────────────────────────────── */ + +const escapeMd = (text: string) => text.replace(/([\\[\]])/g, "\\$1") + +function markdownItem(thing: Thing, depth: number): string { + const name = thing.label ? `${thing.label} — ${thing.title}` : thing.title + const link = + thing.openable && thing.url ? `[${escapeMd(name)}](${thing.url.replace(/\)/g, "%29")})` : escapeMd(name) + const facts = [...(thing.system ? [thing.system] : []), historyText(thing)] + return `${" ".repeat(depth)}- ${link} · ${facts.join(" · ")}` +} + +/** A trail as a markdown list — for a PR description, a standup, a ticket comment. */ +export function markdownOf(arranged: Arranged): string { + const out: string[] = [] + for (const line of linesOf(arranged)) + out.push(line.kind === "head" ? `- **${escapeMd(line.name)}**` : markdownItem(line.thing, line.depth)) + return out.join("\n") +} diff --git a/packages/trail/src/core/tools.ts b/packages/trail/src/core/tools.ts new file mode 100644 index 00000000..5abec596 --- /dev/null +++ b/packages/trail/src/core/tools.ts @@ -0,0 +1,47 @@ +/** + * The two agent tools as plain functions of the trail, so either OpenCode's adapter is only wiring: + * read the file, call one of these, append what it returns. + */ + +import { checkAdd, checkList, recordedEvent, type Who } from "./add.ts" +import { conversationThings, type Thing } from "./model.ts" +import { apply, type Event, matchRecord, type State } from "./store.ts" +import { addedText, listText } from "./text.ts" + +export type AddOutcome = + | { ok: false; text: string } + /** `event` is what to append; `state` already holds it. */ + | { ok: true; text: string; event: Event; thing: Thing } + +/** `trail_add`: checked, folded into `state`, and answered. Nothing is written here. */ +export function runAdd(state: State, args: unknown, who: Who): AddOutcome { + const checked = checkAdd(args) + if (!checked.ok) return { ok: false, text: checked.error } + const merged = matchRecord(state, who.rootSession, checked.fields.url, checked.fields.ref) !== undefined + const event = recordedEvent(checked.fields, who, state) + apply(state, event) + const record = matchRecord(state, who.rootSession, event.url, event.ref) + const things = conversationThings(state, who.rootSession) + const thing = things.find((each) => each.key === record?.key) as Thing + const target = event.for + const forMissing = + target !== undefined && + matchRecord(state, who.rootSession, /^https?:/.test(target) ? target : undefined, target) === undefined + return { + ok: true, + event, + thing, + text: addedText({ + thing, + merged, + count: things.length, + stripped: checked.stripped, + ...(forMissing && target ? { forMissing: target } : {}), + }), + } +} + +/** `trail_list`: the trail as text, the same order as `/trail`. */ +export function runList(state: State, args: unknown, session: string, now: number): string { + return listText({ state, session, now, ...checkList(args) }) +} diff --git a/packages/trail/test/add.test.ts b/packages/trail/test/add.test.ts new file mode 100644 index 00000000..a55e3d9b --- /dev/null +++ b/packages/trail/test/add.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, test } from "bun:test" +import { checkAdd, checkList, LIMITS, recordedEvent } from "../src/core/add.ts" +import { apply, emptyState } from "../src/core/store.ts" + +const who = { session: "ses_1", rootSession: "ses_1", by: "agent" as const, at: 1000 } + +const refused = (args: unknown) => { + const checked = checkAdd(args) + if (checked.ok) throw new Error("expected a refusal") + return checked.error +} + +describe("trail_add's arguments", () => { + test("a title and a url is enough", () => { + expect(checkAdd({ title: "Release notes", url: "https://acme.atlassian.net/wiki/x/1" })).toEqual({ + ok: true, + fields: { title: "Release notes", url: "https://acme.atlassian.net/wiki/x/1" }, + stripped: [], + }) + }) + + test("a title and a ref is enough", () => { + expect(checkAdd({ title: "Bump", ref: "a1b2c3d" }).ok).toBe(true) + }) + + test("no title: refused, saying what a title is and that nothing was recorded", () => { + const error = refused({ url: "https://github.com/a/b/pull/1" }) + expect(error).toContain("needs a `title`") + expect(error).toContain("Nothing was recorded") + expect(error).toContain("Call it again") + expect(refused({ title: " ", url: "https://x.dev" })).toContain("needs a `title`") + }) + + test("neither url nor ref: refused, with what each is", () => { + const error = refused({ title: "Something" }) + expect(error).toContain("`url`") + expect(error).toContain("`ref`") + expect(error).toContain("Nothing was recorded") + }) + + test("a url that is not a web link: refused, pointing at ref instead", () => { + const error = refused({ title: "Notes", url: "file:///tmp/notes.md" }) + expect(error).toContain("http(s)") + expect(error).toContain("pass `ref`") + expect(refused({ title: "x", url: "javascript:alert(1)" })).toContain("http(s)") + }) + + test("not text, or not an object: refused, saying so", () => { + expect(refused({ title: ["a", "b"], ref: "x" })).toContain("`title` must be text, got a list") + expect(refused({ title: "a", ref: { id: 1 } })).toContain("`ref` must be text, got object") + expect(refused("PR #33")).toContain("takes an object") + expect(refused(null)).toContain("takes an object") + }) + + test("a link without its scheme is taken as https", () => { + const checked = checkAdd({ title: "PR", url: "github.com/a/b/pull/4" }) + expect(checked.ok && checked.fields.url).toBe("https://github.com/a/b/pull/4") + }) + + test("a PR link names itself when no ref is given; a given ref wins", () => { + const derived = checkAdd({ title: "PR", url: "https://github.com/a/b/pull/4" }) + expect(derived.ok && derived.fields.ref).toBe("a/b#4") + const given = checkAdd({ title: "PR", url: "https://github.com/a/b/pull/4", ref: "WEB-4" }) + expect(given.ok && given.fields.ref).toBe("WEB-4") + }) + + test("secrets are stripped from the link, and named", () => { + const checked = checkAdd({ title: "Report", url: "https://r.acme.dev/x?token=abc&tab=2" }) + expect(checked).toEqual({ + ok: true, + fields: { title: "Report", url: "https://r.acme.dev/x?tab=2" }, + stripped: ["token"], + }) + }) + + test("free text is kept on one line, and cut when too long", () => { + const checked = checkAdd({ + title: "Two\nlines", + ref: 33, + note: "n".repeat(LIMITS.note + 50), + kind: " ", + for: null, + }) + if (!checked.ok) throw new Error(checked.error) + expect(checked.fields.title).toBe("Two lines") + expect(checked.fields.ref).toBe("33") + expect(checked.fields.note?.length).toBe(LIMITS.note) + expect(checked.fields.note?.endsWith("…")).toBe(true) + expect(checked.fields).not.toHaveProperty("kind") + expect(checked.fields).not.toHaveProperty("for") + }) +}) + +describe("the event a call becomes", () => { + test("unsaid, the action is created for something new and updated for something known", () => { + const state = emptyState() + const fields = { title: "PR", url: "https://github.com/a/b/pull/4" } + const first = recordedEvent(fields, { ...who, id: "e1" }, state) + expect(first.action).toBe("created") + apply(state, first) + expect(recordedEvent(fields, { ...who, id: "e2" }, state).action).toBe("updated") + expect(recordedEvent({ ...fields, action: "merged" }, { ...who, id: "e3" }, state).action).toBe("merged") + }) + + test("carries who made it and in which conversation", () => { + const event = recordedEvent( + { title: "Doc", ref: "D-1" }, + { + session: "ses_child", + rootSession: "ses_root", + sessionTitle: "Plan", + by: "agent", + subagent: "docs", + at: 5, + }, + emptyState(), + ) + expect(event).toMatchObject({ + type: "recorded", + session: "ses_child", + rootSession: "ses_root", + sessionTitle: "Plan", + subagent: "docs", + by: "agent", + at: 5, + }) + expect(event.id).toMatch(/^tr_/) + }) +}) + +describe("trail_list's arguments", () => { + test("both optional, and forgiving", () => { + expect(checkList(undefined)).toEqual({ all: false, query: "" }) + expect(checkList({ all: true, query: " COM-1736 " })).toEqual({ all: true, query: "COM-1736" }) + expect(checkList({ all: "true" }).all).toBe(true) + expect(checkList({ all: "no", query: 4 })).toEqual({ all: false, query: "" }) + }) +}) diff --git a/packages/trail/test/journal.test.ts b/packages/trail/test/journal.test.ts new file mode 100644 index 00000000..8508d7b6 --- /dev/null +++ b/packages/trail/test/journal.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "bun:test" +import { appendFileSync, mkdtempSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { createJournal } from "../src/core/journal.ts" +import { trailPaths } from "../src/core/paths.ts" +import { type Event, emptyState, serialize } from "../src/core/store.ts" + +const paths = () => trailPaths("/work/app", { COCKPIT_HOME: mkdtempSync(join(tmpdir(), "trail-journal-")) }) +const event = (id: string, url = `https://github.com/a/b/pull/${id}`): Event => ({ + v: 1, + at: Number(id) || 1, + id, + type: "recorded", + rootSession: "ses_1", + session: "ses_1", + by: "agent", + title: `PR ${id}`, + url, + action: "created", +}) + +describe("where the trail lives", () => { + test("outside the project, one folder per checkout", () => { + const env = { HOME: "/home/me" } + const a = trailPaths("/home/me/a/web", env) + const b = trailPaths("/home/me/b/web", env) + expect(a.events).toMatch( + /^\/home\/me\/\.local\/share\/opencode-cockpit\/trail\/a-web-[0-9a-f]{8}\/events\.ndjson$/, + ) + expect(a.dir).not.toBe(b.dir) + expect( + trailPaths("/x/y", { XDG_DATA_HOME: "/data" }).dir.startsWith("/data/opencode-cockpit/trail/"), + ).toBe(true) + }) +}) + +describe("the trail file", () => { + test("nothing there yet is nothing to read", async () => { + expect(await createJournal(paths()).read()).toEqual({ events: [], reset: false }) + }) + + test("appends are read back once, in order", async () => { + const journal = createJournal(paths()) + await journal.append([event("1"), event("2")]) + expect((await journal.read()).events).toEqual([event("1"), event("2")]) + expect((await journal.read()).events).toEqual([]) + }) + + test("several writers on one file — windows, or one agent instance per directory — each read all", async () => { + const where = paths() + const one = createJournal(where) + const two = createJournal(where) + const three = createJournal(where) + await Promise.all([one.append([event("1")]), two.append([event("2")]), three.append([event("3")])]) + const state = await one.sync(emptyState()) + expect([...state.records.values()].map((record) => record.title).sort()).toEqual(["PR 1", "PR 2", "PR 3"]) + expect((await two.sync(emptyState())).records.size).toBe(3) + }) + + test("sync folds only what is new, and a line read twice counts once", async () => { + const where = paths() + const journal = createJournal(where) + await journal.append([event("1")]) + let state = await journal.sync(emptyState()) + await createJournal(where).append([{ ...event("2", "https://github.com/a/b/pull/1"), action: "updated" }]) + state = await journal.sync(state) + expect([...state.records.values()][0]?.history.map((step) => step.action)).toEqual(["created", "updated"]) + /** The same line appended again (a retried write) is the same event. */ + appendFileSync(where.events, serialize(event("1"))) + state = await journal.sync(state) + expect([...state.records.values()][0]?.history).toHaveLength(2) + }) + + test("a torn last line waits; the next whole line after it is recovered", async () => { + const where = paths() + const journal = createJournal(where) + await journal.append([event("1")]) + await journal.read() + appendFileSync(where.events, serialize(event("2")).slice(0, 15)) + expect((await journal.read()).events).toEqual([]) + appendFileSync(where.events, serialize(event("3"))) + expect((await journal.read()).events).toEqual([event("3")]) + }) + + test("a replaced file is folded again from its start", async () => { + const where = paths() + const journal = createJournal(where) + await journal.append([event("1"), event("2"), event("3")]) + let state = await journal.sync(emptyState()) + expect(state.records.size).toBe(3) + writeFileSync(where.events, serialize(event("9"))) + state = await journal.sync(state) + expect([...state.records.values()].map((record) => record.title)).toEqual(["PR 9"]) + }) +}) diff --git a/packages/trail/test/links.test.ts b/packages/trail/test/links.test.ts new file mode 100644 index 00000000..7e9eaa00 --- /dev/null +++ b/packages/trail/test/links.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test } from "bun:test" +import { + cleanUrl, + linkKey, + linksIn, + openable, + secretParam, + systemOf, + workIn, + workOf, +} from "../src/core/links.ts" + +describe("the system a link belongs to", () => { + test.each([ + ["https://github.com/Codestz/opencode-cockpit/pull/33", "GitHub"], + ["https://acme.atlassian.net/wiki/spaces/ENG/pages/1", "Confluence"], + ["https://acme.atlassian.net/browse/COM-1736", "Jira"], + ["https://claude.ai/public/artifacts/9f2c", "Claude"], + ["https://linear.app/acme/issue/ENG-42/retry", "Linear"], + ["https://gitlab.com/group/project/-/merge_requests/12", "GitLab"], + ["https://www.notion.so/acme/Plan-123", "notion.so"], + ["https://status.acme.dev/incidents/88", "status.acme.dev"], + ])("%s → %s", (url, system) => { + expect(systemOf(url)).toBe(system) + }) + + test("no link, or not a web link, has no system", () => { + expect(systemOf(undefined)).toBeUndefined() + expect(systemOf("file:///tmp/notes.md")).toBeUndefined() + expect(systemOf("COM-1736")).toBeUndefined() + }) + + test("the table is extensible: a row added is read", () => { + expect(systemOf("https://ci.acme.dev/job/1", [{ host: "ci.acme.dev", name: "Jenkins" }])).toBe("Jenkins") + }) +}) + +describe("safe links", () => { + test("only http(s) opens", () => { + expect(openable("https://github.com/x")).toBe(true) + expect(openable("http://localhost:3000")).toBe(true) + for (const url of ["javascript:alert(1)", "file:///etc/passwd", "ssh://host", "", undefined]) + expect(openable(url)).toBe(false) + }) + + test("secret-looking query parameters and credentials are stripped; the rest stays", () => { + const { url, stripped } = cleanUrl( + new URL( + "https://user:pw@s3.amazonaws.com/b/k?X-Amz-Signature=abc&X-Amz-Credential=c&versionId=3&token=t&sig=s&view=full", + ), + ) + expect(url).toBe("https://s3.amazonaws.com/b/k?versionId=3&view=full") + expect(stripped).toEqual(["credentials", "X-Amz-Signature", "X-Amz-Credential", "token", "sig"]) + }) + + test.each([ + "token", + "access_token", + "api_key", + "key", + "signature", + "client_secret", + "X-Goog-Signature", + "githubToken", + "private_token", + ])("%s is a secret", (name) => { + expect(secretParam(name)).toBe(true) + }) + + test.each(["view", "page", "tab", "q", "versionId", "monkey"])("%s is not", (name) => { + expect(secretParam(name)).toBe(false) + }) +}) + +describe("PRs and issues, from their links", () => { + test("GitHub pull and issue", () => { + expect(workOf("https://github.com/Codestz/opencode-cockpit/pull/33/files")).toEqual({ + url: "https://github.com/Codestz/opencode-cockpit/pull/33", + ref: "Codestz/opencode-cockpit#33", + label: "PR #33", + }) + expect(workOf("https://github.com/acme/web/issues/12#issuecomment-1")?.label).toBe("issue #12") + }) + + test("GitLab merge requests and issues, on any host", () => { + expect(workOf("https://git.acme.dev/group/sub/project/-/merge_requests/7")).toEqual({ + url: "https://git.acme.dev/group/sub/project/-/merge_requests/7", + ref: "group/sub/project!7", + label: "MR !7", + }) + expect(workOf("https://gitlab.com/g/p/-/issues/3")?.ref).toBe("g/p#3") + }) + + test("Jira browse and Linear issues", () => { + expect(workOf("https://acme.atlassian.net/browse/COM-1736?focusedCommentId=1")).toEqual({ + url: "https://acme.atlassian.net/browse/COM-1736", + ref: "COM-1736", + label: "COM-1736", + }) + expect(workOf("https://linear.app/acme/issue/ENG-42/retry-the-socket")?.ref).toBe("ENG-42") + }) + + test("anything else is not a work item", () => { + for (const url of [ + "https://github.com/acme/web", + "https://github.com/acme/web/pull/new/branch", + "https://acme.atlassian.net/wiki/x/1", + "https://example.com/pull/3", + ]) + expect(workOf(url)).toBeUndefined() + }) + + test("found in output, prose punctuation and repeats left out", () => { + const text = + "Created https://github.com/a/b/pull/1.\nSee (https://github.com/a/b/pull/1/files) and https://acme.atlassian.net/browse/X-9, https://example.com." + expect(workIn(text).map((work) => work.url)).toEqual([ + "https://github.com/a/b/pull/1", + "https://acme.atlassian.net/browse/X-9", + ]) + expect(linksIn(text)).toContain("https://example.com") + }) + + test("two links to one thing compare equal", () => { + expect(linkKey("https://github.com/a/b/pull/1/files")).toBe(linkKey("https://GitHub.com/a/b/pull/1")) + expect(linkKey("https://Docs.acme.dev/page/")).toBe(linkKey("https://docs.acme.dev/page#top")) + expect(linkKey("https://docs.acme.dev/page?id=1")).not.toBe(linkKey("https://docs.acme.dev/page?id=2")) + }) +}) diff --git a/packages/trail/test/model.test.ts b/packages/trail/test/model.test.ts new file mode 100644 index 00000000..cd26927e --- /dev/null +++ b/packages/trail/test/model.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, test } from "bun:test" +import { + arrange, + conversationThings, + foundIn, + type Line, + linesOf, + matches, + notRecorded, + projectThings, +} from "../src/core/model.ts" +import { SAMPLE_NOW, SAMPLE_SESSION, SAMPLES } from "../src/core/sample.ts" +import { emptyState, type State } from "../src/core/store.ts" +import { runAdd } from "../src/core/tools.ts" + +let at = 0 +function trail(adds: { [key: string]: string }[], session = "s1", state: State = emptyState()): State { + for (const args of adds) { + const out = runAdd(state, args, { session, rootSession: session, by: "agent", at: ++at * 60_000 }) + if (!out.ok) throw new Error(out.text) + } + return state +} + +const names = (lines: Line[]) => + lines.map((line) => + line.kind === "head" + ? `# ${line.name}` + : `${" ".repeat(line.depth)}${line.thing.label ?? line.thing.title}`, + ) + +describe("order: by the work, not by the tool", () => { + test("a ticket heads its PRs, newest first inside; groups come before things that stand alone", () => { + const state = trail([ + { title: "Deploy", ref: "deploy-1", kind: "deploy" }, + { title: "Bundle desync", url: "https://acme.atlassian.net/browse/COM-1736" }, + { title: "Older PR", url: "https://github.com/a/b/pull/12", for: "COM-1736" }, + { title: "Newer PR", url: "https://github.com/a/b/pull/33", for: "COM-1736" }, + { title: "Page", url: "https://acme.atlassian.net/wiki/x/1" }, + ]) + expect(names(linesOf(arrange(conversationThings(state, "s1"))))).toEqual([ + "COM-1736", + " PR #33", + " PR #12", + "Page", + "deploy-1", + ]) + }) + + test("`for` may be the ticket's link, and a ticket nothing records heads its group by name", () => { + const state = trail([ + { title: "A", url: "https://github.com/a/b/pull/1", for: "https://acme.atlassian.net/browse/COM-9" }, + { title: "B", ref: "abc123", kind: "commit", for: "com-9" }, + ]) + const lines = linesOf(arrange(conversationThings(state, "s1"))) + expect(names(lines)).toEqual(["# COM-9", " abc123", " PR #1"]) + }) + + test("a chain is one group under its top; a loop draws every thing once", () => { + const chain = trail([ + { title: "Epic", ref: "EPIC-1" }, + { title: "Story", ref: "ST-1", for: "EPIC-1" }, + { title: "PR", url: "https://github.com/a/b/pull/2", for: "ST-1" }, + ]) + expect(names(linesOf(arrange(conversationThings(chain, "s1"))))).toEqual(["EPIC-1", " PR #2", " ST-1"]) + const loop = trail([ + { title: "One", ref: "L-1", for: "L-2" }, + { title: "Two", ref: "L-2", for: "L-1" }, + ]) + expect(names(linesOf(arrange(conversationThings(loop, "s1"))))).toEqual(["L-1", " L-2"]) + }) + + test("the busy sample, as the design sketches it", () => { + const { state, session } = (SAMPLES.busy as () => { state: State; session: string })() + const lines = names(linesOf(arrange(conversationThings(state, session)))) + expect(lines.slice(0, 6)).toEqual([ + "# COM-1801", + " a1b2c3d", + " ENG-42", + "COM-1736", + " PR #33", + " PR #12", + ]) + }) +}) + +describe("search", () => { + const state = trail([ + { title: "Release notes for 0.8", url: "https://acme.atlassian.net/wiki/x/1", kind: "page" }, + { title: "Fix desync", url: "https://github.com/a/b/pull/33", for: "COM-1736" }, + { title: "Bundle desync", url: "https://acme.atlassian.net/browse/COM-1736" }, + ]) + const things = conversationThings(state, "s1") + const found = (query: string) => things.filter((thing) => matches(thing, query)).map((thing) => thing.title) + + test("over title, ref, kind and system — every word, case-blind", () => { + expect(found("release")).toEqual(["Release notes for 0.8"]) + expect(found("a/b#33")).toEqual(["Fix desync"]) + expect(found("PAGE")).toEqual(["Release notes for 0.8"]) + expect(found("confluence")).toEqual(["Release notes for 0.8"]) + expect(found("desync jira")).toEqual(["Bundle desync"]) + expect(found("")).toHaveLength(3) + }) + + test("a query keeps a group's head as context, and counts only what matched", () => { + const arranged = arrange(things, "github") + expect(names(linesOf(arranged))).toEqual(["COM-1736", " PR #33"]) + expect(arranged.shown).toBe(1) + expect(arranged.total).toBe(3) + }) +}) + +describe("All conversations", () => { + test("one row per link, every conversation that touched it, the latest first", () => { + const state = trail([{ title: "PR", url: "https://github.com/a/b/pull/1" }], "s1") + trail( + [{ title: "PR, reviewed", url: "https://github.com/a/b/pull/1/files", action: "reviewed" }], + "s2", + state, + ) + trail([{ title: "Other", ref: "X-1" }], "s2", state) + const things = projectThings(state) + expect(things).toHaveLength(2) + const pr = things.find((thing) => thing.label === "PR #1") + expect(pr?.touches.map((touch) => touch.session)).toEqual(["s2", "s1"]) + expect(pr?.title).toBe("PR, reviewed") + expect(pr?.history.map((step) => step.action)).toEqual(["created", "reviewed"]) + }) + + test("a deleted conversation's touch is marked, and keeps its title", () => { + const { state } = (SAMPLES.project as () => { state: State })() + const gone = projectThings(state).find((thing) => thing.title === "Old dashboard") + expect(gone?.touches[0]).toMatchObject({ deleted: true, title: "Set up the latency dashboard" }) + }) +}) + +describe("found, not recorded", () => { + test("links in output minus what this conversation recorded, by link or derived ref", () => { + const state = trail([ + { title: "Recorded", url: "https://github.com/a/b/pull/1" }, + { title: "By ref", ref: "a/b#2" }, + ]) + const seen = [ + ...foundIn("https://github.com/a/b/pull/1/files https://github.com/a/b/pull/2", 10), + ...foundIn("Created https://github.com/a/b/pull/3.", 20), + ...foundIn("again https://github.com/a/b/pull/3", 30), + ] + expect(notRecorded(state, "s1", seen).map((each) => [each.url, each.at])).toEqual([ + ["https://github.com/a/b/pull/3", 20], + ]) + expect(notRecorded(state, "s9", seen)).toHaveLength(3) + }) + + test("a find knows how to be named and where it lives", () => { + expect(foundIn("https://acme.atlassian.net/browse/COM-9", SAMPLE_NOW)).toEqual([ + { + url: "https://acme.atlassian.net/browse/COM-9", + ref: "COM-9", + label: "COM-9", + system: "Jira", + at: SAMPLE_NOW, + }, + ]) + expect(SAMPLE_SESSION).toBe("ses_main") + }) +}) diff --git a/packages/trail/test/store.test.ts b/packages/trail/test/store.test.ts new file mode 100644 index 00000000..3237a1a3 --- /dev/null +++ b/packages/trail/test/store.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, test } from "bun:test" +import { + applyAll, + type Event, + emptyState, + historyText, + parseLines, + type State, + serialize, + vanished, +} from "../src/core/store.ts" + +let n = 0 +function recorded(fields: Partial>, at = ++n * 1000): Event { + return { + v: 1, + at, + id: `e${++n}`, + type: "recorded", + rootSession: "ses_1", + session: "ses_1", + sessionTitle: "Fix the desync", + by: "agent", + title: "Thing", + action: "created", + ...fields, + } as Event +} + +const records = (state: State) => [...state.records.values()] +const PR = "https://github.com/a/b/pull/1" + +describe("one thing, one record", () => { + test("the same url again is the same record, its actions a history", () => { + const state = applyAll(emptyState(), [ + recorded({ url: PR, title: "Draft" }), + recorded({ url: `${PR}/files`, title: "Fix the desync", action: "updated" }), + recorded({ url: PR, title: "Fix the desync", action: "updated" }), + ]) + expect(records(state)).toHaveLength(1) + const [record] = records(state) + expect(record?.title).toBe("Fix the desync") + expect(record?.history.map((step) => step.action)).toEqual(["created", "updated", "updated"]) + expect(historyText(record as NonNullable)).toBe("created → updated") + }) + + test("with no url, the same ref is the same record, case-blind", () => { + const state = applyAll(emptyState(), [ + recorded({ ref: "COM-1736", title: "Bundle desync" }), + recorded({ ref: "com-1736", url: "https://acme.atlassian.net/browse/COM-1736", action: "updated" }), + ]) + expect(records(state)).toHaveLength(1) + expect(records(state)[0]?.url).toBe("https://acme.atlassian.net/browse/COM-1736") + }) + + test("what a later mention leaves out is kept", () => { + const state = applyAll(emptyState(), [ + recorded({ url: PR, kind: "pull request", for: "COM-1", note: "first" }), + recorded({ url: PR, title: "Better", action: "merged" }), + ]) + expect(records(state)[0]).toMatchObject({ + title: "Better", + kind: "pull request", + for: "COM-1", + note: "first", + }) + }) + + test("two conversations keep a record each", () => { + const state = applyAll(emptyState(), [ + recorded({ url: PR }), + recorded({ url: PR, rootSession: "ses_2", session: "ses_2", sessionTitle: "Review" }), + ]) + expect(records(state).map((record) => record.session)).toEqual(["ses_1", "ses_2"]) + expect(state.conversations.get("ses_2")?.title).toBe("Review") + }) + + test("a subagent's record belongs to the conversation and remembers the subagent", () => { + const state = applyAll(emptyState(), [recorded({ url: PR, session: "ses_child", subagent: "explore" })]) + expect(records(state)[0]).toMatchObject({ session: "ses_1", subagent: "explore" }) + }) + + test("an event read twice is applied once", () => { + const event = recorded({ url: PR }) + const state = applyAll(emptyState(), [event, event, recorded({ url: PR, action: "updated" })]) + expect(records(state)[0]?.history).toHaveLength(2) + }) + + test("removed takes the record out; recording it again starts a new one", () => { + const first = recorded({ url: PR }) + const state = applyAll(emptyState(), [ + first, + { v: 1, at: 99_000, id: "rm", type: "removed", rootSession: "ses_1", record: first.id }, + ]) + expect(records(state)).toHaveLength(0) + applyAll(state, [recorded({ url: PR, action: "updated" })]) + expect(records(state)[0]?.history.map((step) => step.action)).toEqual(["updated"]) + }) +}) + +describe("deleted conversations", () => { + test("keep their records, marked, and their title", () => { + const state = applyAll(emptyState(), [ + recorded({ url: PR, rootSession: "ses_gone", session: "ses_gone", sessionTitle: "Old work" }), + { v: 1, at: 500_000, id: "del", type: "deleted", rootSession: "ses_gone" }, + ]) + expect(records(state)).toHaveLength(1) + expect(state.conversations.get("ses_gone")).toMatchObject({ title: "Old work", deletedAt: 500_000 }) + }) + + test("a conversation with records the host no longer lists has vanished", () => { + const state = applyAll(emptyState(), [ + recorded({ url: PR }), + recorded({ url: PR, rootSession: "ses_2", session: "ses_2" }), + ]) + expect(vanished(state, new Set(["ses_1"])).map((each) => each.session)).toEqual(["ses_2"]) + applyAll(state, [{ v: 1, at: 1, id: "d2", type: "deleted", rootSession: "ses_2" }]) + expect(vanished(state, new Set(["ses_1"]))).toEqual([]) + }) +}) + +describe("the file", () => { + test("a line is one event, whatever a title holds", () => { + const event = recorded({ url: PR, title: 'Line\nbreak "quoted"' }) + expect(serialize(event).split("\n")).toHaveLength(2) + expect(parseLines(serialize(event)).events).toEqual([event]) + }) + + test("a line cut short by a crash is skipped, and the event appended onto it recovered", () => { + const a = recorded({ url: PR }) + const b = recorded({ ref: "X-1" }) + const torn = serialize(a).slice(0, 25) + serialize(b) + expect(parseLines(torn).events).toEqual([b]) + }) + + test("what follows the last newline is left for the next read", () => { + const a = recorded({ url: PR }) + const { events, rest } = parseLines(`${serialize(a)}{"v":1,"at"`) + expect(events).toEqual([a]) + expect(rest).toBe('{"v":1,"at"') + }) + + test("an event that is not one of ours is ignored", () => { + const lines = [ + JSON.stringify({ v: 2, at: 1, id: "x", rootSession: "s", type: "recorded" }), + JSON.stringify({ + v: 1, + at: 1, + id: "x", + rootSession: "s", + type: "recorded", + session: "s", + title: "t", + action: "a", + by: "agent", + }), + JSON.stringify({ v: 1, at: 1, id: "x", rootSession: "s", type: "moved" }), + "not json", + "", + ].join("\n") + expect(parseLines(`${lines}\n`).events).toEqual([]) + }) +}) diff --git a/packages/trail/test/text.test.ts b/packages/trail/test/text.test.ts new file mode 100644 index 00000000..9610d4cf --- /dev/null +++ b/packages/trail/test/text.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, test } from "bun:test" +import { arrange, conversationThings, foundIn, notRecorded, projectThings } from "../src/core/model.ts" +import { SAMPLE_NOW, SAMPLES, type Sample } from "../src/core/sample.ts" +import { emptyState } from "../src/core/store.ts" +import { + ADD_DESCRIPTION, + GUIDANCE, + LIST_DESCRIPTION, + listText, + markdownOf, + PRODUCED_LIMIT, + producedLine, + seenLine, +} from "../src/core/text.ts" +import { runAdd, runList } from "../src/core/tools.ts" +import { dialogRows } from "../src/core/view/dialog.ts" + +const sample = (name: string) => (SAMPLES[name] as () => Sample)() +const who = { session: "s1", rootSession: "s1", by: "agent" as const, at: SAMPLE_NOW } + +describe("what the model is told", () => { + test("the tool says when to call it within what OpenCode 2's catalog shows (~115 characters)", () => { + const shown = ADD_DESCRIPTION.slice(0, 112) + expect(shown).toContain("right after you create or change something outside this repo") + expect(shown).toContain("PR") + expect(shown).toContain("ticket") + expect(LIST_DESCRIPTION.slice(0, 112)).toContain("trail_add") + }) + + test("the guidance names tools.trail_add and carries two examples", () => { + expect(GUIDANCE.startsWith("## Trail")).toBe(true) + expect(GUIDANCE).toContain("tools.trail_add") + expect(GUIDANCE.match(/tools\.trail_add\(\{ title: /g)).toHaveLength(2) + expect(GUIDANCE).toContain("Not for files in this repository") + }) +}) + +describe("trail_add answers", () => { + test("a new record: what it is, what was done, how many now", () => { + const state = emptyState() + const out = runAdd(state, { title: "Fix desync", url: "https://github.com/a/b/pull/1" }, who) + expect(out.ok).toBe(true) + expect(out.text).toContain('Recorded PR #1 "Fix desync" (GitHub) — created.') + expect(out.text).toContain("holds 1 thing") + }) + + test("the same url again: the existing record, its history, and that no row was added", () => { + const state = emptyState() + runAdd(state, { title: "Draft", url: "https://github.com/a/b/pull/1" }, who) + const out = runAdd( + state, + { title: "Fix desync", url: "https://github.com/a/b/pull/1" }, + { ...who, at: who.at + 1 }, + ) + expect(out.text).toContain( + 'Updated the existing record of PR #1 "Fix desync" (GitHub): created → updated.', + ) + expect(out.text).toContain("never makes a second row") + expect(out.text).toContain("holds 1 thing") + }) + + test("a stripped secret and a `for` not in the trail are both said", () => { + const out = runAdd( + emptyState(), + { title: "Report", url: "https://r.acme.dev/x?token=abc", for: "COM-1" }, + who, + ) + expect(out.text).toContain("looked like a secret: token") + expect(out.text).toContain('"COM-1" is not in this conversation\'s trail itself') + if (out.ok) expect(out.event).toMatchObject({ type: "recorded", url: "https://r.acme.dev/x" }) + }) + + test("a refusal writes nothing and says how to recover", () => { + const state = emptyState() + const out = runAdd(state, { url: "https://github.com/a/b/pull/1" }, who) + expect(out.ok).toBe(false) + expect(out.text).toContain("Nothing was recorded") + expect(state.records.size).toBe(0) + }) +}) + +describe("trail_list answers", () => { + test("empty: says so and what to do", () => { + expect(runList(emptyState(), {}, "s1", SAMPLE_NOW)).toContain("record it with trail_add") + expect(runList(emptyState(), { all: true }, "s1", SAMPLE_NOW)).toContain( + "Nothing is in the trail for this project", + ) + }) + + test("the same facts, in the same order, as the dialog draws (one truth)", () => { + const { state, session, found } = sample("busy") + const text = listText({ state, session, all: false, query: "", now: SAMPLE_NOW }) + const view = dialogRows({ + width: 200, + height: 60, + tab: "this", + state, + session, + found, + now: SAMPLE_NOW, + project: "p", + }) + const dialogOrder = view.items.flatMap((item) => (item.kind === "thing" ? [item.thing.title] : [])) + const listOrder = [...text.matchAll(/^\s*- (?:[^"\n]* )?"([^"]+)"/gm)].map((match) => match[1]) + expect(listOrder).toEqual(dialogOrder) + expect(text).toContain("9 things in this conversation, grouped by what they are for, newest work first:") + expect(text).toContain("- COM-1801 (not recorded itself)") + expect(text).toContain("created → updated 1h ago") + expect(text).toContain("by subagent docs") + expect(text).toContain("added by you") + expect(text).toContain("note: behind the flag") + }) + + test("a query that finds nothing says what was searched and what to try", () => { + const { state, session } = sample("busy") + const text = runList(state, { query: "kubernetes" }, session, SAMPLE_NOW) + expect(text).toContain('Nothing in this conversation matches "kubernetes"') + expect(text).toContain("all: true") + expect(runList(state, { query: "jira" }, session, SAMPLE_NOW)).toMatch( + /^1 of 9 things in this conversation/, + ) + }) + + test("all: every conversation that touched a thing, with its session id, a deleted one marked", () => { + const { state, session } = sample("project") + const text = runList(state, { all: true }, session, SAMPLE_NOW) + expect(text).toContain('in "Review the 0.8 branch" (ses_review) — reviewed 20m ago') + expect(text).toContain( + 'in "Fix the bundle desync" (ses_main) — created → updated 1h ago — this conversation', + ) + expect(text).toContain('in "Set up the latency dashboard" (ses_gone, deleted)') + }) +}) + +describe("the lines on every request", () => { + test("produced: nothing when nothing is recorded; else the things, capped, pointing at trail_list", () => { + expect(producedLine(emptyState(), "s1")).toBeUndefined() + const { state, session } = sample("busy") + const line = producedLine(state, session) as string + expect(line.startsWith("Trail — this conversation produced: ")).toBe(true) + expect(line).toContain('PR #33 "0.8: Trust, one design system, and the sidebar or…" (created → updated)') + expect(line).toContain(`+ ${9 - PRODUCED_LIMIT} more (trail_list)`) + }) + + test("seen: worded as a choice, recorded links left out", () => { + const { state, session, found } = sample("busy") + const line = seenLine(notRecorded(state, session, found)) as string + expect(line).toBe( + "Seen in output — record it with tools.trail_add if you created or changed it: https://github.com/acme/web/pull/40, https://acme.atlassian.net/browse/COM-1800", + ) + expect(line).not.toContain("pull/33") + expect(seenLine([])).toBeUndefined() + const many = foundIn(Array.from({ length: 8 }, (_, i) => `https://github.com/a/b/pull/${i}`).join(" "), 1) + expect(seenLine(many)).toEndWith("(+ 3 more)") + }) +}) + +describe("copy as markdown", () => { + test("a nested list, links where there are links, titles escaped", () => { + const state = emptyState() + runAdd(state, { title: "Bundle desync", url: "https://acme.atlassian.net/browse/COM-1" }, who) + runAdd( + state, + { title: "Fix [the] desync", url: "https://github.com/a/b/pull/1", for: "COM-1" }, + { ...who, at: who.at + 1 }, + ) + runAdd(state, { title: "Commit", ref: "abc123" }, { ...who, at: who.at + 2 }) + expect(markdownOf(arrange(conversationThings(state, "s1")))).toBe( + [ + "- [COM-1 — Bundle desync](https://acme.atlassian.net/browse/COM-1) · Jira · created", + " - [PR #1 — Fix \\[the\\] desync](https://github.com/a/b/pull/1) · GitHub · created", + "- abc123 — Commit · created", + ].join("\n"), + ) + }) + + test("a group headed by name is bold", () => { + const { state } = sample("busy") + expect(markdownOf(arrange(projectThings(state)))).toStartWith("- **COM-1801**\n - a1b2c3d — Bump") + }) +}) diff --git a/packages/trail/tsconfig.json b/packages/trail/tsconfig.json new file mode 100644 index 00000000..f0a5fd2e --- /dev/null +++ b/packages/trail/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "types", + "lib": ["ESNext", "DOM"] + }, + "include": ["src"], + "references": [ + { + "path": "../client" + } + ] +} diff --git a/tsconfig.json b/tsconfig.json index ffe75b75..709ae0f8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,6 +28,9 @@ { "path": "packages/trust" }, + { + "path": "packages/trail" + }, { "path": "packages/opencode" } From 7c01b5a47a207fb484ed2e428311c94e02e8f17d Mon Sep 17 00:00:00 2001 From: Codestz Date: Sat, 3 Oct 2026 09:16:44 -0500 Subject: [PATCH 06/90] Trail views: the sidebar block, /trail's rows and the preview CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure rows on the design system, drawn by `bun packages/trail/src/cli/preview.ts` for every sample (empty, one, busy, long, a project with a deleted conversation) at any width: the sidebar's heading, none-yet slot, ↗ and + N more; /trail's two tabs, found-not-recorded rows, search, a window that keeps the cursor in view, and the agreed footer with esc last. Co-Authored-By: Claude Opus 5.5 --- packages/trail/src/cli/preview.ts | 172 +++++++++ packages/trail/src/core/view/dialog.ts | 485 ++++++++++++++++++++++++ packages/trail/src/core/view/rows.ts | 184 +++++++++ packages/trail/src/core/view/sidebar.ts | 152 ++++++++ packages/trail/test/view.test.ts | 220 +++++++++++ 5 files changed, 1213 insertions(+) create mode 100644 packages/trail/src/cli/preview.ts create mode 100644 packages/trail/src/core/view/dialog.ts create mode 100644 packages/trail/src/core/view/rows.ts create mode 100644 packages/trail/src/core/view/sidebar.ts create mode 100644 packages/trail/test/view.test.ts diff --git a/packages/trail/src/cli/preview.ts b/packages/trail/src/cli/preview.ts new file mode 100644 index 00000000..8d30b5b1 --- /dev/null +++ b/packages/trail/src/cli/preview.ts @@ -0,0 +1,172 @@ +#!/usr/bin/env bun +/** + * The see-it loop: the sidebar block and `/trail`, drawn in this terminal from sample trails with no + * OpenCode running — and what the agent reads (`trail_list`, the per-request lines, the markdown + * copy). The same rows OpenCode draws; only the colours come from a fixed palette (OpenCode's + * default theme) instead of the user's. + * + * bunx @opencode-cockpit/trail preview every sample + * bunx @opencode-cockpit/trail preview --sample busy one of them + * bunx @opencode-cockpit/trail preview --width 30 the sidebar at another width + * bunx @opencode-cockpit/trail preview --columns 80 the dialog at another width + * bunx @opencode-cockpit/trail preview --rows 14 the dialog in a short window + * bunx @opencode-cockpit/trail preview --text what the agent reads, too + * bunx @opencode-cockpit/trail preview --html > a.html the same, as a page + */ + +import { arrange, conversationThings, notRecorded, projectThings } from "../core/model.ts" +import { SAMPLE_NOW, SAMPLE_PROJECT, SAMPLES } from "../core/sample.ts" +import { listText, markdownOf, producedLine, seenLine } from "../core/text.ts" +import { dialogRows, type Tab } from "../core/view/dialog.ts" +import { type Fill, type Row, type Run, TINTS, type Tone } from "../core/view/rows.ts" +import { sidebarRows } from "../core/view/sidebar.ts" + +/** OpenCode's default theme, measured (docs/opencode/v2.md). The dialog is drawn on the panel colour. */ +const HEX: { [T in Exclude]: string } = { + text: "#eeeeee", + muted: "#808080", + accent: "#9d7cd8", + info: "#56b6c2", + tool: "#fab283", + success: "#7fd88f", + error: "#e06c75", + warning: "#f5a742", + border: "#484848", +} +const PANEL = "#141414" +const ELEMENT = "#1e1e1e" + +const channels = (hex: string) => [1, 3, 5].map((i) => Number.parseInt(hex.slice(i, i + 2), 16)) +const toHex = (values: number[]) => + `#${values.map((v) => Math.round(v).toString(16).padStart(2, "0")).join("")}` +const mix = (tone: string, amount: number) => { + const a = channels(tone) + const b = channels(PANEL) + return toHex(a.map((value, i) => (b[i] as number) + (value - (b[i] as number)) * amount)) +} +const tint = (name: keyof typeof TINTS) => mix(HEX[TINTS[name].tone as keyof typeof HEX], TINTS[name].amount) +const FILL: { [F in Exclude]: string } = { + selected: ELEMENT, + panel: ELEMENT, + button: ELEMENT, + buttonOn: HEX.accent, + chip: tint("chip"), + ok: tint("ok"), + warn: tint("warn"), + err: tint("err"), +} +const ink = (tone: Tone | undefined) => (tone === "ink" ? PANEL : HEX[tone ?? "text"]) + +const rgb = (hex: string) => channels(hex).join(";") +const args = process.argv.slice(2) +const html = args.includes("--html") +const color = html || (process.stdout.isTTY && !process.env.NO_COLOR) +const entities = (text: string) => text.replace(/&/g, "&").replace(//g, ">") + +function paint(row: Row): string { + if (!color) return row.map((run) => run.text).join("") + if (html) + return row + .map((run: Run) => { + const style = [`color:${ink(run.tone)}`] + if (run.fill && run.fill !== "none") style.push(`background:${FILL[run.fill]}`) + if (run.bold) style.push("font-weight:bold") + if (run.faint) style.push("opacity:.55") + return `${entities(run.text)}` + }) + .join("") + return row + .map((run: Run) => { + const codes = [`38;2;${rgb(ink(run.tone))}`] + if (run.fill && run.fill !== "none") codes.push(`48;2;${rgb(FILL[run.fill])}`) + if (run.bold) codes.push("1") + if (run.faint) codes.push("2") + return `\x1b[${codes.join(";")}m${run.text}\x1b[0m` + }) + .join("") +} + +if (args.includes("--help") || args.includes("-h")) { + process.stdout.write( + `Usage: trail preview [--sample ${Object.keys(SAMPLES).join("|")}] [--width ] [--columns ] [--rows ] [--text] [--html]\n`, + ) + process.exit(0) +} +const value = (flag: string) => { + const at = args.indexOf(flag) + return at >= 0 ? args[at + 1] : undefined +} +const sidebarWidth = Number(value("--width")) || 42 +const columns = Number(value("--columns")) || Math.max(60, Math.min(process.stdout.columns || 100, 116)) +const height = Number(value("--rows")) || 20 +const only = value("--sample") +const text = args.includes("--text") +const names = only ? [only] : Object.keys(SAMPLES) + +const frame = (rows: Row[]) => { + const edge = html + ? `│` + : color + ? `\x1b[38;2;${rgb(HEX.border)}m│\x1b[0m` + : "│" + return rows.map((row) => `${edge}${paint(row)}${edge}`) +} + +const out: string[] = [] +for (const name of names) { + const make = SAMPLES[name] + if (!make) { + process.stderr.write(`No sample "${name}". Try: ${Object.keys(SAMPLES).join(", ")}\n`) + process.exit(1) + } + const { state, session, found } = make() + out.push("", `── ${name} ──`) + const mine = arrange(conversationThings(state, session)) + out.push("", `Sidebar (${sidebarWidth} columns)`, "") + out.push(...frame(sidebarRows({ width: sidebarWidth, arranged: mine, now: SAMPLE_NOW, limit: 6 }).rows)) + + const dialog = ( + title: string, + tab: Tab, + extra: { selected?: string; query?: string; searching?: boolean } = {}, + ) => { + const view = dialogRows({ + width: columns, + height, + tab, + state, + session, + found, + now: SAMPLE_NOW, + project: SAMPLE_PROJECT, + ...extra, + }) + out.push("", title, "") + out.push(...frame(view.rows)) + return view + } + const first = dialog("/trail — This conversation", "this") + const remaining = first.items.find((item) => item.kind === "found") + if (remaining) dialog("/trail — on a find", "this", { selected: remaining.key }) + dialog("/trail — All conversations", "all") + if (mine.total > 1) dialog("/trail — searching", "this", { query: "github", searching: true }) + + if (text) { + out.push("", "trail_list", "") + out.push(listText({ state, session, all: false, query: "", now: SAMPLE_NOW })) + out.push("", "trail_list all", "") + out.push(listText({ state, session, all: true, query: "", now: SAMPLE_NOW })) + out.push("", "Per-request lines", "") + out.push(producedLine(state, session) ?? "(none)") + out.push(seenLine(notRecorded(state, session, found)) ?? "(none)") + out.push("", "Copied as markdown", "") + out.push(markdownOf(mine) || "(empty)") + out.push("", "All conversations, as markdown", "") + out.push(markdownOf(arrange(projectThings(state))) || "(empty)") + } +} +process.stdout.write( + html + ? `Trail preview
${out.map((line) => (line.includes("\n`
+    : `${out.join("\n")}\n`,
+)
diff --git a/packages/trail/src/core/view/dialog.ts b/packages/trail/src/core/view/dialog.ts
new file mode 100644
index 00000000..8fa9f5dd
--- /dev/null
+++ b/packages/trail/src/core/view/dialog.ts
@@ -0,0 +1,485 @@
+/**
+ * `/trail`: the whole trail, for this conversation or every conversation in the project.
+ *
+ *    Trail · opencode-cockpit         [tab]  This conversation   All conversations
+ *
+ *    COM-1736    Bundle desync                      Jira   updated             2h ↗
+ *      PR #33    0.8: Trust, one design s…        GitHub   created → updated   1h ↗
+ *    artifact    Rollout checklist · docs         Claude   created            30m ↗
+ *
+ *    FOUND, NOT RECORDED  seen in output — add what this conversation made
+ *    PR #40      https://github.com/acme/web/pull/40      GitHub   seen 3m  [a] Add
+ *
+ *    [enter] Open   [g] Go   [c] Copy   [x] Remove   [/] Search   …   [esc] Close
+ *
+ * The same `arrange` as the sidebar and `trail_list`, so the three cannot disagree. *All
+ * conversations* lists, under a thing other conversations touched too, each conversation that did —
+ * a row of its own, so `g` on it goes there (to the root: a subagent's view has no sidebar,
+ * docs/opencode/trail-interface.md). A conversation since deleted keeps its row, marked.
+ *
+ * Found-not-recorded rows are offered, never added: `a` records one, as you.
+ *
+ * Keys with nothing to act on under the cursor are dimmed, not removed (design-system.md); narrow,
+ * a dimmed key gives way a little before a live one of the same rank.
+ */
+
+import { closeHint, fitHints, type Hint } from "@opencode-cockpit/client/design"
+import {
+  type Arranged,
+  arrange,
+  conversationThings,
+  type Found,
+  lastOf,
+  linesOf,
+  notRecorded,
+  projectThings,
+  type Thing,
+  type Touch,
+} from "../model.ts"
+import { historyText, type State } from "../store.ts"
+import { age, cursorRow, cut, fit, type Row, type Run, spread, widthOf } from "./rows.ts"
+
+export type Tab = "this" | "all"
+
+export const TAB_NAMES: Readonly<{ [T in Tab]: { long: string; short: string } }> = {
+  this: { long: "This conversation", short: "This" },
+  all: { long: "All conversations", short: "All" },
+}
+
+export interface DialogInput {
+  width: number
+  height: number
+  tab: Tab
+  state: State
+  /** The conversation on screen (its root session). */
+  session: string
+  /** PR and issue links seen in this conversation's tool output. */
+  found: readonly Found[]
+  now: number
+  /** The project's folder name, for the header. */
+  project: string
+  /** The key of the item under the cursor; the first when unset or gone. */
+  selected?: string
+  query?: string
+  /** `/` was pressed: the query is being typed. */
+  searching?: boolean
+}
+
+/** Something the cursor can sit on. */
+export type Item =
+  | { kind: "thing"; key: string; thing: Thing }
+  /** One conversation's part in a thing, under it in All conversations. */
+  | { kind: "touch"; key: string; thing: Thing; touch: Touch }
+  | { kind: "found"; key: string; found: Found }
+
+/** What the keys do to the item under the cursor. Absent: nothing to do, and the key is dimmed. */
+export interface Target {
+  /** `enter`: the page to open (http(s) only). */
+  open?: string
+  /** `g`: the conversation to go to. */
+  go?: string
+  /** `c`: what to copy — the link, else the ref. */
+  copy?: string
+  /** `a`: the find to record. */
+  add?: Found
+  /** `x`: the record to remove. */
+  remove?: string
+}
+
+export interface DialogView {
+  rows: Row[]
+  items: Item[]
+  item?: Item
+  target: Target
+  /** The rows a click selects. */
+  hits: { y: number; key: string }[]
+  arranged: Arranged
+  found: Found[]
+}
+
+/** 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 {}
+  if (item.kind === "found") return { open: item.found.url, copy: item.found.url, add: item.found }
+  const { thing } = item
+  const copy = thing.url ?? thing.ref ?? thing.title
+  const open = thing.openable ? thing.url : undefined
+  const touch = item.kind === "touch" ? item.touch : tab === "all" ? thing.touches[0] : undefined
+  const go = touch && touch.session !== session ? touch.session : undefined
+  /** In All conversations a thing several conversations touched is removed one conversation at a time. */
+  const remove =
+    item.kind === "touch"
+      ? item.touch.record
+      : thing.touches.length === 1
+        ? thing.touches[0]?.record
+        : undefined
+  return {
+    ...(open ? { open } : {}),
+    ...(go ? { go } : {}),
+    copy,
+    ...(remove ? { remove } : {}),
+  }
+}
+
+function hintsFor(target: Target, searching: boolean): Hint[] {
+  if (searching)
+    return [
+      { key: "enter", label: "Done", priority: 9 },
+      { key: "esc", label: "Cancel", close: true },
+    ]
+  /** A key with something to do under the cursor gives way after a dimmed one of about its rank. */
+  const hint = (key: string, label: string, priority: number, on: unknown): Hint =>
+    on ? { key, label, priority: priority + 3 } : { key, label, priority, off: true }
+  return [
+    hint("enter", "Open", 9, target.open),
+    hint("g", "Go", 6, target.go),
+    hint("c", "Copy", 7, target.copy),
+    hint("a", "Add", 5, target.add),
+    hint("x", "Remove", 4, target.remove),
+    hint("/", "Search", 8, true),
+    hint("m", "Markdown", 1, true),
+    closeHint(),
+  ]
+}
+
+function headerRow(input: DialogInput, width: number): Row {
+  const left: Run[] = [
+    { text: " Trail", tone: "text", bold: true },
+    ...(input.project ? [muted(` · ${input.project}`)] : []),
+  ]
+  const tabs = (long: boolean): Run[] => [
+    keyRun("tab"),
+    { text: " " },
+    ...(["this", "all"] as const).flatMap((tab, at): Run[] => {
+      const name = long ? TAB_NAMES[tab].long : TAB_NAMES[tab].short
+      return [
+        ...(at > 0 ? [{ text: " " }] : []),
+        tab === input.tab
+          ? { text: ` ${name} `, tone: "text", fill: "chip", bold: true }
+          : { text: ` ${name} `, tone: "muted" },
+      ]
+    }),
+    { text: " " },
+  ]
+  const textOf = (runs: Run[]) => runs.map((run) => run.text).join("")
+  const long = tabs(true)
+  const room = width - widthOf(textOf(left)) - 2
+  return spread(left, widthOf(textOf(long)) <= room ? long : tabs(false), width)
+}
+
+interface Columns {
+  label: number
+  system: number
+  history: number
+  age: number
+}
+
+function columnsFor(
+  things: readonly { thing: Thing; depth: number }[],
+  input: DialogInput,
+  width: number,
+): Columns {
+  const most = (values: number[]) => Math.max(0, ...values)
+  const full: Columns = {
+    label: Math.min(
+      LABEL_MAX,
+      most(things.map(({ thing, depth }) => widthOf(thing.label ?? "") + 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(age(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(age(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}`
+          : ""
+      : ""
+  return spread(
+    [
+      { text: " " },
+      ...(columns.label
+        ? [muted(cell(`${indent}${thing.label ?? ""}`, 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,
+  )
+}
+
+function foundRow(found: Found, columns: Columns, width: number, now: number): Row {
+  return spread(
+    [
+      { text: " " },
+      ...(columns.label ? [muted(cell(found.label, columns.label)), { text: "  " }] : []),
+      { text: found.url, tone: "text" },
+    ],
+    [
+      { text: " ".repeat(Math.max(0, columns.system - (found.system ? widthOf(found.system) + 2 : 0))) },
+      ...(found.system ? [chip(found.system)] : []),
+      ...(columns.system ? [{ text: "  " }] : []),
+      muted(`seen ${age(now - found.at).padStart(columns.age)}  `),
+      keyRun("a"),
+      muted(" Add "),
+    ],
+    width,
+  )
+}
+
+/** Wrapped muted prose, a cell of margin each side: what an empty tab says. */
+function prose(text: string, width: number): Row[] {
+  const room = Math.max(8, width - 4)
+  const out: Row[] = []
+  let line = ""
+  for (const word of text.split(" ")) {
+    if (line && widthOf(`${line} ${word}`) > room) {
+      out.push(fit([muted(`   ${line}`)], width))
+      line = word
+    } else line = line ? `${line} ${word}` : word
+  }
+  if (line) out.push(fit([muted(`   ${line}`)], width))
+  return out
+}
+
+export function dialogRows(input: DialogInput): DialogView {
+  const width = Math.max(20, input.width)
+  const height = Math.max(MIN_HEIGHT, input.height)
+  const query = input.query ?? ""
+  const things =
+    input.tab === "all" ? projectThings(input.state) : conversationThings(input.state, input.session)
+  const arranged = arrange(things, query)
+  const lines = linesOf(arranged)
+  const found = input.tab === "this" ? notRecorded(input.state, input.session, input.found) : []
+
+  /** The body, each row with the item it selects. */
+  const body: { row: Row; item?: Item }[] = []
+  const placed = lines.flatMap((line) => (line.kind === "thing" ? [line] : []))
+  const columns = columnsFor(placed, input, width)
+  /** Finds line up with the records above them on the left, and with each other on the right. */
+  const foundColumns: Columns = {
+    label: Math.max(
+      columns.label,
+      Math.min(LABEL_NARROW, Math.max(0, ...found.map((each) => widthOf(each.label)))),
+    ),
+    system: Math.min(
+      SYSTEM_MAX,
+      Math.max(0, ...found.map((each) => (each.system ? widthOf(each.system) + 2 : 0))),
+    ),
+    history: 0,
+    age: Math.max(0, ...found.map((each) => widthOf(age(input.now - each.at)))),
+  }
+
+  if (arranged.total === 0)
+    body.push(
+      {
+        row: fit(
+          [
+            {
+              text:
+                input.tab === "this"
+                  ? " Nothing recorded in this conversation yet."
+                  : " Nothing recorded in this project yet.",
+              tone: "text",
+            },
+          ],
+          width,
+        ),
+      },
+      ...prose(
+        "The agent records what it creates or changes outside the repository — a PR, a ticket, a page, a deploy — with trail_add. Add one yourself with /link .",
+        width,
+      ).map((row) => ({ row })),
+    )
+  else if (arranged.shown === 0)
+    body.push({ row: fit([{ text: ` Nothing matches “${query}�.`, tone: "text" }], width) })
+
+  for (const line of lines) {
+    if (line.kind === "head") {
+      body.push({
+        row: fit(
+          [{ text: " " }, { text: line.name, tone: "text", bold: true }, muted("  not recorded itself")],
+          width,
+        ),
+      })
+      continue
+    }
+    const { thing, depth } = line
+    body.push({
+      row: thingRow(thing, depth, input, columns, width),
+      item: { kind: "thing", key: thing.key, thing },
+    })
+    /** A thing only this conversation touched needs no row saying so: there is nowhere to go. */
+    const elsewhere = thing.touches.some((touch) => touch.session !== input.session)
+    if (input.tab === "all" && elsewhere)
+      for (const touch of thing.touches)
+        body.push({
+          row: touchRow(touch, depth, input, columns, width),
+          item: { kind: "touch", key: `${thing.key}\n${touch.session}`, thing, touch },
+        })
+  }
+
+  if (found.length > 0) {
+    if (body.length > 0) body.push({ row: fit([], width) })
+    body.push({
+      row: fit(
+        [
+          { text: " FOUND, NOT RECORDED", tone: "text", bold: true },
+          muted("  seen in output — add what this conversation made"),
+        ],
+        width,
+      ),
+    })
+    for (const each of found)
+      body.push({
+        row: foundRow(each, foundColumns, width, input.now),
+        item: { kind: "found", key: `found:${each.url}`, found: each },
+      })
+  }
+
+  const items = body.flatMap((entry) => (entry.item ? [entry.item] : []))
+  const item = items.find((each) => each.key === input.selected) ?? items[0]
+  const target = targetOf(item, input.tab, input.session)
+
+  /* The frame: header, search, air, the body's window, air, keys. */
+  const rows: Row[] = [headerRow(input, width)]
+  if (input.searching || query) {
+    const count = query
+      ? muted(`${arranged.shown} of ${arranged.total} `)
+      : muted("title, ref, kind, system ")
+    rows.push(
+      spread(
+        [
+          { text: " " },
+          keyRun("/"),
+          { text: " " },
+          { text: query, tone: "text" },
+          ...(input.searching ? [{ text: "â–�", tone: "accent" as const }] : []),
+        ],
+        [count],
+        width,
+      ),
+    )
+  }
+  rows.push(fit([], width))
+  const room = height - rows.length - 2
+  const at = Math.max(
+    0,
+    body.findIndex((entry) => entry.item === item),
+  )
+  let start = 0
+  let end = body.length
+  if (body.length > room) {
+    /** Keep the cursor in view, a row of the list above it when there is one. */
+    start = Math.min(Math.max(0, at - 1), body.length - room)
+    end = start + room
+    if (start > 0) start++
+    if (end < body.length) end--
+  }
+  const hits: { y: number; key: string }[] = []
+  if (start > 0) rows.push(fit([muted(` ↑ ${start} more`)], width))
+  for (const entry of body.slice(start, end)) {
+    if (entry.item) hits.push({ y: rows.length, key: entry.item.key })
+    rows.push(entry.item && entry.item === item ? cursorRow(entry.row, width) : entry.row)
+  }
+  if (end < body.length) rows.push(fit([muted(` ↓ ${body.length - end} more`)], width))
+  while (rows.length < height - 2) rows.push(fit([], width))
+  rows.push(fit([], width))
+  const fitted = fitHints(hintsFor(target, input.searching === true), Math.max(0, width - 2))
+  rows.push(fit([{ text: " " }, ...fitted.runs], width))
+
+  return { rows, items, ...(item ? { item } : {}), target, hits, arranged, found }
+}
diff --git a/packages/trail/src/core/view/rows.ts b/packages/trail/src/core/view/rows.ts
new file mode 100644
index 00000000..43030a7b
--- /dev/null
+++ b/packages/trail/src/core/view/rows.ts
@@ -0,0 +1,184 @@
+/**
+ * Rows of styled runs — every Trail surface, pure so a test can measure it.
+ *
+ * Tones name a meaning, never a colour; the interface's one render file is the only place that knows
+ * what a tone looks like, so the preview CLI and OpenCode draw the same rows
+ * (docs/building/terminal-ui.md). The helpers are Trust's (`core/view/rows.ts`), copied rather than
+ * imported: bays never import each other.
+ */
+
+export type Tone =
+  | "text"
+  | "muted"
+  | "accent"
+  | "info"
+  | "tool"
+  | "success"
+  | "error"
+  | "warning"
+  | "border"
+  /** Text cut out of a solid fill (a focused button): the colour of what the dialog is drawn on. */
+  | "ink"
+
+/**
+ * What sits behind a run. Two cover a whole row — the row under the cursor and the card's raised
+ * panel — and the rest a few cells: an agent's chip, a button, a focused button, and the tinted
+ * badges that say success, warning and error. A tint is the tone's colour faded into the dialog's
+ * own, so it follows the theme (`TINTS`; `tui/render.ts` mixes it).
+ */
+export type Fill = "none" | "selected" | "panel" | "chip" | "button" | "buttonOn" | "ok" | "warn" | "err"
+
+/** The fills that belong to a row rather than a run: what a row is padded with, and what a cursor replaces. */
+const ROW_FILLS: ReadonlySet = new Set(["selected", "panel"])
+const isRowFill = (fill: Fill | undefined): fill is Fill => fill !== undefined && ROW_FILLS.has(fill)
+
+/**
+ * The tinted fills: which tone each is made of, and how much of it is mixed into the dialog's
+ * background. Little enough that the tone's own text on it stays legible; enough to be seen.
+ */
+export const TINTS: Readonly> = {
+  chip: { tone: "info", amount: 0.12 },
+  ok: { tone: "success", amount: 0.16 },
+  warn: { tone: "warning", amount: 0.16 },
+  err: { tone: "error", amount: 0.16 },
+}
+
+export interface Run {
+  text: string
+  tone?: Tone
+  fill?: Fill
+  bold?: boolean
+  faint?: boolean
+}
+
+export type Row = Run[]
+
+export const rowText = (row: Row): string => row.map((run) => run.text).join("")
+
+/** Columns a string takes. Wide characters (CJK, most emoji) take two. */
+export function widthOf(text: string, limit = Number.POSITIVE_INFINITY): number {
+  let width = 0
+  for (const char of text) {
+    if (width > limit) return width
+    const code = char.codePointAt(0) ?? 0
+    width +=
+      code >= 0x1100 &&
+      (code <= 0x115f ||
+        (code >= 0x2e80 && code <= 0xa4cf) ||
+        (code >= 0xac00 && code <= 0xd7a3) ||
+        (code >= 0xf900 && code <= 0xfaff) ||
+        (code >= 0xfe30 && code <= 0xfe4f) ||
+        (code >= 0xff00 && code <= 0xff60) ||
+        (code >= 0xffe0 && code <= 0xffe6) ||
+        (code >= 0x1f300 && code <= 0x1faff) ||
+        (code >= 0x20000 && code <= 0x3fffd))
+        ? 2
+        : 1
+  }
+  return width
+}
+
+/** `text` in at most `width` columns, ending in `…` when cut. */
+export function cut(text: string, width: number): string {
+  if (width <= 0) return ""
+  if (widthOf(text, width) <= width) return text
+  let out = ""
+  let used = 0
+  for (const char of text) {
+    const w = widthOf(char)
+    if (used + w > width - 1) break
+    out += char
+    used += w
+  }
+  return `${out}…`
+}
+
+/**
+ * A row exactly `width` columns wide: runs cut where they overflow, padded in the last run's fill
+ * where they fall short. Every row every surface draws goes through here; the grid test holds it.
+ */
+export function fit(row: Row, width: number): Row {
+  const out: Row = []
+  let used = 0
+  for (const run of row) {
+    if (used >= width) break
+    const room = width - used
+    const w = widthOf(run.text, room)
+    if (w <= room) {
+      out.push(run)
+      used += w
+    } else {
+      const text = cut(run.text, room)
+      out.push({ ...run, text })
+      used += widthOf(text)
+      break
+    }
+  }
+  if (used < width) {
+    const fill = rowFillOf(out)
+    out.push({ text: " ".repeat(width - used), ...(fill ? { fill } : {}) })
+  }
+  return out
+}
+
+/** The fill a row is drawn on: the last row-wide fill among its runs. A chip at its end is not one. */
+export function rowFillOf(row: Row): Fill | undefined {
+  for (let i = row.length - 1; i >= 0; i--) {
+    const fill = row[i]?.fill
+    if (isRowFill(fill)) return fill
+  }
+  return undefined
+}
+
+/** Left and right parts on one row, the right part flush against the edge; the left gives way. */
+export function spread(left: Row, right: Row, width: number): Row {
+  const rightWidth = widthOf(rowText(right))
+  if (rightWidth >= width) return fit(right, width)
+  const leftRoom = width - rightWidth - 1
+  const shown = fit(left, leftRoom)
+  const fill = rowFillOf([...left, ...right])
+  return [...shown, { text: " ", ...(fill ? { fill } : {}) }, ...right]
+}
+
+/**
+ * A row on one surface — the row under the cursor, the card's panel. A run with a fill of its own
+ * (a chip, a badge, a button) keeps it: the surface is what is behind them.
+ */
+export const filled = (row: Row, fill: Fill): Row =>
+  row.map((run) =>
+    run.fill === undefined || run.fill === "none" || isRowFill(run.fill) ? { ...run, fill } : run,
+  )
+
+/**
+ * The row under the cursor: `▌` in its first cell — the margin every row keeps for it — and the
+ * selected fill to both edges (docs/building/design-system.md: `▌` is the cursor and nothing else).
+ */
+export function cursorRow(row: Row, width: number): Row {
+  const [first, ...rest] = row
+  const trimmed = !first
+    ? []
+    : widthOf(first.text) > 1
+      ? [{ ...first, text: [...first.text].slice(1).join("") }, ...rest]
+      : rest
+  return filled(fit([{ text: "▌", tone: "accent" }, ...trimmed], width), "selected")
+}
+
+/**
+ * How long ago, in the fewest characters that still read: `now`, `5m`, `2h`, `3d`. In a column of
+ * times, "ago" on every one says nothing.
+ */
+export function age(ms: number): string {
+  const s = Math.max(0, Math.floor(ms / 1000))
+  if (s < 60) return "now"
+  const m = Math.floor(s / 60)
+  if (m < 60) return `${m}m`
+  const h = Math.floor(m / 60)
+  if (h < 48) return `${h}h`
+  return `${Math.floor(h / 24)}d`
+}
+
+/** The same in a sentence: `just now`, `5m ago`. */
+export const ago = (ms: number): string => {
+  const said = age(ms)
+  return said === "now" ? "just now" : `${said} ago`
+}
diff --git a/packages/trail/src/core/view/sidebar.ts b/packages/trail/src/core/view/sidebar.ts
new file mode 100644
index 00000000..26eedd0b
--- /dev/null
+++ b/packages/trail/src/core/view/sidebar.ts
@@ -0,0 +1,152 @@
+/**
+ * The sidebar block: what this conversation made, grouped by what it was for, one click from the page.
+ *
+ *   Trail                                            9
+ *
+ *   COM-1801
+ *     ENG-42   Retry the sock…  Linear  created  15m ↗
+ *   COM-1736   Bundle desync      Jira  updated   2h ↗
+ *     PR #33   0.8: Trust, on…  GitHub  updated   1h ↗
+ *   + 4 more · /trail
+ *
+ * (`COM-1801` heads its group by name: nothing here records the ticket itself.)
+ *
+ * A row: the ref (or kind) muted, the title, the system, then what this conversation last did and
+ * when — muted, because it is history and not a state; nothing here goes stale, so nothing wears a
+ * state's colour. `↗` marks a row a click opens in the browser.
+ *
+ * **Present when empty** (Gate 1): the heading and a muted `none yet` in the slot the first record
+ * will take, so the first record replaces the line instead of pushing the blocks below down.
+ *
+ * Narrow, the row gives up its columns in order — the action, then the system, then the ref's width —
+ * before the title drops below a readable few cells; whatever is cut ends in `…`.
+ */
+
+import { HEADING, HEADING_GAP, moreText } from "@opencode-cockpit/client/design"
+import { type Arranged, type Line, lastOf, linesOf, type Thing } from "../model.ts"
+import { age, cut, fit, type Row, type Run, spread, widthOf } from "./rows.ts"
+
+export interface SidebarInput {
+  width: number
+  /** This conversation's trail, arranged. */
+  arranged: Arranged
+  now: number
+  /** Rows of records before `+ N more`. */
+  limit: number
+  /** Draw nothing at all while the trail is empty (`hideWhenEmpty`). */
+  hideWhenEmpty?: boolean
+}
+
+/** What a click on a row does: open a page, open `/trail` at a record, or open `/trail`. */
+export type SidebarHit =
+  | { y: number; kind: "open"; key: string; url: string }
+  | { y: number; kind: "select"; key: string }
+  | { y: number; kind: "more" }
+
+export interface SidebarView {
+  rows: Row[]
+  hits: SidebarHit[]
+}
+
+export const OPEN_MARK = "↗"
+export const EMPTY_TEXT = "none yet"
+/** The fewest cells a title is given before a column is dropped for it. */
+const TITLE_MIN = 12
+const LABEL_MAX = 10
+const SYSTEM_MAX = 12
+const ACTION_MAX = 10
+const INDENT = 2
+
+interface Columns {
+  label: number
+  system: number
+  action: number
+  age: number
+}
+
+const muted = (text: string): Run => ({ text, tone: "muted" })
+const pad = (text: string, room: number) => `${text}${" ".repeat(Math.max(0, room - widthOf(text)))}`
+
+function columnsFor(lines: readonly Line[], width: number, now: number): Columns {
+  const things = lines.flatMap((line) => (line.kind === "thing" ? [line] : []))
+  const most = (values: number[]) => Math.max(0, ...values)
+  const full: Columns = {
+    label: Math.min(
+      LABEL_MAX,
+      most(things.map(({ thing, depth }) => widthOf(thing.label ?? "") + depth * INDENT)),
+    ),
+    system: Math.min(SYSTEM_MAX, most(things.map(({ thing }) => widthOf(thing.system ?? "")))),
+    action: Math.min(ACTION_MAX, most(things.map(({ thing }) => widthOf(lastOf(thing).action)))),
+    age: most(things.map(({ thing }) => widthOf(age(now - lastOf(thing).at)))),
+  }
+  /** Two cells after the title; each column its width and two after it; the age; the mark's two. */
+  const right = (c: Columns) => 2 + (c.system ? c.system + 2 : 0) + (c.action ? c.action + 2 : 0) + c.age + 2
+  const titleRoom = (c: Columns) => width - (c.label ? c.label + 2 : 0) - right(c)
+  const tries: Columns[] = [
+    full,
+    { ...full, action: 0 },
+    { ...full, action: 0, system: 0 },
+    { ...full, action: 0, system: 0, label: Math.min(full.label, 6) },
+  ]
+  return tries.find((c) => titleRoom(c) >= TITLE_MIN) ?? (tries.at(-1) as Columns)
+}
+
+function thingRow(thing: Thing, depth: 0 | 1, columns: Columns, width: number, now: number): Row {
+  const last = lastOf(thing)
+  const indent = " ".repeat(depth * INDENT)
+  const label = columns.label
+    ? [muted(pad(cut(`${indent}${thing.label ?? ""}`, columns.label), columns.label)), { text: "  " }]
+    : [{ text: indent }]
+  /** The system right-aligned, so a short name sits against the action like a column of numbers. */
+  const right: Run[] = [
+    { text: " " },
+    ...(columns.system
+      ? [
+          { text: cut(thing.system ?? "", columns.system).padStart(columns.system), tone: "info" as const },
+          { text: "  " },
+        ]
+      : []),
+    ...(columns.action ? [muted(pad(cut(last.action, columns.action), columns.action)), { text: "  " }] : []),
+    muted(age(now - last.at).padStart(columns.age)),
+    { text: thing.openable ? ` ${OPEN_MARK}` : "  ", tone: "muted" },
+  ]
+  return spread([...label, { text: thing.title, tone: "text" }], right, width)
+}
+
+export function sidebarRows(input: SidebarInput): SidebarView {
+  const { width, arranged, now } = input
+  if (width < 8 || (input.hideWhenEmpty && arranged.total === 0)) return { rows: [], hits: [] }
+  const rows: Row[] = []
+  const hits: SidebarHit[] = []
+  rows.push(
+    spread([{ text: "Trail", ...HEADING }], arranged.total > 0 ? [muted(String(arranged.total))] : [], width),
+  )
+  for (let gap = 0; gap < HEADING_GAP; gap++) rows.push(fit([], width))
+  if (arranged.total === 0) {
+    rows.push(fit([muted(EMPTY_TEXT)], width))
+    return { rows, hits }
+  }
+
+  const lines = linesOf(arranged)
+  const shown = lines.length > input.limit ? lines.slice(0, Math.max(1, input.limit)) : lines
+  const columns = columnsFor(shown, width, now)
+  for (const line of shown) {
+    const y = rows.length
+    if (line.kind === "head") {
+      rows.push(fit([{ text: line.name, tone: "text" }], width))
+      continue
+    }
+    rows.push(thingRow(line.thing, line.depth, columns, width, now))
+    hits.push(
+      line.thing.openable && line.thing.url
+        ? { y, kind: "open", key: line.thing.key, url: line.thing.url }
+        : { y, kind: "select", key: line.thing.key },
+    )
+  }
+  const hidden = lines.slice(shown.length).filter((line) => line.kind === "thing").length
+  if (hidden > 0) {
+    hits.push({ y: rows.length, kind: "more" })
+    rows.push(fit([muted(`${moreText(hidden)} · /trail`)], width))
+  }
+  return { rows, hits }
+}
diff --git a/packages/trail/test/view.test.ts b/packages/trail/test/view.test.ts
new file mode 100644
index 00000000..fdce44a9
--- /dev/null
+++ b/packages/trail/test/view.test.ts
@@ -0,0 +1,220 @@
+import { describe, expect, test } from "bun:test"
+import { arrange, conversationThings } from "../src/core/model.ts"
+import { SAMPLE_NOW, SAMPLES, type Sample } from "../src/core/sample.ts"
+import { emptyState } from "../src/core/store.ts"
+import { runAdd } from "../src/core/tools.ts"
+import { type DialogInput, dialogRows, MIN_HEIGHT, type Tab } from "../src/core/view/dialog.ts"
+import { type Row, rowText, widthOf } from "../src/core/view/rows.ts"
+import { EMPTY_TEXT, sidebarRows } from "../src/core/view/sidebar.ts"
+
+const sample = (name: string) => (SAMPLES[name] as () => Sample)()
+const texts = (rows: readonly Row[]) => rows.map((row) => rowText(row).trimEnd())
+const sidebarOf = (name: string, width: number, limit = 6) => {
+  const { state, session } = sample(name)
+  return sidebarRows({ width, arranged: arrange(conversationThings(state, session)), now: SAMPLE_NOW, limit })
+}
+const dialogOf = (name: string, extra: Partial = {}) => {
+  const { state, session, found } = sample(name)
+  return dialogRows({
+    width: 100,
+    height: 24,
+    tab: "this",
+    state,
+    session,
+    found,
+    now: SAMPLE_NOW,
+    project: "opencode-cockpit",
+    ...extra,
+  })
+}
+
+describe("the grid: every row exactly its width", () => {
+  const names = Object.keys(SAMPLES)
+  for (const name of names)
+    test(name, () => {
+      for (const width of [20, 30, 36, 42, 60])
+        for (const row of sidebarOf(name, width).rows) expect(widthOf(rowText(row))).toBe(width)
+      for (const tab of ["this", "all"] as Tab[])
+        for (const [width, height] of [
+          [40, MIN_HEIGHT],
+          [60, 12],
+          [80, 20],
+          [116, 30],
+          [200, 44],
+        ] as const) {
+          const { rows } = dialogOf(name, { tab, width, height })
+          expect(rows).toHaveLength(height)
+          for (const row of rows) expect(widthOf(rowText(row))).toBe(width)
+        }
+    })
+})
+
+describe("the sidebar block", () => {
+  test("empty: the heading, its row of air, and `none yet` in the first record's slot", () => {
+    const rows = texts(sidebarOf("empty", 36).rows)
+    expect(rows).toEqual(["Trail", "", EMPTY_TEXT])
+    const one = texts(sidebarOf("one", 36).rows)
+    expect(one).toHaveLength(rows.length)
+    expect(one[0]).toMatch(/^Trail +1$/)
+  })
+
+  test("hideWhenEmpty draws nothing, and only while empty", () => {
+    const empty = sample("empty")
+    expect(
+      sidebarRows({ width: 36, arranged: arrange([]), now: SAMPLE_NOW, limit: 5, hideWhenEmpty: true }).rows,
+    ).toEqual([])
+    const one = sample("one")
+    expect(
+      sidebarRows({
+        width: 36,
+        arranged: arrange(conversationThings(one.state, one.session)),
+        now: SAMPLE_NOW,
+        limit: 5,
+        hideWhenEmpty: true,
+      }).rows,
+    ).toHaveLength(3)
+    expect(empty.state.records.size).toBe(0)
+  })
+
+  test("a row: ref, title, system, last action and age; ↗ when it opens", () => {
+    const rows = texts(sidebarOf("busy", 60).rows)
+    expect(rows[0]).toMatch(/^Trail +9$/)
+    expect(rows).toContain("COM-1801")
+    expect(rows.find((row) => row.startsWith("  PR #33"))).toMatch(
+      /^ {2}PR #33 {3}0\.8: Trust, one design s… +GitHub {2}updated {3}1h ↗$/,
+    )
+    expect(rows.find((row) => row.includes("a1b2c3d"))).toMatch(/created {2}12m$/)
+  })
+
+  test("+ N more · /trail counts the records not shown, and is a hit", () => {
+    const view = sidebarOf("busy", 42, 4)
+    expect(texts(view.rows).at(-1)).toBe("+ 6 more · /trail")
+    expect(view.hits.at(-1)).toEqual({ y: view.rows.length - 1, kind: "more" })
+  })
+
+  test("a click opens the page, or selects a record that has none", () => {
+    const view = sidebarOf("busy", 42)
+    const commit = view.hits.find((hit) => hit.kind === "select")
+    expect(commit && texts(view.rows)[commit.y]).toContain("a1b2c3d")
+    const pr = view.hits.find((hit) => hit.kind === "open" && hit.url.endsWith("/pull/33"))
+    expect(pr && texts(view.rows)[pr.y]).toContain("PR #33")
+  })
+
+  test("narrow: columns give way before the title does, and a cut says …", () => {
+    const narrow = texts(sidebarOf("busy", 30).rows)
+    expect(narrow.join("\n")).not.toContain("GitHub")
+    expect(narrow.join("\n")).not.toContain("created")
+    expect(narrow.find((row) => row.includes("PR #33"))).toContain("…")
+    const wide = texts(sidebarOf("busy", 60).rows)
+    expect(wide.join("\n")).toContain("GitHub")
+  })
+})
+
+describe("/trail", () => {
+  test("the header names the tabs, the current one marked", () => {
+    const view = dialogOf("busy")
+    expect(texts(view.rows)[0]).toMatch(
+      /^ Trail · opencode-cockpit +\[tab\] {2}This conversation {3}All conversations$/,
+    )
+    const active = view.rows[0]?.find((run) => run.text === " This conversation ")
+    expect(active).toMatchObject({ fill: "chip", bold: true })
+    expect(texts(dialogOf("busy", { width: 60 }).rows)[0]).toMatch(/\[tab\] {2}This {3}All$/)
+  })
+
+  test("the footer: the keys in the agreed order, esc last", () => {
+    const footer = texts(dialogOf("busy", { width: 116 }).rows).at(-1)
+    expect(footer).toBe(
+      " [enter] Open   [g] Go   [c] Copy   [a] Add   [x] Remove   [/] Search   [m] Markdown   [esc] Close",
+    )
+    expect(texts(dialogOf("busy", { width: 40 }).rows).at(-1)).toMatch(/… {3}\[esc\] Close$/)
+  })
+
+  test("keys follow the cursor: a record without a page cannot open; a find can be added", () => {
+    const commit = dialogOf("busy")
+    expect(commit.item?.kind).toBe("thing")
+    expect(commit.target).toEqual({ copy: "a1b2c3d", remove: "ev_9" })
+    const find = commit.items.find((item) => item.kind === "found")
+    const onFind = dialogOf("busy", { selected: find?.key })
+    expect(onFind.target.add?.url).toBe("https://github.com/acme/web/pull/40")
+    const row = texts(onFind.rows).find((text) => text.includes("acme/web/pull/40"))
+    expect(row).toMatch(/^â–ŒPR #40 .*GitHub {3}seen 3m {2}\[a\] Add$/)
+  })
+
+  test("found-not-recorded rows: only what this conversation has not recorded, and only on This conversation", () => {
+    const rows = texts(dialogOf("busy").rows)
+    expect(rows).toContain(" FOUND, NOT RECORDED  seen in output — add what this conversation made")
+    expect(rows.filter((row) => /seen \d+m {2}\[a\] Add$/.test(row))).toHaveLength(2)
+    expect(texts(dialogOf("busy", { tab: "all", height: 60 }).rows).join("\n")).not.toContain("FOUND")
+  })
+
+  test("All conversations: each conversation under what it touched; g goes there; a deleted one is marked", () => {
+    const view = dialogOf("project", { tab: "all", height: 60, width: 116 })
+    const rows = texts(view.rows)
+    expect(rows.some((row) => /↳ “Review the 0\.8 branch� .* reviewed +20m$/.test(row))).toBe(true)
+    expect(rows.some((row) => row.includes("“Set up the latency dashboard� · deleted"))).toBe(true)
+    const touch = view.items.find((item) => item.kind === "touch" && item.touch.session === "ses_review")
+    expect(dialogOf("project", { tab: "all", selected: touch?.key }).target).toMatchObject({
+      go: "ses_review",
+      open: "https://github.com/Codestz/opencode-cockpit/pull/33",
+    })
+    /** A thing only this conversation touched has no conversation rows: there is nowhere to go. */
+    expect(rows.some((row) => row.includes("Status page incident"))).toBe(true)
+    expect(
+      view.items.filter((item) => item.kind === "touch" && item.thing.title === "Status page incident"),
+    ).toEqual([])
+  })
+
+  test("a short window keeps the cursor in view and says what is above and below", () => {
+    const all = dialogOf("busy", { height: 60 }).items
+    const last = all.at(-1)
+    const view = dialogOf("busy", { height: 10, selected: last?.key })
+    const rows = texts(view.rows)
+    expect(rows.find((row) => row.startsWith("▌"))).toContain("COM-1800")
+    expect(rows.some((row) => /^ ↑ \d+ more$/.test(row))).toBe(true)
+    const top = texts(dialogOf("busy", { height: 10 }).rows)
+    expect(top.some((row) => /^ ↓ \d+ more$/.test(row))).toBe(true)
+  })
+
+  test("search: the query, how many it kept, and Cancel as the way out while typing", () => {
+    const view = dialogOf("busy", { query: "github", searching: true })
+    const rows = texts(view.rows)
+    expect(rows[1]).toMatch(/^ \[\/\] githubâ–� +2 of 9$/)
+    expect(rows.at(-1)).toBe(" [enter] Done   [esc] Cancel")
+    expect(texts(dialogOf("busy", { query: "nothing-like-this" }).rows)).toContain(
+      " Nothing matches “nothing-like-this�.",
+    )
+  })
+
+  test("empty: says so, and how things get here", () => {
+    const rows = texts(dialogOf("empty").rows)
+    expect(rows[2]).toBe(" Nothing recorded in this conversation yet.")
+    expect(rows.join(" ")).toContain("with trail_add")
+    expect(dialogOf("empty").item).toBeUndefined()
+  })
+
+  test("a subagent's record and one you added say who", () => {
+    const rows = texts(dialogOf("busy", { height: 40 }).rows)
+    expect(rows.some((row) => row.includes("Rollout checklist · docs"))).toBe(true)
+    expect(rows.some((row) => row.includes("Status page incident · you"))).toBe(true)
+  })
+
+  test("x removes one conversation's record; in All a thing several touched is removed one at a time", () => {
+    const state = emptyState()
+    const add = (session: string) =>
+      runAdd(
+        state,
+        { title: "PR", url: "https://github.com/a/b/pull/1" },
+        {
+          session,
+          rootSession: session,
+          by: "agent",
+          at: SAMPLE_NOW,
+        },
+      )
+    add("s1")
+    add("s2")
+    const input = { width: 100, height: 20, state, session: "s1", found: [], now: SAMPLE_NOW, project: "p" }
+    expect(dialogRows({ ...input, tab: "this" }).target.remove).toBeDefined()
+    expect(dialogRows({ ...input, tab: "all" }).target.remove).toBeUndefined()
+  })
+})

From 9c046a7bf1c0152fd3eb70cb54c5223f78dac469 Mon Sep 17 00:00:00 2001
From: Codestz 
Date: Sat, 3 Oct 2026 09:22:00 -0500
Subject: [PATCH 07/90] Review: binaries read as bytes, described, compared,
 previewed and opened
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Binary detection by git's rule (a NUL in the first 8000 bytes); both sides read as bytes
(cat-file blob, never through UTF-8), with a 32 MB binary cap beside the 400 KB text cap, so a
screenshot is in the review instead of skipped or shown as U+FFFD.

- Header readers for PNG/APNG, JPEG, GIF, WebP (VP8/VP8L/VP8X), BMP: `PNG 2880×1800 · 807 KB → 789 KB`;
  other binaries `binary · 12.3 KB → 14.0 KB`.
- PNG and GIF decoded in slices off the draw path (async inflate, then generators that yield
  every ~256K pixels); pixel diff for same-size pairs with % changed, bounding box and a mask.
- Half-block preview, before and after side by side, changes lit; drawn through the row pool with
  a new exact `background` on Run. Thumbs and diffs cached by content hash.
- `o` opens both versions in the system viewer: old bytes into a temp file, Bun.which on the
  spawn's PATH, detached spawn with its error reported in the footer; temp dir cleaned on close.
- `[?] Keys` screen; `ctrl+p` (the host palette's key) makes the review step aside.

Co-Authored-By: Claude Opus 5.5 
---
 packages/review/src/cli/preview.ts            |  30 +-
 packages/review/src/core/fixtures.ts          |  74 ++++-
 packages/review/src/core/git/sources.ts       | 194 ++++++++++--
 packages/review/src/core/image/describe.ts    |  89 ++++++
 packages/review/src/core/image/gif.ts         | 200 +++++++++++++
 packages/review/src/core/image/looks.ts       | 215 +++++++++++++
 packages/review/src/core/image/pixels.ts      | 265 ++++++++++++++++
 packages/review/src/core/image/png.ts         | 282 ++++++++++++++++++
 packages/review/src/core/image/samples.ts     |  64 ++++
 packages/review/src/core/image/sniff.ts       | 154 ++++++++++
 packages/review/src/core/image/steps.ts       |  33 ++
 packages/review/src/core/model/review.ts      |  26 ++
 packages/review/src/core/palette.ts           |  45 +++
 packages/review/src/core/view/chrome.ts       |  30 +-
 packages/review/src/core/view/diff.ts         |   9 +
 packages/review/src/core/view/image.ts        | 158 ++++++++++
 packages/review/src/core/view/keys.ts         |  94 ++++++
 packages/review/src/core/view/layout.ts       |  10 +-
 packages/review/src/core/view/rows.ts         |  10 +-
 packages/review/src/core/view/state.ts        |  11 +
 packages/review/src/core/viewer.ts            | 138 +++++++++
 packages/review/src/tui/index.tsx             | 101 ++++++-
 packages/review/src/tui/panel/actions.ts      |  59 ++++
 packages/review/src/tui/panel/keys.ts         |  32 +-
 packages/review/src/tui/panel/paint.ts        |  28 +-
 packages/review/src/tui/panel/surface.ts      |   8 +
 packages/review/src/tui/view/pool.ts          |  55 +++-
 packages/review/test/git/binary.test.ts       | 128 ++++++++
 packages/review/test/image/decode.test.ts     |  79 +++++
 packages/review/test/image/fixtures/after.gif | Bin 0 -> 264 bytes
 packages/review/test/image/fixtures/after.png | Bin 0 -> 300 bytes
 .../review/test/image/fixtures/alpha.webp     | Bin 0 -> 424 bytes
 packages/review/test/image/fixtures/anim.apng | Bin 0 -> 1010 bytes
 packages/review/test/image/fixtures/anim.gif  | Bin 0 -> 594 bytes
 .../review/test/image/fixtures/anim.gif.rgba  |   1 +
 packages/review/test/image/fixtures/anim.webp | Bin 0 -> 586 bytes
 .../review/test/image/fixtures/baseline.jpg   | Bin 0 -> 1332 bytes
 .../review/test/image/fixtures/before.gif     | Bin 0 -> 241 bytes
 .../review/test/image/fixtures/before.png     | Bin 0 -> 278 bytes
 packages/review/test/image/fixtures/blob.bin  | Bin 0 -> 1028 bytes
 .../review/test/image/fixtures/bottomup.bmp   | Bin 0 -> 7734 bytes
 packages/review/test/image/fixtures/core.bmp  | Bin 0 -> 30 bytes
 packages/review/test/image/fixtures/exif.jpg  | Bin 0 -> 31370 bytes
 .../test/image/fixtures/grey-alpha16.png      | Bin 0 -> 1289 bytes
 .../test/image/fixtures/grey-alpha16.png.rgba | Bin 0 -> 1196 bytes
 .../test/image/fixtures/grey-alpha8.png       | Bin 0 -> 691 bytes
 .../test/image/fixtures/grey-alpha8.png.rgba  | Bin 0 -> 1196 bytes
 .../test/image/fixtures/grey1-adam7.png       | Bin 0 -> 154 bytes
 .../test/image/fixtures/grey1-adam7.png.rgba  | Bin 0 -> 1196 bytes
 packages/review/test/image/fixtures/grey1.png | Bin 0 -> 132 bytes
 .../review/test/image/fixtures/grey1.png.rgba | Bin 0 -> 1196 bytes
 .../review/test/image/fixtures/grey16.png     | Bin 0 -> 691 bytes
 .../test/image/fixtures/grey16.png.rgba       | Bin 0 -> 1196 bytes
 packages/review/test/image/fixtures/grey2.png | Bin 0 -> 171 bytes
 .../review/test/image/fixtures/grey2.png.rgba | Bin 0 -> 1196 bytes
 packages/review/test/image/fixtures/grey4.png | Bin 0 -> 249 bytes
 .../review/test/image/fixtures/grey4.png.rgba | Bin 0 -> 1196 bytes
 .../review/test/image/fixtures/grey8-key.png  | Bin 0 -> 406 bytes
 .../test/image/fixtures/grey8-key.png.rgba    | Bin 0 -> 1196 bytes
 packages/review/test/image/fixtures/grey8.png | Bin 0 -> 392 bytes
 .../review/test/image/fixtures/grey8.png.rgba | Bin 0 -> 1196 bytes
 .../review/test/image/fixtures/interlaced.gif | Bin 0 -> 249 bytes
 .../test/image/fixtures/interlaced.gif.rgba   | Bin 0 -> 10240 bytes
 .../review/test/image/fixtures/lossless.webp  | Bin 0 -> 124 bytes
 .../review/test/image/fixtures/lossy.webp     | Bin 0 -> 374 bytes
 .../review/test/image/fixtures/palette1.png   | Bin 0 -> 150 bytes
 .../test/image/fixtures/palette1.png.rgba     |   1 +
 .../review/test/image/fixtures/palette2.png   | Bin 0 -> 209 bytes
 .../test/image/fixtures/palette2.png.rgba     |   1 +
 .../test/image/fixtures/palette4-adam7.png    | Bin 0 -> 324 bytes
 .../image/fixtures/palette4-adam7.png.rgba    | Bin 0 -> 1196 bytes
 .../review/test/image/fixtures/palette4.png   | Bin 0 -> 309 bytes
 .../test/image/fixtures/palette4.png.rgba     |   1 +
 .../review/test/image/fixtures/palette8.png   | Bin 0 -> 1116 bytes
 .../test/image/fixtures/palette8.png.rgba     | Bin 0 -> 1196 bytes
 .../test/image/fixtures/progressive.jpg       | Bin 0 -> 1213 bytes
 .../review/test/image/fixtures/resized.png    | Bin 0 -> 661 bytes
 packages/review/test/image/fixtures/rgb16.png | Bin 0 -> 1887 bytes
 .../review/test/image/fixtures/rgb16.png.rgba | Bin 0 -> 1196 bytes
 .../review/test/image/fixtures/rgb8-adam7.png | Bin 0 -> 1003 bytes
 .../test/image/fixtures/rgb8-adam7.png.rgba   | Bin 0 -> 1196 bytes
 .../review/test/image/fixtures/rgb8-key.png   | Bin 0 -> 1008 bytes
 .../test/image/fixtures/rgb8-key.png.rgba     | Bin 0 -> 1196 bytes
 packages/review/test/image/fixtures/rgb8.png  | Bin 0 -> 990 bytes
 .../review/test/image/fixtures/rgb8.png.rgba  | Bin 0 -> 1196 bytes
 .../review/test/image/fixtures/rgba16.png     | Bin 0 -> 2485 bytes
 .../test/image/fixtures/rgba16.png.rgba       | Bin 0 -> 1196 bytes
 packages/review/test/image/fixtures/rgba8.png | Bin 0 -> 1289 bytes
 .../review/test/image/fixtures/rgba8.png.rgba | Bin 0 -> 1196 bytes
 packages/review/test/image/fixtures/still.gif | Bin 0 -> 244 bytes
 .../review/test/image/fixtures/still.gif.rgba |   1 +
 .../review/test/image/fixtures/topdown.bmp    | Bin 0 -> 7734 bytes
 packages/review/test/image/pixels.test.ts     | 227 ++++++++++++++
 packages/review/test/image/sniff.test.ts      | 129 ++++++++
 packages/review/test/image/viewer.test.ts     | 150 ++++++++++
 packages/review/test/panel/palette.test.ts    |  25 ++
 packages/review/test/view/grid.test.ts        |  14 +-
 packages/review/test/view/image.test.ts       | 146 +++++++++
 packages/review/test/watch/stats.test.ts      |   2 +-
 99 files changed, 3249 insertions(+), 70 deletions(-)
 create mode 100644 packages/review/src/core/image/describe.ts
 create mode 100644 packages/review/src/core/image/gif.ts
 create mode 100644 packages/review/src/core/image/looks.ts
 create mode 100644 packages/review/src/core/image/pixels.ts
 create mode 100644 packages/review/src/core/image/png.ts
 create mode 100644 packages/review/src/core/image/samples.ts
 create mode 100644 packages/review/src/core/image/sniff.ts
 create mode 100644 packages/review/src/core/image/steps.ts
 create mode 100644 packages/review/src/core/palette.ts
 create mode 100644 packages/review/src/core/view/image.ts
 create mode 100644 packages/review/src/core/view/keys.ts
 create mode 100644 packages/review/src/core/viewer.ts
 create mode 100644 packages/review/test/git/binary.test.ts
 create mode 100644 packages/review/test/image/decode.test.ts
 create mode 100644 packages/review/test/image/fixtures/after.gif
 create mode 100644 packages/review/test/image/fixtures/after.png
 create mode 100644 packages/review/test/image/fixtures/alpha.webp
 create mode 100644 packages/review/test/image/fixtures/anim.apng
 create mode 100644 packages/review/test/image/fixtures/anim.gif
 create mode 100644 packages/review/test/image/fixtures/anim.gif.rgba
 create mode 100644 packages/review/test/image/fixtures/anim.webp
 create mode 100644 packages/review/test/image/fixtures/baseline.jpg
 create mode 100644 packages/review/test/image/fixtures/before.gif
 create mode 100644 packages/review/test/image/fixtures/before.png
 create mode 100644 packages/review/test/image/fixtures/blob.bin
 create mode 100644 packages/review/test/image/fixtures/bottomup.bmp
 create mode 100644 packages/review/test/image/fixtures/core.bmp
 create mode 100644 packages/review/test/image/fixtures/exif.jpg
 create mode 100644 packages/review/test/image/fixtures/grey-alpha16.png
 create mode 100644 packages/review/test/image/fixtures/grey-alpha16.png.rgba
 create mode 100644 packages/review/test/image/fixtures/grey-alpha8.png
 create mode 100644 packages/review/test/image/fixtures/grey-alpha8.png.rgba
 create mode 100644 packages/review/test/image/fixtures/grey1-adam7.png
 create mode 100644 packages/review/test/image/fixtures/grey1-adam7.png.rgba
 create mode 100644 packages/review/test/image/fixtures/grey1.png
 create mode 100644 packages/review/test/image/fixtures/grey1.png.rgba
 create mode 100644 packages/review/test/image/fixtures/grey16.png
 create mode 100644 packages/review/test/image/fixtures/grey16.png.rgba
 create mode 100644 packages/review/test/image/fixtures/grey2.png
 create mode 100644 packages/review/test/image/fixtures/grey2.png.rgba
 create mode 100644 packages/review/test/image/fixtures/grey4.png
 create mode 100644 packages/review/test/image/fixtures/grey4.png.rgba
 create mode 100644 packages/review/test/image/fixtures/grey8-key.png
 create mode 100644 packages/review/test/image/fixtures/grey8-key.png.rgba
 create mode 100644 packages/review/test/image/fixtures/grey8.png
 create mode 100644 packages/review/test/image/fixtures/grey8.png.rgba
 create mode 100644 packages/review/test/image/fixtures/interlaced.gif
 create mode 100644 packages/review/test/image/fixtures/interlaced.gif.rgba
 create mode 100644 packages/review/test/image/fixtures/lossless.webp
 create mode 100644 packages/review/test/image/fixtures/lossy.webp
 create mode 100644 packages/review/test/image/fixtures/palette1.png
 create mode 100644 packages/review/test/image/fixtures/palette1.png.rgba
 create mode 100644 packages/review/test/image/fixtures/palette2.png
 create mode 100644 packages/review/test/image/fixtures/palette2.png.rgba
 create mode 100644 packages/review/test/image/fixtures/palette4-adam7.png
 create mode 100644 packages/review/test/image/fixtures/palette4-adam7.png.rgba
 create mode 100644 packages/review/test/image/fixtures/palette4.png
 create mode 100644 packages/review/test/image/fixtures/palette4.png.rgba
 create mode 100644 packages/review/test/image/fixtures/palette8.png
 create mode 100644 packages/review/test/image/fixtures/palette8.png.rgba
 create mode 100644 packages/review/test/image/fixtures/progressive.jpg
 create mode 100644 packages/review/test/image/fixtures/resized.png
 create mode 100644 packages/review/test/image/fixtures/rgb16.png
 create mode 100644 packages/review/test/image/fixtures/rgb16.png.rgba
 create mode 100644 packages/review/test/image/fixtures/rgb8-adam7.png
 create mode 100644 packages/review/test/image/fixtures/rgb8-adam7.png.rgba
 create mode 100644 packages/review/test/image/fixtures/rgb8-key.png
 create mode 100644 packages/review/test/image/fixtures/rgb8-key.png.rgba
 create mode 100644 packages/review/test/image/fixtures/rgb8.png
 create mode 100644 packages/review/test/image/fixtures/rgb8.png.rgba
 create mode 100644 packages/review/test/image/fixtures/rgba16.png
 create mode 100644 packages/review/test/image/fixtures/rgba16.png.rgba
 create mode 100644 packages/review/test/image/fixtures/rgba8.png
 create mode 100644 packages/review/test/image/fixtures/rgba8.png.rgba
 create mode 100644 packages/review/test/image/fixtures/still.gif
 create mode 100644 packages/review/test/image/fixtures/still.gif.rgba
 create mode 100644 packages/review/test/image/fixtures/topdown.bmp
 create mode 100644 packages/review/test/image/pixels.test.ts
 create mode 100644 packages/review/test/image/sniff.test.ts
 create mode 100644 packages/review/test/image/viewer.test.ts
 create mode 100644 packages/review/test/panel/palette.test.ts
 create mode 100644 packages/review/test/view/image.test.ts

diff --git a/packages/review/src/cli/preview.ts b/packages/review/src/cli/preview.ts
index 3ec0984d..af129d64 100755
--- a/packages/review/src/cli/preview.ts
+++ b/packages/review/src/cli/preview.ts
@@ -30,6 +30,8 @@ if (args.includes("--help")) {
     --file      which file to show the diff of (default: the second one)
     --width        columns (default: this terminal)
     --height       rows (default: this terminal)
+    --diff            the cursor in the diff pane rather than the file list
+    --keys            the [?] Keys screen
 `)
   process.exit(0)
 }
@@ -81,12 +83,17 @@ const BG: Record = {
 
 const DIM = `${ESC}[2m`
 
+/** A packed `0xRRGGBB` as an SGR colour: 38 for ink, 48 for background. */
+const exact = (layer: 38 | 48, colour: number) =>
+  `${ESC}[${layer};2;${(colour >> 16) & 255};${(colour >> 8) & 255};${colour & 255}m`
+
 const paint = (row: Row): string =>
   row.runs
-    .map(
-      (run) =>
-        `${BG[run.fill ?? "none"]}${FG[run.tone ?? "text"]}${run.bold ? BOLD : ""}${run.faint ? DIM : ""}${run.text}${RESET}`,
-    )
+    .map((run) => {
+      const bg = typeof run.background === "number" ? exact(48, run.background) : BG[run.fill ?? "none"]
+      const fg = typeof run.color === "number" ? exact(38, run.color) : FG[run.tone ?? "text"]
+      return `${bg}${fg}${run.bold ? BOLD : ""}${run.faint ? DIM : ""}${run.text}${RESET}`
+    })
     .join("")
 
 const width = Number(flag("width") ?? process.stdout.columns ?? 120)
@@ -116,7 +123,20 @@ const file = flag("file") ?? changes.files[1]?.path ?? changes.files[0]?.path
 
 /** What the pane passes too: without it the footer offered `[s] Submit` dimmed with notes waiting. */
 const waiting = waitingOnAgent(review).length
-const rows = layout(changes, review, { file, cursor: file, context: 3, waiting }, { width, height })
+const rows = layout(
+  changes,
+  review,
+  {
+    file,
+    cursor: file,
+    context: 3,
+    waiting,
+    ...(fixture.looks ? { looks: fixture.looks } : {}),
+    ...(args.includes("--keys") ? { keys: true } : {}),
+    ...(args.includes("--diff") ? { pane: "diff" as const } : {}),
+  },
+  { width, height },
+)
 
 console.log(`\n${BOLD}${name}${RESET} — ${fixture.about}  ${FG.muted}${width}×${height}${RESET}`)
 console.log(`${FG.border}┌${"─".repeat(width - 2)}�${RESET}`)
diff --git a/packages/review/src/core/fixtures.ts b/packages/review/src/core/fixtures.ts
index fb77123c..aff4faba 100644
--- a/packages/review/src/core/fixtures.ts
+++ b/packages/review/src/core/fixtures.ts
@@ -6,7 +6,9 @@
  * The preview draws all of these, so none of them is discovered in OpenCode.
  */
 
-import type { ChangeSet } from "./model/review.ts"
+import { type ImageLook, tooLargeText } from "./image/looks.ts"
+import { SAMPLE, sampleLook } from "./image/samples.ts"
+import type { BinarySide, ChangeSet, FileChange } from "./model/review.ts"
 
 const lines = (count: number, token: string) =>
   `${Array.from({ length: count }, (_, i) => `${token} ${i + 1}`).join("\n")}\n`
@@ -42,7 +44,36 @@ export function merge(base: Config, over: Config): Config {
 }
 `
 
-export const FIXTURES: Record = {
+/** A PNG side, as git and the header reader would describe it. */
+const png = (width: number, height: number, size: number): BinarySide => ({
+  size,
+  image: { format: "png", width, height },
+})
+
+/** A binary file change: no text, no counts, what each side is. */
+const image = (
+  path: string,
+  before: BinarySide | undefined,
+  after: BinarySide | undefined,
+  change?: FileChange["change"],
+): FileChange => ({
+  path,
+  before: "",
+  after: "",
+  additions: 0,
+  deletions: 0,
+  ...(change ? { change } : {}),
+  binary: { ...(before ? { before, revision: "a1b2c3d" } : {}), ...(after ? { after } : {}) },
+})
+
+export interface Fixture {
+  about: string
+  changes: ChangeSet
+  /** What the pane would know about the images once decoded. */
+  looks?: ReadonlyMap
+}
+
+export const FIXTURES: Record = {
   /** The ordinary case: a few files, edits you can read at a glance. */
   turn: {
     about: "one turn's work — three files, a mix of edits",
@@ -185,6 +216,45 @@ export const FIXTURES: Record = {
     },
   },
 
+  /**
+   * Binaries: every state an image change can be in, and a binary that is not an image.
+   *
+   * The pictures are drawn in code (`image/samples.ts`) and go through the real shrink and pixel diff,
+   * so what the preview shows is what the pane would.
+   */
+  images: {
+    about: "binaries — changed, resized, new, deleted, a JPEG, one too large, one not an image",
+    changes: {
+      source: "branch",
+      files: [
+        /** First, so the preview marks it viewed and the pictures below stay open. */
+        image("assets/font.woff2", { size: 12_595 }, { size: 14_336 }),
+        image("media/dashboard.png", png(288, 180, 807_358), png(288, 180, 789_120)),
+        image("media/thumbnail.png", png(288, 180, 826_548), png(144, 90, 220_412)),
+        image("media/logo.png", undefined, png(96, 96, 12_904), "added"),
+        image(
+          "media/old-banner.gif",
+          { size: 574_310, image: { format: "gif", width: 288, height: 180 } },
+          undefined,
+          "deleted",
+        ),
+        image(
+          "media/photo.jpg",
+          { size: 368_596, image: { format: "jpeg", width: 4032, height: 3024 } },
+          { size: 341_022, image: { format: "jpeg", width: 4032, height: 3024 } },
+        ),
+        image("media/poster.png", png(12_000, 9_000, 182_400_000), png(12_000, 9_000, 183_100_512)),
+      ],
+    },
+    looks: new Map([
+      ["media/dashboard.png", sampleLook(SAMPLE.before, SAMPLE.after, 1)],
+      ["media/thumbnail.png", sampleLook(SAMPLE.before, SAMPLE.resized, 2)],
+      ["media/logo.png", sampleLook(undefined, SAMPLE.logo, 3)],
+      ["media/old-banner.gif", sampleLook(SAMPLE.before, undefined, 4)],
+      ["media/poster.png", { problem: tooLargeText(12_000, 9_000), stamp: 5 }],
+    ]),
+  },
+
   /** Nothing to review. The first thing anyone sees, and the easiest to leave looking broken. */
   clean: {
     about: "no changes at all — what you see before the agent has done anything",
diff --git a/packages/review/src/core/git/sources.ts b/packages/review/src/core/git/sources.ts
index 7cd44b9b..c634bea0 100644
--- a/packages/review/src/core/git/sources.ts
+++ b/packages/review/src/core/git/sources.ts
@@ -10,10 +10,10 @@
  * repository, rather than mocked into agreeing with itself.
  */
 
-import { readFileSync } from "node:fs"
 import { join } from "node:path"
 import { countChanges, diffLines } from "../diff/hunks.ts"
-import type { FileChange } from "../model/review.ts"
+import { HEADER_BYTES, looksBinary, sniff } from "../image/sniff.ts"
+import type { BinarySide, FileChange } from "../model/review.ts"
 
 export interface GitResult {
   files: FileChange[]
@@ -25,8 +25,16 @@ export interface GitResult {
 
 /** More than this and the pane is not the right tool — and reading them all would stall the TUI. */
 const MAX_FILES = 200
-/** A file bigger than this is almost certainly not being read line by line. */
+/** A text file bigger than this is almost certainly not being read line by line. */
 const MAX_BYTES = 400_000
+/**
+ * A binary is read whole up to this — its own cap, far above the text one.
+ *
+ * Every real screenshot is over 400 KB, and under the text cap each one was skipped with "too large
+ * to review here": the file most worth a look silently was not in the review. Past this the header
+ * is still read, so the file is still named, sized and described.
+ */
+export const MAX_BINARY_BYTES = 32 * 1024 * 1024
 
 export type RunGit = (args: string[], cwd: string) => Promise<{ ok: boolean; out: string }>
 
@@ -55,21 +63,149 @@ export async function headOf(cwd: string, git: RunGit = runGit): Promise 0 ? head : undefined
 }
 
-/** A file's contents at a revision, or "" when it did not exist there — which is what a diff wants. */
-async function show(git: RunGit, cwd: string, revision: string, path: string): Promise {
-  const result = await git(["show", `${revision}:${path}`], cwd)
-  return result.ok ? result.out : ""
+/** Some or all of a file's bytes, and how many there are in all. */
+export interface Read {
+  bytes: Uint8Array
+  size: number
+  /** False when only the first bytes were read, the file being over the cap. */
+  whole: boolean
+}
+
+const joined = (chunks: readonly Uint8Array[], total: number): Uint8Array => {
+  if (chunks.length === 1) return chunks[0] as Uint8Array
+  const out = new Uint8Array(total)
+  let at = 0
+  for (const chunk of chunks) {
+    out.set(chunk, at)
+    at += chunk.length
+  }
+  return out
+}
+
+/**
+ * A file as git has it at a revision, as **bytes** — or undefined when it did not exist there.
+ *
+ * Read as text, git's output went through UTF-8 and a 91 KB PNG came out as 166 KB of something else:
+ * the bytes were gone, not merely ugly. `cat-file blob` rather than `show`, so nothing (a textconv, a
+ * pager) stands between the blob and what is read. Past `cap` the stream is cut and only the size is
+ * asked for — a 200 MB asset is described, never held.
+ */
+export async function readBlob(
+  cwd: string,
+  revision: string,
+  path: string,
+  cap = MAX_BINARY_BYTES,
+): Promise {
+  const proc = Bun.spawn(["git", "cat-file", "blob", `${revision}:${path}`], {
+    cwd,
+    stdout: "pipe",
+    stderr: "ignore",
+  })
+  const reader = proc.stdout.getReader()
+  const chunks: Uint8Array[] = []
+  let total = 0
+  let over = false
+  while (true) {
+    const { done, value } = await reader.read()
+    if (done) break
+    chunks.push(value)
+    total += value.length
+    if (total > cap) {
+      over = true
+      break
+    }
+  }
+  if (over) {
+    await reader.cancel().catch(() => {})
+    proc.kill()
+    await proc.exited
+    const sized = await runGit(["cat-file", "-s", `${revision}:${path}`], cwd)
+    const size = Number(sized.out.trim())
+    return {
+      bytes: joined(chunks, total).subarray(0, HEADER_BYTES),
+      size: sized.ok && Number.isFinite(size) ? size : total,
+      whole: false,
+    }
+  }
+  if ((await proc.exited) !== 0) return undefined
+  return { bytes: joined(chunks, total), size: total, whole: true }
 }
 
-function readWorking(cwd: string, path: string): { text: string; error?: string } {
+/**
+ * The working copy's bytes, or undefined when it is not there (deleted: an empty "after" is right).
+ *
+ * A file over the text cap is read in full only when its first bytes say it is binary and it is under
+ * the binary cap; otherwise its head is enough to say what it is.
+ */
+export async function readWorking(cwd: string, path: string): Promise {
   try {
-    const full = join(cwd, path)
-    const file = Bun.file(full)
-    if (file.size > MAX_BYTES) return { text: "", error: `${path}: too large to review here` }
-    return { text: readFileSync(full, "utf8") }
+    const file = Bun.file(join(cwd, path))
+    const size = file.size
+    if (size > MAX_BYTES) {
+      const head = new Uint8Array(await file.slice(0, HEADER_BYTES).arrayBuffer())
+      if (!looksBinary(head) || size > MAX_BINARY_BYTES) return { bytes: head, size, whole: false }
+    }
+    const bytes = new Uint8Array(await file.arrayBuffer())
+    return { bytes, size: bytes.length, whole: true }
   } catch {
-    // Deleted from the working tree: an empty "after" is exactly right.
-    return { text: "" }
+    return undefined
+  }
+}
+
+/** UTF-8, with a byte-order mark kept on both sides alike, so a BOM is never a change of its own. */
+const decoder = new TextDecoder("utf-8", { ignoreBOM: true })
+
+const sameBytes = (a: Read | undefined, b: Read | undefined): boolean => {
+  if (!a || !b) return a === b
+  if (!a.whole || !b.whole || a.size !== b.size) return false
+  return Buffer.from(a.bytes.buffer, a.bytes.byteOffset, a.bytes.length).equals(b.bytes)
+}
+
+/** One side of a binary: its size, and what its header says if it is an image. */
+const sideOf = (read: Read | undefined): BinarySide | undefined => {
+  if (!read) return undefined
+  const image = sniff(read.bytes)
+  return { size: read.size, ...(image ? { image } : {}) }
+}
+
+/**
+ * Both sides read, one file decided: text, binary, too large, or unchanged.
+ *
+ * Binary is decided once, for the pair — a file that became binary, or stopped being, is not a text
+ * diff on either side.
+ */
+export function decide(
+  path: string,
+  before: Read | undefined,
+  after: Read | undefined,
+  change: FileChange["change"],
+  from: string | undefined,
+  revision: string,
+): { file?: FileChange; error?: string } {
+  if (sameBytes(before, after) && change !== "renamed") return {}
+  if ((before && looksBinary(before.bytes)) || (after && looksBinary(after.bytes))) {
+    const sides = { before: sideOf(before), after: sideOf(after) }
+    return {
+      file: {
+        ...fileOf(path, "", "", change, from),
+        binary: {
+          ...(sides.before ? { before: sides.before } : {}),
+          ...(sides.after ? { after: sides.after } : {}),
+          ...(before ? { revision } : {}),
+        },
+      },
+    }
+  }
+  if ((after && !after.whole) || (after && after.size > MAX_BYTES) || (before && !before.whole))
+    return { error: `${path}: too large to review here` }
+  return {
+    file: fileOf(
+      path,
+      before ? decoder.decode(before.bytes) : "",
+      after ? decoder.decode(after.bytes) : "",
+      change,
+      from,
+    ),
   }
 }
 
@@ -102,14 +238,11 @@ export async function worktreeChanges(cwd: string, git: RunGit = runGit): Promis
       break
     }
     const change = statusChange(code, from)
-    const before = code.includes("?") || code.includes("C") ? "" : await show(git, cwd, "HEAD", from ?? path)
-    const { text: after, error } = readWorking(cwd, path)
-    if (error) {
-      errors.push(error)
-      continue
-    }
-    if (before === after && change !== "renamed") continue
-    files.push(fileOf(path, before, after, change, from))
+    const before =
+      code.includes("?") || code.includes("C") ? undefined : await readBlob(cwd, "HEAD", from ?? path)
+    const { file, error } = decide(path, before, await readWorking(cwd, path), change, from, "HEAD")
+    if (error) errors.push(error)
+    if (file) files.push(file)
   }
   return { files, errors }
 }
@@ -320,14 +453,10 @@ export async function branchChanges(
       break
     }
     const { change, from } = changed.get(path) ?? { change: undefined }
-    const before = change === "added" ? "" : await show(git, cwd, fork, from ?? path)
-    const { text: after, error } = readWorking(cwd, path)
-    if (error) {
-      errors.push(error)
-      continue
-    }
-    if (before === after && change !== "renamed") continue
-    files.push(fileOf(path, before, after, change, from))
+    const before = change === "added" ? undefined : await readBlob(cwd, fork, from ?? path)
+    const { file, error } = decide(path, before, await readWorking(cwd, path), change, from, fork)
+    if (error) errors.push(error)
+    if (file) files.push(file)
   }
   return { files, errors, base: against }
 }
@@ -337,5 +466,8 @@ export async function branchChanges(
  * sources for one number is how a list ends up saying +12 above a hunk showing eleven lines.
  */
 export function withCounts(files: readonly FileChange[]): FileChange[] {
-  return files.map((file) => ({ ...file, ...countChanges(diffLines(file.before, file.after)) }))
+  /** A binary has no lines to count; its card says what changed instead. */
+  return files.map((file) =>
+    file.binary ? file : { ...file, ...countChanges(diffLines(file.before, file.after)) },
+  )
 }
diff --git a/packages/review/src/core/image/describe.ts b/packages/review/src/core/image/describe.ts
new file mode 100644
index 00000000..89c29101
--- /dev/null
+++ b/packages/review/src/core/image/describe.ts
@@ -0,0 +1,89 @@
+/**
+ * A binary file's change, in words.
+ *
+ * `PNG 2880×1800 · 807 KB → 789 KB`, then `2.56% of pixels changed · 601×221 at 1900,300`. In order of
+ * how often each is the whole answer (docs/opencode/images.md): did it change and how, how much and
+ * where. Plain strings, so the card, the preview CLI and anything else that ever has to say it say the
+ * same thing.
+ */
+
+import type { BinaryChange, BinarySide } from "../model/review.ts"
+import type { PixelDiff } from "./pixels.ts"
+import { formatName } from "./sniff.ts"
+
+/** `512 B`, `12.3 KB`, `807 KB`, `13.5 MB`: three significant figures at most, in 1024s. */
+export function sizeText(bytes: number): string {
+  if (bytes < 1024) return `${bytes} B`
+  const units = ["KB", "MB", "GB"]
+  let value = bytes / 1024
+  let unit = 0
+  while (value >= 1024 && unit < units.length - 1) {
+    value /= 1024
+    unit++
+  }
+  const shown = value >= 100 ? Math.round(value).toString() : value.toFixed(1)
+  return `${shown} ${units[unit]}`
+}
+
+const dimensions = (side: BinarySide | undefined): string =>
+  side?.image ? `${side.image.width}×${side.image.height}` : ""
+
+const kind = (side: BinarySide | undefined): string => (side?.image ? formatName(side.image) : "binary")
+
+/**
+ * What the file is, and what happened to its size and shape.
+ *
+ *   PNG 2880×1800 · 807 KB → 789 KB          edited, same dimensions
+ *   PNG 2880×1800 → 1440×900 · 807 KB → 220 KB
+ *   PNG 640×400 → JPEG 640×400 · 91 KB → 40 KB
+ *   PNG 2880×1800 · 807 KB                    added or deleted: the one side there is
+ *   binary · 12.3 KB → 14.0 KB                not an image this bay can name
+ */
+export function summaryText(binary: BinaryChange): string {
+  const { before, after } = binary
+  const sizes = [before, after]
+    .filter((side): side is BinarySide => side !== undefined)
+    .map((side) => sizeText(side.size))
+    .join(" → ")
+  if (!before || !after) {
+    const side = before ?? after
+    return [kind(side), dimensions(side)].filter(Boolean).join(" ") + (sizes ? ` · ${sizes}` : "")
+  }
+  const was = [kind(before), dimensions(before)].filter(Boolean).join(" ")
+  const now = [kind(after), dimensions(after)].filter(Boolean).join(" ")
+  let shape: string
+  if (was === now) shape = was
+  else if (kind(before) === kind(after)) shape = `${was} → ${dimensions(after)}`
+  else shape = `${was} → ${now}`
+  return `${shape} · ${sizes}`
+}
+
+/** Whether both sides are images of one size — the only case a pixel percentage means anything. */
+export const sameDimensions = (binary: BinaryChange): boolean =>
+  binary.before?.image !== undefined &&
+  binary.after?.image !== undefined &&
+  binary.before.image.width === binary.after.image.width &&
+  binary.before.image.height === binary.after.image.height
+
+/** `2.56%`, `0.04%`, `<0.01%`, `100%`. */
+export function percentText(changed: number, total: number): string {
+  if (total === 0 || changed === 0) return "0%"
+  const percent = (changed / total) * 100
+  if (percent < 0.01) return "<0.01%"
+  if (percent >= 99.995 && changed < total) return ">99.99%"
+  if (changed === total) return "100%"
+  return `${percent < 10 ? percent.toFixed(2) : percent.toFixed(1)}%`
+}
+
+/**
+ * How much changed, and where: `2.56% of pixels changed · 601×221 at 1900,300`.
+ *
+ * Same pixels with different bytes is its own answer — the file was re-encoded or its metadata
+ * changed, and the picture did not.
+ */
+export function pixelText(diff: PixelDiff): string {
+  if (diff.changed === 0) return "Same pixels — only the encoding or metadata changed"
+  const box = diff.box
+  const where = box ? ` · ${box.width}×${box.height} at ${box.x},${box.y}` : ""
+  return `${percentText(diff.changed, diff.total)} of pixels changed${where}`
+}
diff --git a/packages/review/src/core/image/gif.ts b/packages/review/src/core/image/gif.ts
new file mode 100644
index 00000000..64ea73fd
--- /dev/null
+++ b/packages/review/src/core/image/gif.ts
@@ -0,0 +1,200 @@
+/**
+ * A GIF's first frame to RGBA, and how many frames it has.
+ *
+ * LZW, global and local colour tables, interlacing, the transparent index. Only the first frame is
+ * drawn — a review asks "what does it look like", and a frame counter answers "is it animated" — but
+ * every frame is walked over so the count is true. Byte-exact against PIL on the spike's fixtures.
+ */
+
+import type { Pixels } from "./png.ts"
+import { finish, finishSoon, SLICE, type Steps } from "./steps.ts"
+
+const u16 = (b: Uint8Array, o: number) => (b[o] ?? 0) | ((b[o + 1] ?? 0) << 8)
+
+function* gifSteps(b: Uint8Array): Steps {
+  if (b.length < 13 || String.fromCharCode(...b.subarray(0, 4)) !== "GIF8") throw new Error("not a GIF")
+  const width = u16(b, 6)
+  const height = u16(b, 8)
+  if (width === 0 || height === 0) throw new Error("GIF has no pixels")
+  const flags = b[10] as number
+  let o = 13
+  let global: Uint8Array | undefined
+  if (flags & 0x80) {
+    const size = 3 << ((flags & 7) + 1)
+    global = b.subarray(o, o + size)
+    o += size
+  }
+  const out = new Uint8Array(width * height * 4)
+  let transparent = -1
+  let frames = 0
+  let decoded = false
+  /** Skips a run of sub-blocks, each a length byte and that many bytes, ended by a zero. */
+  const skipBlocks = () => {
+    while (o < b.length && b[o] !== 0) o += (b[o] as number) + 1
+    o++
+  }
+  while (o < b.length) {
+    const block = b[o++] as number
+    if (block === 0x3b) break
+    if (block === 0x21) {
+      const label = b[o++] as number
+      /** The graphic control extension before the first image says which index is see-through. */
+      if (label === 0xf9 && !decoded && ((b[o + 1] ?? 0) & 1) === 1) transparent = b[o + 4] ?? -1
+      skipBlocks()
+      continue
+    }
+    if (block !== 0x2c) throw new Error("GIF block not understood")
+    frames++
+    const left = u16(b, o)
+    const top = u16(b, o + 2)
+    const fw = u16(b, o + 4)
+    const fh = u16(b, o + 6)
+    const frameFlags = b[o + 8] ?? 0
+    o += 9
+    let table = global
+    if (frameFlags & 0x80) {
+      const size = 3 << ((frameFlags & 7) + 1)
+      table = b.subarray(o, o + size)
+      o += size
+    }
+    const minCode = b[o++] ?? 2
+    if (decoded) {
+      skipBlocks()
+      continue
+    }
+    const parts: Uint8Array[] = []
+    let total = 0
+    while (o < b.length && b[o] !== 0) {
+      const n = b[o] as number
+      parts.push(b.subarray(o + 1, o + 1 + n))
+      total += n
+      o += n + 1
+    }
+    o++
+    const data = new Uint8Array(total)
+    let written = 0
+    for (const part of parts) {
+      data.set(part, written)
+      written += part.length
+    }
+    const indices = yield* lzw(data, minCode, fw * fh)
+    const rows = frameFlags & 0x40 ? interlaceOrder(fh) : undefined
+    for (let y = 0; y < fh; y++) {
+      const dy = top + (rows ? (rows[y] as number) : y)
+      if (dy >= height) continue
+      for (let x = 0; x < fw; x++) {
+        const dx = left + x
+        if (dx >= width) continue
+        const index = indices[y * fw + x] as number
+        if (index === transparent) continue
+        const d = (dy * width + dx) * 4
+        out[d] = table?.[index * 3] ?? 0
+        out[d + 1] = table?.[index * 3 + 1] ?? 0
+        out[d + 2] = table?.[index * 3 + 2] ?? 0
+        out[d + 3] = 255
+      }
+    }
+    decoded = true
+    yield
+  }
+  if (!decoded) throw new Error("GIF has no image")
+  return { width, height, data: out, frames }
+}
+
+/** The four passes of an interlaced GIF, as the order its rows arrive in. */
+function interlaceOrder(h: number): number[] {
+  const rows: number[] = []
+  for (const [start, step] of [
+    [0, 8],
+    [4, 8],
+    [2, 4],
+    [1, 2],
+  ] as const)
+    for (let y = start; y < h; y += step) rows.push(y)
+  return rows
+}
+
+/** GIF's variable-width LZW to colour indices, yielding every slice of output. */
+function* lzw(data: Uint8Array, minCode: number, count: number): Steps {
+  const out = new Uint8Array(count)
+  const clear = 1 << minCode
+  const end = clear + 1
+  const prefix = new Int16Array(4096)
+  const suffix = new Uint8Array(4096)
+  const first = new Uint8Array(4096)
+  const stack = new Uint8Array(4097)
+  let size = minCode + 1
+  let mask = (1 << size) - 1
+  let next = end + 1
+  let old = -1
+  let bits = 0
+  let acc = 0
+  let pos = 0
+  let w = 0
+  let work = 0
+  for (let i = 0; i < clear; i++) {
+    prefix[i] = -1
+    suffix[i] = i
+    first[i] = i
+  }
+  while (w < count) {
+    while (bits < size) {
+      if (pos >= data.length) return out
+      acc |= (data[pos++] as number) << bits
+      bits += 8
+    }
+    const code = acc & mask
+    acc >>>= size
+    bits -= size
+    if (code === clear) {
+      size = minCode + 1
+      mask = (1 << size) - 1
+      next = end + 1
+      old = -1
+      continue
+    }
+    if (code === end) break
+    if (old === -1) {
+      out[w++] = suffix[code] as number
+      old = code
+      continue
+    }
+    let c = code
+    let sp = 0
+    if (code >= next) {
+      stack[sp++] = first[old] as number
+      c = old
+    }
+    while (c >= clear) {
+      stack[sp++] = suffix[c] as number
+      c = prefix[c] as number
+    }
+    stack[sp++] = suffix[c] as number
+    if (next < 4096) {
+      prefix[next] = old
+      suffix[next] = first[c] as number
+      first[next] = first[old] as number
+      next++
+      if ((next & mask) === 0 && next < 4096) {
+        size++
+        mask = (1 << size) - 1
+      }
+    }
+    const before = w
+    while (sp > 0 && w < count) out[w++] = stack[--sp] as number
+    old = code
+    work += w - before
+    if (work >= SLICE) {
+      work = 0
+      yield
+    }
+  }
+  return out
+}
+
+/** Decodes now, on this thread. For tests and the preview CLI. */
+export const decodeGif = (file: Uint8Array): Pixels => finish(gifSteps(file))
+
+/** Decodes a slice at a time, giving the event loop back between them. For the pane. */
+export const decodeGifSoon = (file: Uint8Array, pause?: () => Promise): Promise =>
+  finishSoon(gifSteps(file), pause)
diff --git a/packages/review/src/core/image/looks.ts b/packages/review/src/core/image/looks.ts
new file mode 100644
index 00000000..6df37bc9
--- /dev/null
+++ b/packages/review/src/core/image/looks.ts
@@ -0,0 +1,215 @@
+/**
+ * Looking at changed images: decode, compare, keep a small copy — off the draw path, and once.
+ *
+ * The pane shows a binary's metadata the moment git is read; this is what fills in the rest when it
+ * is ready — the pixel diff and the two thumbs the preview is drawn from. Decoding a screenshot costs
+ * 50–100 ms, so it never happens inside a paint: it runs here, in slices (`steps.ts`), one file at a
+ * time, and asks for a paint when each file is done.
+ *
+ * Cached by the bytes' hash, not by path: reloading the review (`g`, a source switch) re-reads the
+ * bytes — 8 ms through git — and finds every picture it has already decoded.
+ */
+
+import type { FileChange } from "../model/review.ts"
+import { decodeGifSoon } from "./gif.ts"
+import { comparePixels, type PixelDiff, shrink, type Thumb } from "./pixels.ts"
+import { decodePngSoon, type Pixels } from "./png.ts"
+import { decodable, type ImageInfo, sniff } from "./sniff.ts"
+import { finishSoon, turn } from "./steps.ts"
+
+/**
+ * Past this many pixels a side is described and not decoded: 4096×4096, 64 MB of RGBA. A 5K
+ * screenshot (5120×2880) fits; a print-resolution scan does not, and `o` is the answer for it.
+ */
+export const MAX_PIXELS = 4096 * 4096
+
+/** What a pane can show of an image change beyond its metadata. */
+export interface ImageLook {
+  /** Still working: the card says so where the preview will appear. */
+  pending?: boolean
+  before?: Thumb
+  after?: Thumb
+  /** Only for two decodable images of the same size: anything else would be a percentage that lies. */
+  diff?: PixelDiff
+  /** Frames in an animation, when there is more than one. */
+  frames?: { before?: number; after?: number }
+  /** Why there is no picture where there could have been one. */
+  problem?: string
+  /** Changes whenever the look does, so a cached row knows it is stale. */
+  stamp: number
+}
+
+export type Side = "before" | "after"
+
+/** Reads one side's bytes in full, or undefined when there is nothing to read (or too much). */
+export type ReadSide = (file: FileChange, side: Side) => Promise
+
+/** Whether a file has anything worth decoding: an image this bay can decode on at least one side. */
+export const worthLooking = (file: FileChange): boolean =>
+  file.binary !== undefined && (decodable(file.binary.before?.image) || decodable(file.binary.after?.image))
+
+const tooBig = (info: ImageInfo | undefined): boolean =>
+  info !== undefined && info.width * info.height > MAX_PIXELS
+
+/** Said where the picture would be; the card's `[o]` right under it is the way to see it anyway. */
+export const tooLargeText = (width: number, height: number): string =>
+  `Too large to preview here — ${Math.round((width * height) / 1e6)} megapixels, past ${Math.round(MAX_PIXELS / 1e6)}`
+
+interface Decoded {
+  thumb: Thumb
+  frames?: number
+}
+
+/** Small copies by content hash, so a reload never decodes twice. Thumbs are ≤ 300 KB each. */
+const thumbs = new Map()
+const diffs = new Map()
+const REMEMBERED = 48
+
+const remember = (map: Map, key: string, value: T) => {
+  if (map.size >= REMEMBERED) {
+    const oldest = map.keys().next().value
+    if (oldest !== undefined) map.delete(oldest)
+  }
+  map.set(key, value)
+}
+
+const hashOf = (bytes: Uint8Array): string => `${Bun.hash(bytes).toString(36)}:${bytes.length}`
+
+const decode = (info: ImageInfo, bytes: Uint8Array, pause: () => Promise): Promise =>
+  info.format === "png" ? decodePngSoon(bytes, pause) : decodeGifSoon(bytes, pause)
+
+const message = (error: unknown) => (error instanceof Error ? error.message : String(error))
+
+let stamps = 0
+
+/**
+ * Everything worth showing about one image change: thumbs for whichever sides decode, and the pixel
+ * diff when both do and their dimensions match.
+ */
+export async function analyse(
+  file: FileChange,
+  read: ReadSide,
+  pause: () => Promise = turn,
+): Promise {
+  const binary = file.binary
+  const look: ImageLook = { stamp: ++stamps }
+  if (!binary) return look
+  const sides: Side[] = ["before", "after"]
+  const problems: string[] = []
+  const named = (side: Side) => (side === "before" ? "old" : "new")
+
+  /** Both sides' bytes first: reading is cheap, and the hashes decide what needs decoding at all. */
+  const bytes: Partial> = {}
+  for (const side of sides) {
+    const info = binary[side]?.image
+    if (!info || !decodable(info)) continue
+    if (tooBig(info)) {
+      problems.push(tooLargeText(info.width, info.height))
+      continue
+    }
+    const data = await read(file, side)
+    /** Sniffed again: the working copy may have moved on since git was read. */
+    const now = data ? sniff(data) : undefined
+    if (!data || !now || now.format !== info.format || tooBig(now)) {
+      problems.push(`the ${named(side)} side could not be read`)
+      continue
+    }
+    bytes[side] = { data, info: now, hash: hashOf(data) }
+  }
+
+  const before = bytes.before
+  const after = bytes.after
+  const pair =
+    before && after && before.info.width === after.info.width && before.info.height === after.info.height
+      ? `${before.hash}>${after.hash}`
+      : undefined
+  /** The full pixels are only kept when a diff needs them and none is cached. */
+  const comparing = pair !== undefined && !diffs.has(pair)
+  const full: Partial> = {}
+
+  for (const side of sides) {
+    const got = bytes[side]
+    if (!got) continue
+    let known = thumbs.get(got.hash)
+    if (!known || comparing) {
+      try {
+        const pixels = await decode(got.info, got.data, pause)
+        if (comparing) full[side] = pixels
+        known ??= {
+          thumb: await finishSoon(shrink(pixels), pause),
+          ...(pixels.frames && pixels.frames > 1 ? { frames: pixels.frames } : {}),
+        }
+        remember(thumbs, got.hash, known)
+      } catch (error) {
+        problems.push(`could not decode the ${named(side)} side: ${message(error)}`)
+        continue
+      }
+    }
+    look[side] = known.thumb
+    if (known.frames) look.frames = { ...look.frames, [side]: known.frames }
+  }
+
+  if (pair) {
+    const cached = diffs.get(pair)
+    if (cached) look.diff = cached
+    else if (full.before && full.after && look.after) {
+      const diff = await finishSoon(
+        comparePixels(full.before, full.after, look.after.width, look.after.height),
+        pause,
+      )
+      remember(diffs, pair, diff)
+      look.diff = diff
+    }
+  }
+  if (problems[0]) look.problem = problems[0]
+  return look
+}
+
+export interface Looks {
+  /** What is known about each image change, by path. A new map whenever anything in it changes. */
+  current: () => ReadonlyMap
+  /** Starts looking at these files' images, one at a time; a later call supersedes an earlier one. */
+  sync: (files: readonly FileChange[]) => void
+}
+
+/**
+ * The pane's view of the images under review.
+ *
+ * `changed` asks for a paint. A file being worked on keeps what it showed before, marked pending,
+ * so a reload does not blank every preview on screen and draw them back one by one.
+ */
+export function createLooks(read: ReadSide, changed: () => void, pause: () => Promise = turn): Looks {
+  let looks: ReadonlyMap = new Map()
+  let generation = 0
+
+  const put = (path: string, look: ImageLook | undefined) => {
+    const next = new Map(looks)
+    if (look) next.set(path, look)
+    else next.delete(path)
+    looks = next
+  }
+
+  return {
+    current: () => looks,
+    sync(files) {
+      const mine = ++generation
+      const wanted = files.filter(worthLooking)
+      /** Files no longer in the review are forgotten; the rest show "decoding…" until they are done. */
+      const kept = new Map()
+      for (const file of wanted)
+        kept.set(file.path, { ...(looks.get(file.path) ?? {}), pending: true, stamp: ++stamps })
+      looks = kept
+      if (wanted.length > 0) changed()
+      void (async () => {
+        for (const file of wanted) {
+          const look = await analyse(file, read, pause).catch(
+            (error): ImageLook => ({ problem: message(error), stamp: ++stamps }),
+          )
+          if (mine !== generation) return
+          put(file.path, look)
+          changed()
+        }
+      })()
+    },
+  }
+}
diff --git a/packages/review/src/core/image/pixels.ts b/packages/review/src/core/image/pixels.ts
new file mode 100644
index 00000000..e412b15f
--- /dev/null
+++ b/packages/review/src/core/image/pixels.ts
@@ -0,0 +1,265 @@
+/**
+ * Working with decoded pixels: comparing two images, shrinking one, and turning it into cells.
+ *
+ * Kept apart from the decoders because none of it cares where the pixels came from, and apart from
+ * the view because the expensive parts — every pixel of a 2880×1800 screenshot — must run in slices
+ * off the draw path, while the cheap part (a 320-pixel copy into fifty cells) runs inside a paint.
+ */
+
+import type { Pixels } from "./png.ts"
+import { SLICE, type Steps } from "./steps.ts"
+
+/**
+ * A small copy of an image, kept instead of the image.
+ *
+ * Mid-resolution on purpose: big enough that a wider pane or a width cycle (`w`) re-derives sharper
+ * cells from it in well under a millisecond, small enough (≤ 300 KB) that keeping one per side of
+ * every image in a review costs nothing. Full-size pixels are dropped as soon as this exists.
+ */
+export interface Thumb {
+  width: number
+  height: number
+  /** 8-bit RGBA. Alpha is kept: what it is composited over is the pane's colour, decided at paint. */
+  data: Uint8Array
+}
+
+/** The largest a thumb gets, either way. */
+export const THUMB_WIDTH = 320
+export const THUMB_HEIGHT = 240
+
+/** A grid over the image marking which parts changed, at the thumb's resolution. */
+export interface ChangeMask {
+  width: number
+  height: number
+  /** One byte per cell, non-zero where any pixel inside it changed. */
+  data: Uint8Array
+}
+
+export interface PixelDiff {
+  /** Pixels that differ by more than the tolerance in any channel. */
+  changed: number
+  total: number
+  /** The rectangle holding every changed pixel, in the image's own pixels. */
+  box?: { x: number; y: number; width: number; height: number }
+  mask?: ChangeMask
+}
+
+/** Dimensions that fit inside `maxWidth × maxHeight` with the aspect kept, never larger than the source. */
+export function fitWithin(width: number, height: number, maxWidth: number, maxHeight: number) {
+  const scale = Math.min(1, maxWidth / width, maxHeight / height)
+  return {
+    width: Math.max(1, Math.round(width * scale)),
+    height: Math.max(1, Math.round(height * scale)),
+  }
+}
+
+/**
+ * Box-average downscale, every source pixel read once, colour weighted by alpha so a transparent
+ * pixel's (meaningless) colour does not bleed into its neighbours.
+ */
+export function* shrink(source: Pixels, maxWidth = THUMB_WIDTH, maxHeight = THUMB_HEIGHT): Steps {
+  const { width: w, height: h } = fitWithin(source.width, source.height, maxWidth, maxHeight)
+  const out = new Uint8Array(w * h * 4)
+  const r = new Float64Array(w)
+  const g = new Float64Array(w)
+  const b = new Float64Array(w)
+  const a = new Float64Array(w)
+  const n = new Float64Array(w)
+  const column = new Uint32Array(source.width)
+  for (let x = 0; x < source.width; x++) column[x] = Math.min(w - 1, Math.floor((x * w) / source.width))
+  const d = source.data
+  let work = 0
+  for (let ty = 0; ty < h; ty++) {
+    r.fill(0)
+    g.fill(0)
+    b.fill(0)
+    a.fill(0)
+    n.fill(0)
+    const y0 = Math.floor((ty * source.height) / h)
+    const y1 = Math.max(y0 + 1, Math.floor(((ty + 1) * source.height) / h))
+    for (let y = y0; y < y1; y++) {
+      for (let x = 0, i = y * source.width * 4; x < source.width; x++, i += 4) {
+        const tx = column[x] as number
+        const alpha = d[i + 3] as number
+        r[tx] = (r[tx] as number) + (d[i] as number) * alpha
+        g[tx] = (g[tx] as number) + (d[i + 1] as number) * alpha
+        b[tx] = (b[tx] as number) + (d[i + 2] as number) * alpha
+        a[tx] = (a[tx] as number) + alpha
+        n[tx] = (n[tx] as number) + 1
+      }
+    }
+    for (let tx = 0; tx < w; tx++) {
+      const weight = a[tx] as number
+      const o = (ty * w + tx) * 4
+      if (weight > 0) {
+        out[o] = Math.round((r[tx] as number) / weight)
+        out[o + 1] = Math.round((g[tx] as number) / weight)
+        out[o + 2] = Math.round((b[tx] as number) / weight)
+      }
+      out[o + 3] = Math.round(weight / ((n[tx] as number) || 1))
+    }
+    work += (y1 - y0) * source.width
+    if (work >= SLICE) {
+      work = 0
+      yield
+    }
+  }
+  return { width: w, height: h, data: out }
+}
+
+/**
+ * Two same-size images compared pixel by pixel, at full resolution.
+ *
+ * Words first: identical pixels — nearly all of a re-taken screenshot — cost one 32-bit compare, and
+ * only a mismatch is checked per channel against `tolerance`, which absorbs an encoder's rounding.
+ * 6 ms for 2880×1800 in the spike. The mask is built on the way, at `maskWidth × maskHeight`, so the
+ * preview can light the cells that changed without a second pass over the pixels.
+ */
+export function* comparePixels(
+  before: Pixels,
+  after: Pixels,
+  maskWidth: number,
+  maskHeight: number,
+  tolerance = 8,
+): Steps {
+  if (before.width !== after.width || before.height !== after.height)
+    throw new Error("a pixel diff needs two images of one size")
+  const { width, height } = after
+  const da = before.data
+  const db = after.data
+  const wa = new Uint32Array(da.buffer, da.byteOffset, da.length >> 2)
+  const wb = new Uint32Array(db.buffer, db.byteOffset, db.length >> 2)
+  const mask = new Uint8Array(maskWidth * maskHeight)
+  const mx = new Uint32Array(width)
+  for (let x = 0; x < width; x++) mx[x] = Math.min(maskWidth - 1, Math.floor((x * maskWidth) / width))
+  let changed = 0
+  let x0 = width
+  let y0 = height
+  let x1 = -1
+  let y1 = -1
+  let work = 0
+  for (let y = 0, p = 0; y < height; y++) {
+    const row = Math.min(maskHeight - 1, Math.floor((y * maskHeight) / height)) * maskWidth
+    for (let x = 0; x < width; x++, p++) {
+      if (wa[p] === wb[p]) continue
+      const i = p * 4
+      if (
+        Math.abs((da[i] as number) - (db[i] as number)) > tolerance ||
+        Math.abs((da[i + 1] as number) - (db[i + 1] as number)) > tolerance ||
+        Math.abs((da[i + 2] as number) - (db[i + 2] as number)) > tolerance ||
+        Math.abs((da[i + 3] as number) - (db[i + 3] as number)) > tolerance
+      ) {
+        changed++
+        mask[row + (mx[x] as number)] = 1
+        if (x < x0) x0 = x
+        if (x > x1) x1 = x
+        if (y < y0) y0 = y
+        if (y > y1) y1 = y
+      }
+    }
+    work += width
+    if (work >= SLICE * 4) {
+      work = 0
+      yield
+    }
+  }
+  return {
+    changed,
+    total: width * height,
+    ...(changed > 0
+      ? {
+          box: { x: x0, y: y0, width: x1 - x0 + 1, height: y1 - y0 + 1 },
+          mask: { width: maskWidth, height: maskHeight, data: mask },
+        }
+      : {}),
+  }
+}
+
+/**
+ * A picture in half-block cells: `â–€` with the top pixel as ink and the bottom as background, so one
+ * cell is two square-ish pixels. Colours are packed `0xRRGGBB`.
+ */
+export interface Cells {
+  cols: number
+  rows: number
+  top: Uint32Array
+  bottom: Uint32Array
+}
+
+/** The grid of pixels (one column × half a row each) a picture takes inside `cols × rows` cells. */
+export function cellGrid(width: number, height: number, cols: number, rows: number) {
+  const fit = fitWithin(width, height, cols, rows * 2)
+  /** A cell holds two pixels, so the height rounds to whole cells — never less than one. */
+  return { width: fit.width, height: Math.max(2, fit.height + (fit.height % 2)) }
+}
+
+/** `colour` pulled `amount` of the way toward `toward`. */
+export const mix = (colour: number, toward: number, amount: number): number => {
+  const channel = (shift: number) =>
+    Math.round(((colour >> shift) & 255) * (1 - amount) + ((toward >> shift) & 255) * amount)
+  return (channel(16) << 16) | (channel(8) << 8) | channel(0)
+}
+
+/** How far an unchanged part of a picture is faded, so the change is what the eye lands on. */
+export const FADE = 0.55
+
+/**
+ * A thumb as cells inside `cols × rows`, composited over `canvas` (the pane's own colour).
+ *
+ * With a mask, every pixel whose part of the image did not change is faded toward the canvas: the
+ * picture stays recognisable, and what changed is the only thing at full strength.
+ */
+export function toCells(thumb: Thumb, cols: number, rows: number, canvas: number, mask?: ChangeMask): Cells {
+  const grid = cellGrid(thumb.width, thumb.height, cols, rows)
+  const w = grid.width
+  const h = grid.height
+  const px = new Uint32Array(w * h)
+  const d = thumb.data
+  const cr = (canvas >> 16) & 255
+  const cg = (canvas >> 8) & 255
+  const cb = canvas & 255
+  for (let ty = 0; ty < h; ty++) {
+    const y0 = Math.floor((ty * thumb.height) / h)
+    const y1 = Math.max(y0 + 1, Math.floor(((ty + 1) * thumb.height) / h))
+    for (let tx = 0; tx < w; tx++) {
+      const x0 = Math.floor((tx * thumb.width) / w)
+      const x1 = Math.max(x0 + 1, Math.floor(((tx + 1) * thumb.width) / w))
+      let r = 0
+      let g = 0
+      let b = 0
+      let n = 0
+      for (let y = y0; y < y1; y++)
+        for (let x = x0, i = (y * thumb.width + x0) * 4; x < x1; x++, i += 4) {
+          const alpha = (d[i + 3] as number) / 255
+          r += (d[i] as number) * alpha + cr * (1 - alpha)
+          g += (d[i + 1] as number) * alpha + cg * (1 - alpha)
+          b += (d[i + 2] as number) * alpha + cb * (1 - alpha)
+          n++
+        }
+      let colour = (Math.round(r / n) << 16) | (Math.round(g / n) << 8) | Math.round(b / n)
+      if (mask && !maskHit(mask, tx / w, ty / h, (tx + 1) / w, (ty + 1) / h))
+        colour = mix(colour, canvas, FADE)
+      px[ty * w + tx] = colour
+    }
+  }
+  const cellRows = h / 2
+  const top = new Uint32Array(w * cellRows)
+  const bottom = new Uint32Array(w * cellRows)
+  for (let row = 0; row < cellRows; row++)
+    for (let x = 0; x < w; x++) {
+      top[row * w + x] = px[row * 2 * w + x] as number
+      bottom[row * w + x] = px[(row * 2 + 1) * w + x] as number
+    }
+  return { cols: w, rows: cellRows, top, bottom }
+}
+
+/** Whether any marked cell of the mask falls inside this fraction of the image. */
+function maskHit(mask: ChangeMask, fx0: number, fy0: number, fx1: number, fy1: number): boolean {
+  const x0 = Math.floor(fx0 * mask.width)
+  const y0 = Math.floor(fy0 * mask.height)
+  const x1 = Math.max(x0 + 1, Math.ceil(fx1 * mask.width))
+  const y1 = Math.max(y0 + 1, Math.ceil(fy1 * mask.height))
+  for (let y = y0; y < y1 && y < mask.height; y++)
+    for (let x = x0; x < x1 && x < mask.width; x++) if (mask.data[y * mask.width + x]) return true
+  return false
+}
diff --git a/packages/review/src/core/image/png.ts b/packages/review/src/core/image/png.ts
new file mode 100644
index 00000000..4fa142c4
--- /dev/null
+++ b/packages/review/src/core/image/png.ts
@@ -0,0 +1,282 @@
+/**
+ * PNG to RGBA, in TypeScript and `node:zlib`.
+ *
+ * Chunks, inflate, unfilter (None/Sub/Up/Average/Paeth), expand to 8-bit RGBA. Every colour type
+ * (grey, RGB, palette, grey+alpha, RGBA), every bit depth (1/2/4/8/16), palette transparency and the
+ * grey/RGB colour key, and Adam7 interlacing. Gamma and ICC are ignored: this is for a pixel diff and a
+ * preview a few dozen cells wide, not for proofing colour.
+ *
+ * Byte-exact against PIL on every fixture in `test/image/` (the spike's twelve, up to 20.7 MB of RGBA).
+ */
+
+import { inflate, inflateSync } from "node:zlib"
+import { finish, finishSoon, SLICE, type Steps } from "./steps.ts"
+
+export interface Pixels {
+  width: number
+  height: number
+  /** 8-bit RGBA, row-major, `width * height * 4` bytes. */
+  data: Uint8Array
+  /** For an animation: how many frames it has. Only the first is decoded. */
+  frames?: number
+}
+
+interface PngParts {
+  width: number
+  height: number
+  depth: number
+  colour: number
+  interlace: number
+  palette?: Uint8Array
+  trns?: Uint8Array
+  /** Every IDAT's payload, joined: one zlib stream. */
+  idat: Uint8Array
+}
+
+/** Samples per pixel, by colour type. */
+const CHANNELS: Record = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }
+/** Bit depths the specification allows for each colour type. */
+const DEPTHS: Record = {
+  0: [1, 2, 4, 8, 16],
+  2: [8, 16],
+  3: [1, 2, 4, 8],
+  4: [8, 16],
+  6: [8, 16],
+}
+
+const u32 = (b: Uint8Array, o: number) =>
+  (b[o] as number) * 0x1000000 +
+  ((b[o + 1] as number) << 16) +
+  ((b[o + 2] as number) << 8) +
+  (b[o + 3] as number)
+
+function partsOf(file: Uint8Array): PngParts {
+  let o = 8
+  let header: Omit | undefined
+  let palette: Uint8Array | undefined
+  let trns: Uint8Array | undefined
+  const idat: Uint8Array[] = []
+  while (o + 8 <= file.length) {
+    const length = u32(file, o)
+    const type = String.fromCharCode(...file.subarray(o + 4, o + 8))
+    const body = file.subarray(o + 8, o + 8 + length)
+    if (type === "IHDR")
+      header = {
+        width: u32(body, 0),
+        height: u32(body, 4),
+        depth: body[8] ?? 0,
+        colour: body[9] ?? 0,
+        interlace: body[12] ?? 0,
+      }
+    else if (type === "PLTE") palette = body
+    else if (type === "tRNS") trns = body
+    else if (type === "IDAT") idat.push(body)
+    else if (type === "IEND") break
+    o += 12 + length
+  }
+  if (!header || header.width === 0 || header.height === 0) throw new Error("not a PNG")
+  if (!DEPTHS[header.colour]?.includes(header.depth))
+    throw new Error(`PNG colour type ${header.colour} at ${header.depth}-bit is not valid`)
+  if (header.colour === 3 && !palette) throw new Error("PNG palette missing")
+  if (idat.length === 0) throw new Error("PNG has no image data")
+  return { ...header, idat: idat.length === 1 ? (idat[0] as Uint8Array) : Buffer.concat(idat), palette, trns }
+}
+
+/**
+ * The inflated scanlines to RGBA, a slice at a time.
+ *
+ * Inflate is not in here: done asynchronously it already runs off the thread (the spike's longest
+ * stall inflating a 13.5 MB file was 1.2 ms), and that leaves unfiltering and expanding — the part
+ * that is JavaScript, and the part that has to yield.
+ */
+function* unfilterPng(parts: PngParts, raw: Uint8Array): Steps {
+  const { width, height, depth, colour, palette, trns } = parts
+  const channels = CHANNELS[colour] as number
+  /** Bytes per whole pixel, for the filters' "the pixel to the left". At least one, below 8-bit. */
+  const bpp = Math.max(1, (channels * depth) >> 3)
+  const out = new Uint8Array(width * height * 4)
+
+  /** Grey and RGB images name one colour as transparent, at the image's own depth. */
+  const key =
+    trns && colour === 0 && trns.length >= 2
+      ? [((trns[0] as number) << 8) | (trns[1] as number)]
+      : trns && colour === 2 && trns.length >= 6
+        ? [
+            ((trns[0] as number) << 8) | (trns[1] as number),
+            ((trns[2] as number) << 8) | (trns[3] as number),
+            ((trns[4] as number) << 8) | (trns[5] as number),
+          ]
+        : undefined
+  const max = (1 << depth) - 1
+  const scale = (v: number) => (depth === 16 ? v >> 8 : depth === 8 ? v : Math.round((v * 255) / max))
+
+  let read = 0
+  let work = 0
+
+  /** One row's samples into RGBA at its place in the image — every `dx` pixels from `x0` on row `y`. */
+  const expand = (line: Uint8Array, pw: number, y: number, x0: number, dx: number) => {
+    let dst = (y * width + x0) * 4
+    const step = dx * 4
+    /** The common cases, one branch per row rather than per pixel. */
+    if (depth === 8 && colour === 6) {
+      if (dx === 1) out.set(line.subarray(0, pw * 4), dst)
+      else
+        for (let i = 0, s = 0; i < pw; i++, s += 4, dst += step) {
+          out[dst] = line[s] as number
+          out[dst + 1] = line[s + 1] as number
+          out[dst + 2] = line[s + 2] as number
+          out[dst + 3] = line[s + 3] as number
+        }
+      return
+    }
+    if (depth === 8 && colour === 2) {
+      for (let i = 0, s = 0; i < pw; i++, s += 3, dst += step) {
+        const r = line[s] as number
+        const g = line[s + 1] as number
+        const b = line[s + 2] as number
+        out[dst] = r
+        out[dst + 1] = g
+        out[dst + 2] = b
+        out[dst + 3] = key && r === key[0] && g === key[1] && b === key[2] ? 0 : 255
+      }
+      return
+    }
+    const sample = (index: number): number => {
+      if (depth === 8) return line[index] as number
+      if (depth === 16) return ((line[index * 2] as number) << 8) | (line[index * 2 + 1] as number)
+      const bit = index * depth
+      return ((line[bit >> 3] as number) >> (8 - depth - (bit & 7))) & max
+    }
+    for (let i = 0; i < pw; i++, dst += step) {
+      let r: number
+      let g: number
+      let b: number
+      let a = 255
+      switch (colour) {
+        case 0: {
+          const v = sample(i)
+          r = g = b = scale(v)
+          if (key && v === key[0]) a = 0
+          break
+        }
+        case 2: {
+          const vr = sample(i * 3)
+          const vg = sample(i * 3 + 1)
+          const vb = sample(i * 3 + 2)
+          r = scale(vr)
+          g = scale(vg)
+          b = scale(vb)
+          if (key && vr === key[0] && vg === key[1] && vb === key[2]) a = 0
+          break
+        }
+        case 3: {
+          const p = sample(i)
+          r = palette?.[p * 3] ?? 0
+          g = palette?.[p * 3 + 1] ?? 0
+          b = palette?.[p * 3 + 2] ?? 0
+          a = trns && p < trns.length ? (trns[p] as number) : 255
+          break
+        }
+        case 4:
+          r = g = b = scale(sample(i * 2))
+          a = scale(sample(i * 2 + 1))
+          break
+        default:
+          r = scale(sample(i * 4))
+          g = scale(sample(i * 4 + 1))
+          b = scale(sample(i * 4 + 2))
+          a = scale(sample(i * 4 + 3))
+      }
+      out[dst] = r
+      out[dst + 1] = g
+      out[dst + 2] = b
+      out[dst + 3] = a
+    }
+  }
+
+  /** One pass of the image — all of it, or one of Adam7's seven — with a yield every slice of pixels. */
+  function* pass(x0: number, y0: number, dx: number, dy: number): Steps {
+    const pw = Math.ceil((width - x0) / dx)
+    const ph = Math.ceil((height - y0) / dy)
+    if (pw <= 0 || ph <= 0) return
+    const stride = Math.ceil((pw * channels * depth) / 8)
+    let previous = new Uint8Array(stride)
+    let current = new Uint8Array(stride)
+    for (let row = 0; row < ph; row++) {
+      if (read + 1 + stride > raw.length) throw new Error("PNG image data is cut short")
+      const filter = raw[read++] as number
+      current.set(raw.subarray(read, read + stride))
+      read += stride
+      unfilter(filter, current, previous, bpp)
+      expand(current, pw, y0 + row * dy, x0, dx)
+      const swap = previous
+      previous = current
+      current = swap
+      work += pw
+      if (work >= SLICE) {
+        work = 0
+        yield
+      }
+    }
+  }
+
+  if (parts.interlace) {
+    yield* pass(0, 0, 8, 8)
+    yield* pass(4, 0, 8, 8)
+    yield* pass(0, 4, 4, 8)
+    yield* pass(2, 0, 4, 4)
+    yield* pass(0, 2, 2, 4)
+    yield* pass(1, 0, 2, 2)
+    yield* pass(0, 1, 1, 2)
+  } else yield* pass(0, 0, 1, 1)
+  return { width, height, data: out }
+}
+
+function unfilter(filter: number, cur: Uint8Array, prev: Uint8Array, bpp: number) {
+  const n = cur.length
+  switch (filter) {
+    case 0:
+      return
+    case 1:
+      for (let i = bpp; i < n; i++) cur[i] = ((cur[i] as number) + (cur[i - bpp] as number)) & 255
+      return
+    case 2:
+      for (let i = 0; i < n; i++) cur[i] = ((cur[i] as number) + (prev[i] as number)) & 255
+      return
+    case 3:
+      for (let i = 0; i < bpp; i++) cur[i] = ((cur[i] as number) + ((prev[i] as number) >> 1)) & 255
+      for (let i = bpp; i < n; i++)
+        cur[i] = ((cur[i] as number) + (((cur[i - bpp] as number) + (prev[i] as number)) >> 1)) & 255
+      return
+    case 4:
+      for (let i = 0; i < bpp; i++) cur[i] = ((cur[i] as number) + (prev[i] as number)) & 255
+      for (let i = bpp; i < n; i++) {
+        const a = cur[i - bpp] as number
+        const b = prev[i] as number
+        const c = prev[i - bpp] as number
+        const p = a + b - c
+        const pa = Math.abs(p - a)
+        const pb = Math.abs(p - b)
+        const pc = Math.abs(p - c)
+        cur[i] = ((cur[i] as number) + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c)) & 255
+      }
+      return
+    default:
+      throw new Error(`PNG row filter ${filter} does not exist`)
+  }
+}
+
+/** Decodes now, on this thread. For tests and the preview CLI. */
+export function decodePng(file: Uint8Array): Pixels {
+  const parts = partsOf(file)
+  return finish(unfilterPng(parts, inflateSync(parts.idat)))
+}
+
+const inflateOffThread = (data: Uint8Array): Promise =>
+  new Promise((resolve, reject) => inflate(data, (error, out) => (error ? reject(error) : resolve(out))))
+
+/** Decodes without holding the thread: inflate in zlib's pool, the rest in slices. For the pane. */
+export async function decodePngSoon(file: Uint8Array, pause?: () => Promise): Promise {
+  const parts = partsOf(file)
+  return finishSoon(unfilterPng(parts, await inflateOffThread(parts.idat)), pause)
+}
diff --git a/packages/review/src/core/image/samples.ts b/packages/review/src/core/image/samples.ts
new file mode 100644
index 00000000..ec32439f
--- /dev/null
+++ b/packages/review/src/core/image/samples.ts
@@ -0,0 +1,64 @@
+/**
+ * Pictures drawn in code, for the preview CLI and the tests: a small "screenshot" of a dark UI, the
+ * same with one panel changed, and the same scaled down. Real pixels through the real pipeline — the
+ * shrink, the pixel diff, the cells — with no image file shipped.
+ */
+
+import type { ImageLook } from "./looks.ts"
+import { comparePixels, shrink } from "./pixels.ts"
+import type { Pixels } from "./png.ts"
+import { finish } from "./steps.ts"
+
+type Paint = (x: number, y: number) => [number, number, number, number]
+
+const picture = (width: number, height: number, paint: Paint): Pixels => {
+  const data = new Uint8Array(width * height * 4)
+  for (let y = 0; y < height; y++)
+    for (let x = 0; x < width; x++) data.set(paint(x / width, y / height), (y * width + x) * 4)
+  return { width, height, data }
+}
+
+const inside = (u: number, v: number, x0: number, y0: number, x1: number, y1: number) =>
+  u >= x0 && u < x1 && v >= y0 && v < y1
+
+/** A sidebar, a header, three cards and some "text" lines — the shape of the screenshots reviews get. */
+const scene =
+  (changed: boolean): Paint =>
+  (u, v) => {
+    if (inside(u, v, 0, 0, 1, 0.08)) return [49, 50, 68, 255]
+    if (inside(u, v, 0, 0.08, 0.2, 1))
+      return inside(u, v, 0.02, 0.14, 0.18, 0.2) ? [137, 180, 250, 255] : [24, 24, 37, 255]
+    if (inside(u, v, 0.24, 0.14, 0.48, 0.5)) return [166, 227, 161, 255]
+    if (inside(u, v, 0.52, 0.14, 0.96, 0.5))
+      return changed && inside(u, v, 0.7, 0.2, 0.92, 0.42) ? [243, 139, 168, 255] : [250, 179, 135, 255]
+    if (inside(u, v, 0.24, 0.56, 0.96, 0.94)) {
+      const row = Math.floor(v * 40)
+      return row % 3 === 0 && u < 0.3 + ((row * 37) % 60) / 100 ? [205, 214, 244, 255] : [30, 30, 46, 255]
+    }
+    return [30, 30, 46, 255]
+  }
+
+/** A logo with a transparent background, for an added file. */
+const logo: Paint = (u, v) => {
+  const d = (u - 0.5) ** 2 + (v - 0.5) ** 2
+  if (d < 0.16 && d > 0.09) return [203, 166, 247, 255]
+  if (d < 0.05) return [148, 226, 213, 255]
+  return [0, 0, 0, 0]
+}
+
+export const SAMPLE = {
+  before: picture(288, 180, scene(false)),
+  after: picture(288, 180, scene(true)),
+  resized: picture(144, 90, scene(false)),
+  logo: picture(96, 96, logo),
+}
+
+/** A look as the pane would have it once the pictures are decoded. */
+export function sampleLook(before: Pixels | undefined, after: Pixels | undefined, stamp: number): ImageLook {
+  const look: ImageLook = { stamp }
+  if (before) look.before = finish(shrink(before))
+  if (after) look.after = finish(shrink(after))
+  if (before && after && look.after && before.width === after.width && before.height === after.height)
+    look.diff = finish(comparePixels(before, after, look.after.width, look.after.height))
+  return look
+}
diff --git a/packages/review/src/core/image/sniff.ts b/packages/review/src/core/image/sniff.ts
new file mode 100644
index 00000000..866fd49d
--- /dev/null
+++ b/packages/review/src/core/image/sniff.ts
@@ -0,0 +1,154 @@
+/**
+ * What a file is, from its first bytes: binary or text, and — for an image — its format and size.
+ *
+ * No decoding. Every reader here looks at a few dozen bytes of header, so it costs nothing to run on
+ * every binary in a review (0.08 ms measured, docs/opencode/images.md), and it is what turns "a PNG
+ * shown as four hundred lines of U+FFFD" into one true sentence about it.
+ *
+ * Checked against PIL and `sips` on every fixture in `test/image/`: each PNG colour type and depth,
+ * interlaced, APNG, baseline and progressive JPEG, a JPEG whose size sits behind a 30 KB EXIF block,
+ * lossy, lossless and extended WebP, BMP.
+ */
+
+export type ImageFormat = "png" | "jpeg" | "gif" | "webp" | "bmp"
+
+export interface ImageInfo {
+  format: ImageFormat
+  width: number
+  height: number
+  /** An APNG, an animated WebP. A GIF's frames are only known once it is decoded. */
+  animated?: boolean
+}
+
+/** How many bytes git looks at to decide a file is binary, and how many this bay does. */
+export const BINARY_PROBE = 8000
+
+/**
+ * Git's own rule: a NUL in the first 8000 bytes means binary.
+ *
+ * The same rule as git so the two never disagree about a file — a file `git diff` calls binary and
+ * this bay drew as text (or the other way round) would be two answers to one question.
+ */
+export const looksBinary = (bytes: Uint8Array): boolean => bytes.subarray(0, BINARY_PROBE).indexOf(0) !== -1
+
+/** Enough of a file to find a JPEG's size behind its EXIF and ICC blocks, which come first. */
+export const HEADER_BYTES = 64 * 1024
+
+const at = (b: Uint8Array, o: number): number => b[o] ?? 0
+const u16be = (b: Uint8Array, o: number) => (at(b, o) << 8) | at(b, o + 1)
+const u16le = (b: Uint8Array, o: number) => at(b, o) | (at(b, o + 1) << 8)
+const u24le = (b: Uint8Array, o: number) => at(b, o) | (at(b, o + 1) << 8) | (at(b, o + 2) << 16)
+const u32be = (b: Uint8Array, o: number) =>
+  at(b, o) * 0x1000000 + (at(b, o + 1) << 16) + (at(b, o + 2) << 8) + at(b, o + 3)
+const u32le = (b: Uint8Array, o: number) =>
+  at(b, o) + (at(b, o + 1) << 8) + (at(b, o + 2) << 16) + at(b, o + 3) * 0x1000000
+const ascii = (b: Uint8Array, o: number, n: number) => String.fromCharCode(...b.subarray(o, o + n))
+
+/** PNG: the signature, then IHDR — always the first chunk. An `acTL` before the image data is an APNG. */
+export function readPng(b: Uint8Array): ImageInfo | undefined {
+  if (b.length < 33 || at(b, 0) !== 0x89 || ascii(b, 1, 3) !== "PNG" || ascii(b, 12, 4) !== "IHDR")
+    return undefined
+  let animated = false
+  for (let o = 8; o + 8 <= b.length; ) {
+    const type = ascii(b, o + 4, 4)
+    if (type === "acTL") animated = true
+    if (type === "IDAT" || type === "IEND") break
+    o += 12 + u32be(b, o)
+  }
+  return { format: "png", width: u32be(b, 16), height: u32be(b, 20), ...(animated ? { animated } : {}) }
+}
+
+/** GIF: the logical screen descriptor, right after the signature. */
+export function readGif(b: Uint8Array): ImageInfo | undefined {
+  if (b.length < 10 || ascii(b, 0, 4) !== "GIF8") return undefined
+  return { format: "gif", width: u16le(b, 6), height: u16le(b, 8) }
+}
+
+/**
+ * WebP: three containers, three places for the size.
+ *
+ * `VP8 ` (lossy) keeps 14-bit fields after its start code; `VP8L` (lossless) packs both into one
+ * little-endian word; `VP8X` (extended — alpha, animation) stores each less one in 24 bits.
+ */
+export function readWebp(b: Uint8Array): ImageInfo | undefined {
+  if (b.length < 16 || ascii(b, 0, 4) !== "RIFF" || ascii(b, 8, 4) !== "WEBP") return undefined
+  const kind = ascii(b, 12, 4)
+  if (kind === "VP8 " && b.length >= 30)
+    return { format: "webp", width: u16le(b, 26) & 0x3fff, height: u16le(b, 28) & 0x3fff }
+  if (kind === "VP8L" && b.length >= 25) {
+    const bits = u32le(b, 21)
+    return { format: "webp", width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 }
+  }
+  if (kind === "VP8X" && b.length >= 30) {
+    const animated = (at(b, 20) & 0x02) !== 0
+    return {
+      format: "webp",
+      width: u24le(b, 24) + 1,
+      height: u24le(b, 27) + 1,
+      ...(animated ? { animated } : {}),
+    }
+  }
+  return undefined
+}
+
+/**
+ * JPEG: walk the markers to the first start-of-frame.
+ *
+ * Any SOF0–SOF15 carries the size, except C4 (Huffman tables), C8 (reserved) and CC (arithmetic
+ * conditioning), which share the range. EXIF and ICC segments come first and can be tens of KB, so
+ * the size may be far in — `HEADER_BYTES` covers what cameras and screenshot tools write.
+ */
+export function readJpeg(b: Uint8Array): ImageInfo | undefined {
+  if (b.length < 4 || at(b, 0) !== 0xff || at(b, 1) !== 0xd8) return undefined
+  let o = 2
+  while (o + 9 < b.length) {
+    if (at(b, o) !== 0xff) return undefined
+    const marker = at(b, o + 1)
+    /** Fill bytes, and markers that stand alone with no length after them. */
+    if (marker === 0xff) {
+      o++
+      continue
+    }
+    if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
+      o += 2
+      continue
+    }
+    if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc)
+      return { format: "jpeg", height: u16be(b, o + 5), width: u16be(b, o + 7) }
+    o += 2 + u16be(b, o + 2)
+  }
+  return undefined
+}
+
+/**
+ * BMP: the DIB header after the 14-byte file header.
+ *
+ * The old OS/2 core header (12 bytes) has 16-bit sizes; every later one 32-bit, with a negative
+ * height for an image stored top-down.
+ */
+export function readBmp(b: Uint8Array): ImageInfo | undefined {
+  if (b.length < 26 || at(b, 0) !== 0x42 || at(b, 1) !== 0x4d) return undefined
+  const dib = u32le(b, 14)
+  if (dib === 12) return { format: "bmp", width: u16le(b, 18), height: u16le(b, 20) }
+  if (dib < 40) return undefined
+  return { format: "bmp", width: u32le(b, 18) | 0, height: Math.abs(u32le(b, 22) | 0) }
+}
+
+/** The image a header describes, or undefined for anything this bay cannot name. */
+export function sniff(bytes: Uint8Array): ImageInfo | undefined {
+  const found =
+    readPng(bytes) ?? readGif(bytes) ?? readWebp(bytes) ?? readJpeg(bytes) ?? readBmp(bytes) ?? undefined
+  /** A header that claims no pixels is a damaged file, not an image to describe. */
+  return found && found.width > 0 && found.height > 0 ? found : undefined
+}
+
+/** The format as people write it: `PNG`, `APNG`, `JPEG`, `GIF`, `WebP`, `BMP`. */
+export function formatName(info: ImageInfo): string {
+  if (info.format === "png") return info.animated ? "APNG" : "PNG"
+  if (info.format === "webp") return "WebP"
+  return info.format.toUpperCase()
+}
+
+/** Formats this bay can decode to pixels — for a pixel diff and a preview. JPEG and WebP are metadata only. */
+export const decodable = (info: ImageInfo | undefined): boolean =>
+  info?.format === "png" || info?.format === "gif"
diff --git a/packages/review/src/core/image/steps.ts b/packages/review/src/core/image/steps.ts
new file mode 100644
index 00000000..a2472587
--- /dev/null
+++ b/packages/review/src/core/image/steps.ts
@@ -0,0 +1,33 @@
+/**
+ * Long work, cut into slices that give the event loop back between them.
+ *
+ * The TUI is one thread: a 2880×1800 PNG takes ~50 ms to unfilter in JavaScript, and inside OpenCode
+ * the pair took 114 ms — a 16 ms frame seven times over (docs/opencode/images.md). So the decoders are
+ * generators that `yield` every few milliseconds of work, and the caller decides what a yield means:
+ * nothing at all in a test or the preview CLI, a turn of the event loop in the pane.
+ */
+
+export type Steps = Generator
+
+/** Pixels of work per slice: about 3–5 ms of unfiltering or LZW on a laptop. */
+export const SLICE = 256 * 1024
+
+/** Runs every slice now. For tests, the CLI, and anything already off the interface thread. */
+export function finish(steps: Steps): T {
+  let next = steps.next()
+  while (!next.done) next = steps.next()
+  return next.value
+}
+
+/** A macrotask, so a paint queued meanwhile gets its turn before the next slice. */
+export const turn = (): Promise => new Promise((resolve) => setTimeout(resolve, 0))
+
+/** Runs the slices with the event loop's turn between each. */
+export async function finishSoon(steps: Steps, pause: () => Promise = turn): Promise {
+  let next = steps.next()
+  while (!next.done) {
+    await pause()
+    next = steps.next()
+  }
+  return next.value
+}
diff --git a/packages/review/src/core/model/review.ts b/packages/review/src/core/model/review.ts
index a30e7bad..d397e5ba 100644
--- a/packages/review/src/core/model/review.ts
+++ b/packages/review/src/core/model/review.ts
@@ -6,6 +6,7 @@
  * own; it renders one of these and calls one of these functions.
  */
 
+import type { ImageInfo } from "../image/sniff.ts"
 import {
   type Anchor,
   type Author,
@@ -37,6 +38,31 @@ export interface FileChange {
   change?: "added" | "deleted" | "renamed"
   /** Where a renamed file was. Its "before" is read from here, so the diff shows the edit and not a copy. */
   from?: string
+  /**
+   * Set when either side is binary — git's rule, a NUL in the first 8000 bytes. `before` and `after`
+   * are then empty and the counts zero: a binary has no lines, and drawing its bytes as UTF-8 was a
+   * diff of four hundred lines of U+FFFD that said nothing true about the file.
+   */
+  binary?: BinaryChange
+}
+
+/** One side of a binary file: how big it is and, for an image, what its header says. */
+export interface BinarySide {
+  /** Bytes. */
+  size: number
+  image?: ImageInfo
+}
+
+export interface BinaryChange {
+  /** Absent for a file that was added. */
+  before?: BinarySide
+  /** Absent for a file that was deleted. */
+  after?: BinarySide
+  /**
+   * The revision the old side was read at — `HEAD` for uncommitted work, the fork point for a branch —
+   * so it can be read again as bytes: to open it in a viewer, or to decode it for a pixel diff.
+   */
+  revision?: string
 }
 
 export interface ChangeSet {
diff --git a/packages/review/src/core/palette.ts b/packages/review/src/core/palette.ts
new file mode 100644
index 00000000..b6290068
--- /dev/null
+++ b/packages/review/src/core/palette.ts
@@ -0,0 +1,45 @@
+/**
+ * The host's command palette, and the key that opens it.
+ *
+ * The review's keys are a *global* layer — the only kind that fires (see `tui/panel/keys.ts`) — so
+ * while it is open it owns `j`, `k`, `enter`, `escape` and most of the alphabet. `ctrl+p` opened
+ * OpenCode's palette underneath the panel, and every letter typed into it moved the review instead:
+ * the palette looked dead. So the review watches for the palette's own key and steps aside.
+ */
+
+/** A key as the keymap reports it — the fields a binding names. */
+export interface KeyLike {
+  name?: string
+  ctrl?: boolean
+  meta?: boolean
+  shift?: boolean
+}
+
+/**
+ * The palette's bindings, as the person's config has them (`keybinds.command_list`), default `ctrl+p`.
+ *
+ * Leader chords are left out: the leader key alone arrives first and means something else, and the
+ * chord's second key is not one the review could tell apart from its own.
+ */
+export function paletteBindings(config: unknown): string[] {
+  const value = (config as { keybinds?: { command_list?: unknown } } | undefined)?.keybinds?.command_list
+  const raw = typeof value === "string" && value.trim() !== "" ? value : "ctrl+p"
+  if (raw.trim() === "none") return []
+  return raw
+    .split(",")
+    .map((binding) => binding.trim().toLowerCase())
+    .filter((binding) => binding !== "" && !binding.includes(""))
+}
+
+/** Whether `key` is `binding` (`ctrl+p`, `ctrl+shift+k`, `f2`) — every modifier exactly. */
+export function matchesBinding(key: KeyLike, binding: string): boolean {
+  const parts = binding.toLowerCase().split("+")
+  const name = parts.pop()
+  const modifiers = new Set(parts)
+  return (
+    key.name?.toLowerCase() === name &&
+    Boolean(key.ctrl) === modifiers.has("ctrl") &&
+    Boolean(key.meta) === (modifiers.has("meta") || modifiers.has("alt")) &&
+    Boolean(key.shift) === modifiers.has("shift")
+  )
+}
diff --git a/packages/review/src/core/view/chrome.ts b/packages/review/src/core/view/chrome.ts
index 831dfab6..bf33fa3a 100644
--- a/packages/review/src/core/view/chrome.ts
+++ b/packages/review/src/core/view/chrome.ts
@@ -70,8 +70,21 @@ export function headerRows(changes: ChangeSet, review: Review, width: number, la
   ]
 }
 
+/**
+ * The way to every key the row had no room for. Ranked just under the way out, so a narrow row keeps
+ * it and gives up `[w] Width` first: one key that leads to all of them beats one more of them.
+ */
+const KEYS: Hint = { key: "?", label: "Keys", priority: 100 }
+
 /** The keys, on screen, because a surface whose keys are undiscoverable has none. */
-export function footerRows(width: number, _columns: Columns, state: ViewState = {}, empty = false): Row[] {
+export function footerRows(
+  width: number,
+  _columns: Columns,
+  state: ViewState = {},
+  empty = false,
+  /** The file under the cursor is a binary: `o` opens it, and is worth a place in the row. */
+  binary = false,
+): Row[] {
   const inDiff = state.pane === "diff"
   const selecting = inDiff && state.anchor !== undefined
   const lines =
@@ -146,12 +159,14 @@ export function footerRows(width: number, _columns: Columns, state: ViewState =
     return [rule, { runs: clipRuns(trouble, width, "none") }]
   }
   if (state.stats) return [rule, { runs: clipRuns([...state.stats], width, "none") }]
+  /** On the keys screen the only key worth a row is the way back to the review. */
+  if (state.keys) return [rule, { runs: keyRow([], [closeHint("Hide Keys")], width) }]
 
   /**
    * Nothing to review: only the keys that act. Moving, noting and marking have nothing to land on,
    * and a row that offers them anyway teaches that its keys do not always mean anything.
    */
-  if (empty) return [rule, { runs: keyRow([], [...sources, close], width) }]
+  if (empty) return [rule, { runs: keyRow([], [...sources, KEYS, close], width) }]
   /** A selection says how many lines it holds before the keys that act on them. */
   const lead: Run[] = selecting
     ? [{ text: `${lines} line${lines === 1 ? "" : "s"}`, tone: "accent", bold: true }]
@@ -161,7 +176,16 @@ export function footerRows(width: number, _columns: Columns, state: ViewState =
     {
       runs: keyRow(
         lead,
-        [...moving, ...extra, submit, ...sources, { key: "w", label: "Width" }, close],
+        [
+          ...moving,
+          ...(binary ? [{ key: "o", label: "Open" }] : []),
+          ...extra,
+          submit,
+          ...sources,
+          { key: "w", label: "Width" },
+          KEYS,
+          close,
+        ],
         width,
       ),
     },
diff --git a/packages/review/src/core/view/diff.ts b/packages/review/src/core/view/diff.ts
index c8aeb29e..587959e9 100644
--- a/packages/review/src/core/view/diff.ts
+++ b/packages/review/src/core/view/diff.ts
@@ -12,6 +12,7 @@ import { type FileChange, type Review, threadAnchor, threadsFor, threadsOnLine }
 import { metrics } from "../perf.ts"
 import { cardRows } from "./card.ts"
 import { tallyOf } from "./counts.ts"
+import { binaryRows, lookKey } from "./image.ts"
 import { cell, clipRuns, elidePath, type Fill, type Row, type Run, skipColumns, type Tone } from "./rows.ts"
 import type { ViewState } from "./state.ts"
 import { languageOf, type SyntaxState, tokenize } from "./syntax/index.ts"
@@ -151,6 +152,8 @@ const signature = (file: FileChange, review: Review, state: ViewState, width: nu
     state.shift ?? 0,
     threads.some((thread) => thread.id === state.thread) ? state.thread : "",
     threads.map((thread) => `${thread.id}:${thread.status}:${thread.entries.length}`).join(","),
+    /** A binary's card changes when its pictures arrive, and with the pane's colour behind them. */
+    file.binary ? `${lookKey(state.looks?.get(file.path))}:${state.canvas ?? ""}` : "",
   ].join("|")
 }
 
@@ -245,6 +248,12 @@ function buildDiffRows(
     )
   }
 
+  /** A binary has no lines: its card says what it is, how it changed, and shows it. */
+  if (file.binary) {
+    rows.push(...binaryRows(file, state.looks?.get(file.path), width, state.canvas, paint))
+    return rows
+  }
+
   const language = languageOf(file.path)
   const body = codeWidth(width)
 
diff --git a/packages/review/src/core/view/image.ts b/packages/review/src/core/view/image.ts
new file mode 100644
index 00000000..851c819c
--- /dev/null
+++ b/packages/review/src/core/view/image.ts
@@ -0,0 +1,158 @@
+/**
+ * A binary file's card: what it is, what changed, a look at it, and the key that opens it.
+ *
+ *   PNG 2880×1800 · 807 KB → 789 KB
+ *   2.56% of pixels changed · 601×221 at 1900,300
+ *   [o] Open Both
+ *
+ *   before                       after
+ *   ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀     ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
+ *
+ * The picture is rows like every other row — runs of `▀` with an exact ink and an exact background —
+ * so the pool's diffing, the grid test and the preview CLI all apply to it unchanged. It shows *where*
+ * and roughly *what colour*; a 2880-pixel screenshot at fifty columns shows no legible text, which is
+ * why `o` is on the card: the real viewer is the answer to "what".
+ */
+
+import { hintRuns } from "@opencode-cockpit/client/design"
+import { pixelText, sameDimensions, summaryText } from "../image/describe.ts"
+import type { ImageLook } from "../image/looks.ts"
+import { type Cells, cellGrid, type Thumb, toCells } from "../image/pixels.ts"
+import { decodable, formatName } from "../image/sniff.ts"
+import type { BinaryChange, FileChange } from "../model/review.ts"
+import { cell, clipRuns, type Row, type Run } from "./rows.ts"
+
+/** Columns in from the card's edge: the code column's margin, near enough, without its gutters. */
+const MARGIN = 2
+/** Between the two pictures. */
+const GAP = 3
+/** The tallest a picture gets: enough to see a layout, not so tall a review becomes a gallery. */
+export const PICTURE_ROWS = 14
+/** Below this a picture says nothing, and the room goes to the words. */
+const NARROWEST = 8
+
+/** The pane's colour when none is known (the preview CLI's default theme). */
+export const CANVAS = 0x1e1e2e
+
+const blank = (width: number): Row => ({ runs: [{ text: " ".repeat(width) }] })
+
+/** A line of words in the card, inset by the margin and exactly `width` wide. */
+const line = (runs: Run[], width: number): Row => ({
+  runs: clipRuns([{ text: " ".repeat(MARGIN) }, ...runs], width),
+})
+
+/** What opening it means, for the key on the card: one side has nothing to open on the other. */
+export function openLabel(binary: BinaryChange): string {
+  if (binary.before && binary.after) return "Open Both"
+  return binary.after ? "Open" : "Open Old Version"
+}
+
+/** What sits where the picture would, when there is no picture. Undefined: say nothing. */
+function lookLine(binary: BinaryChange, look: ImageLook | undefined): Run[] | undefined {
+  if (look?.diff) {
+    const lit = look.diff.changed > 0
+    return [{ text: pixelText(look.diff), tone: lit ? "accent" : "muted", bold: lit }]
+  }
+  const images = [binary.before?.image, binary.after?.image]
+  const decodes = images.some(decodable)
+  if (look?.pending && decodes) return [{ text: "Comparing pixels…", tone: "muted" }]
+  if (look?.problem) return [{ text: look.problem, tone: "muted" }]
+  if (binary.before?.image && binary.after?.image && !sameDimensions(binary))
+    return [{ text: "Resized — every pixel moved, so no pixel diff", tone: "muted" }]
+  /** JPEG, WebP, BMP: described, not decoded. Said once, plainly, so a missing picture is not a bug. */
+  const named = images.find((image) => image && !decodable(image))
+  if (named)
+    return [
+      { text: `No preview for ${formatName(named)} here — the header is all that is read`, tone: "muted" },
+    ]
+  return undefined
+}
+
+/** One picture's row `row` as runs of identical cells merged: `▀▀▀` in one ink on one background. */
+function cellRuns(cells: Cells | undefined, row: number, width: number): Run[] {
+  if (!cells || row >= cells.rows) return [{ text: " ".repeat(width) }]
+  const runs: Run[] = []
+  let at = row * cells.cols
+  const end = at + cells.cols
+  while (at < end) {
+    const top = cells.top[at] as number
+    const bottom = cells.bottom[at] as number
+    let length = 1
+    while (at + length < end && cells.top[at + length] === top && cells.bottom[at + length] === bottom)
+      length++
+    runs.push({ text: "â–€".repeat(length), color: top, background: bottom })
+    at += length
+  }
+  if (cells.cols < width) runs.push({ text: " ".repeat(width - cells.cols) })
+  return runs
+}
+
+/**
+ * The pictures, side by side: before and after, or the one side an added or deleted file has.
+ *
+ * With a pixel diff, the after side shows what changed at full strength and the rest faded toward the
+ * pane — the old side is drawn as it was, so the two still compare by eye.
+ */
+function pictureRows(look: ImageLook, width: number, canvas: number, paint: boolean): Row[] {
+  const sides = [look.before, look.after]
+  const shown = sides.filter((thumb): thumb is Thumb => thumb !== undefined)
+  if (shown.length === 0) return []
+  const room = width - MARGIN
+  const panel =
+    shown.length === 2 ? Math.floor((room - GAP) / 2) : Math.min(room, Math.floor((room - GAP) / 2))
+  if (panel < NARROWEST) return []
+  const mask =
+    look.diff && look.diff.changed > 0 && look.diff.changed < look.diff.total ? look.diff.mask : undefined
+  /** Measuring needs only the grid; the cells are worked out when drawing. */
+  const grids = sides.map((thumb) =>
+    thumb ? cellGrid(thumb.width, thumb.height, panel, PICTURE_ROWS) : undefined,
+  )
+  const height = Math.max(...grids.map((grid) => (grid ? grid.height / 2 : 0)))
+  const cells = paint
+    ? sides.map((thumb, index) =>
+        thumb ? toCells(thumb, panel, PICTURE_ROWS, canvas, index === 1 ? mask : undefined) : undefined,
+      )
+    : [undefined, undefined]
+
+  const rows: Row[] = []
+  const caption = (text: string) => ({ text: cell(text, panel), tone: "muted" as const })
+  const labels: Run[] =
+    shown.length === 2
+      ? [caption("before"), { text: " ".repeat(GAP) }, caption(mask ? "after · changes lit" : "after")]
+      : [caption(look.before ? "before — deleted" : "after — new")]
+  rows.push(line(labels, width))
+  for (let row = 0; row < height; row++) {
+    const runs: Run[] =
+      shown.length === 2
+        ? [...cellRuns(cells[0], row, panel), { text: " ".repeat(GAP) }, ...cellRuns(cells[1], row, panel)]
+        : cellRuns(look.before ? cells[0] : cells[1], row, panel)
+    rows.push(line(runs, width))
+  }
+  return rows
+}
+
+/**
+ * The card's body for a binary file, under its heading and any note on the whole file.
+ *
+ * `paint` false measures: the same rows, the same count, without working out a single cell.
+ */
+export function binaryRows(
+  file: FileChange,
+  look: ImageLook | undefined,
+  width: number,
+  canvas = CANVAS,
+  paint = true,
+): Row[] {
+  const binary = file.binary
+  if (!binary || width <= 0) return []
+  const rows: Row[] = [blank(width), line([{ text: summaryText(binary), tone: "text", bold: true }], width)]
+  const said = lookLine(binary, look)
+  if (said) rows.push(line(said, width))
+  rows.push(line([...hintRuns({ key: "o", label: openLabel(binary) })], width))
+  const pictures = look ? pictureRows(look, width, canvas, paint) : []
+  if (pictures.length > 0) rows.push(blank(width), ...pictures)
+  return rows
+}
+
+/** What a binary card's rows depend on beyond the file, for the row cache's key. */
+export const lookKey = (look: ImageLook | undefined): string => (look ? String(look.stamp) : "")
diff --git a/packages/review/src/core/view/keys.ts b/packages/review/src/core/view/keys.ts
new file mode 100644
index 00000000..ae231909
--- /dev/null
+++ b/packages/review/src/core/view/keys.ts
@@ -0,0 +1,94 @@
+/**
+ * `[?] Keys`: every key the review takes, in the body's place.
+ *
+ * The footer has room for the keys you use constantly and says `…` for the rest; this is the rest, one
+ * line each, what each does wrapped under its own column rather than cut — the same screen Trust and
+ * Shell have, so `?` means the same thing in every bay.
+ */
+
+import { keyName } from "@opencode-cockpit/client/design"
+import { clipRuns, type Row, type Run, wrapText } from "./rows.ts"
+
+export interface KeyLine {
+  keys: string[]
+  does: string
+}
+
+/** In the order a review is done: move, read, say something, finish — then the pane itself. */
+export const REVIEW_KEYS: readonly KeyLine[] = [
+  { keys: ["j/k", "↑/↓"], does: "Move: files in the list, lines in the diff" },
+  { keys: ["tab"], does: "Switch between the file list and the diff" },
+  {
+    keys: ["enter", "l"],
+    does: "Open the file under the cursor, or fold a folder; in the diff, fold the file",
+  },
+  { keys: ["h", "�"], does: "Back to the file list" },
+  { keys: ["d", "u"], does: "Scroll down or up a few lines (also pgdn, pgup)" },
+  {
+    keys: ["L", "H"],
+    does: "Scroll the code sideways, for lines longer than the pane (also shift+→, shift+�)",
+  },
+  { keys: ["v"], does: "Select lines; the cursor is the moving end" },
+  { keys: ["c", "n"], does: "Comment on the line or the selection — or reply, on a thread" },
+  { keys: ["f"], does: "Comment on the whole file" },
+  { keys: ["x"], does: "Remove the thread here" },
+  { keys: ["space", "m"], does: "Mark the file viewed and go to the next one that is not" },
+  { keys: ["z"], does: "Fold or unfold the file" },
+  { keys: ["o"], does: "Open an image or other binary in your system's viewer — both versions" },
+  { keys: ["s"], does: "Hand the review to the agent" },
+  { keys: ["b"], does: "Next source: uncommitted work, or what this branch changes" },
+  { keys: ["B"], does: "Choose the branch to compare against" },
+  { keys: ["g"], does: "Read the changes again" },
+  { keys: ["w"], does: "Right pane or full screen" },
+  { keys: ["p"], does: "Show what the review costs to draw" },
+  { keys: ["?", "esc"], does: "Hide these keys; esc again closes the review" },
+]
+
+const key = (name: string): Run => ({ text: `[${keyName(name)}]`, tone: "accent", bold: true })
+
+/** One key line: its keys in a column, what it does wrapped beside them. */
+function keyLineRows(line: KeyLine, column: number, width: number): Row[] {
+  const keys = line.keys.flatMap((name, at): Run[] => [...(at > 0 ? [{ text: " " }] : []), key(name)])
+  const used = keys.reduce((sum, run) => sum + run.text.length, 0)
+  const indent = 1 + column + 3
+  return wrapText(line.does, Math.max(1, width - indent - 1)).map((text, at) => ({
+    runs: clipRuns(
+      [
+        { text: " " },
+        ...(at === 0
+          ? [...keys, { text: " ".repeat(column - used + 3) }]
+          : [{ text: " ".repeat(indent - 1) }]),
+        { text, tone: "muted" },
+      ],
+      width,
+    ),
+  }))
+}
+
+/** As many key lines as fit in `height` rows; a list cut short says how many keys are below. */
+export function keyRows(width: number, height: number, list: readonly KeyLine[] = REVIEW_KEYS): Row[] {
+  const column = Math.max(
+    ...list.map((each) => each.keys.map((name) => `[${keyName(name)}]`).join(" ").length),
+  )
+  const rows: Row[] = [{ runs: clipRuns([{ text: " KEYS", tone: "text", bold: true }], width) }]
+  rows.push({ runs: [{ text: " ".repeat(width) }] })
+  let shown = 0
+  for (const line of list) {
+    const lines = keyLineRows(line, column, width)
+    const last = shown === list.length - 1
+    if (rows.length + lines.length > (last ? height : height - 1)) break
+    rows.push(...lines)
+    shown++
+  }
+  if (shown < list.length) {
+    const left = list.length - shown
+    rows.push({
+      runs: clipRuns(
+        [{ text: ` ↓ ${left} more key${left === 1 ? "" : "s"} — a taller pane shows them`, tone: "muted" }],
+        width,
+      ),
+    })
+  }
+  while (rows.length < height) rows.push({ runs: [{ text: " ".repeat(width) }] })
+  return rows.slice(0, height)
+}
diff --git a/packages/review/src/core/view/layout.ts b/packages/review/src/core/view/layout.ts
index eb535050..94152393 100644
--- a/packages/review/src/core/view/layout.ts
+++ b/packages/review/src/core/view/layout.ts
@@ -16,6 +16,7 @@ import type { ChangeSet, Review } from "../model/review.ts"
 import { metrics } from "../perf.ts"
 import { footerRows, headerRows } from "./chrome.ts"
 import { type Columns, FOOTER_ROWS, GUTTER, HEADER_ROWS, inset, splitColumns, window } from "./geometry.ts"
+import { keyRows } from "./keys.ts"
 import { fileRows, listScroll, listWidth } from "./list.ts"
 import { cell, faint, type Row } from "./rows.ts"
 import type { Viewport, ViewState } from "./state.ts"
@@ -65,10 +66,17 @@ function compose(changes: ChangeSet, review: Review, state: ViewState, viewport:
   const blank = (width: number): Row => ({ runs: [{ text: " ".repeat(width) }] })
 
   const close = (built: Row[]): Row[] => {
-    const feet = footerRows(content, columns, state, changes.files.length === 0)
+    const binary = changes.files.find((file) => file.path === state.file)?.binary !== undefined
+    const feet = footerRows(content, columns, state, changes.files.length === 0, binary)
     return [...built, ...feet.map((row, index) => (index === 0 ? rule : inset(row, inner)))]
   }
 
+  /** `?`: the keys take the body, and the footer is only the way back. */
+  if (state.keys) {
+    for (const row of keyRows(content, body)) rows.push(inset(row, inner))
+    return close(rows)
+  }
+
   /**
    * Nothing to review, and why.
    *
diff --git a/packages/review/src/core/view/rows.ts b/packages/review/src/core/view/rows.ts
index 82496c92..14d77101 100644
--- a/packages/review/src/core/view/rows.ts
+++ b/packages/review/src/core/view/rows.ts
@@ -84,9 +84,17 @@ export interface Run {
    *
    * A real highlighter returns colours, not categories — so it sets this and the renderers prefer it
    * over `tone`. Untyped because this file is pure: it is the terminal library's own colour object,
-   * carried through untouched.
+   * carried through untouched — or a packed `0xRRGGBB` number, which the renderers turn into one.
    */
   color?: unknown
+  /**
+   * An exact background, beside the named `fill`, and preferred over it.
+   *
+   * A picture drawn in half blocks needs two exact colours per cell — `▀` in the top pixel's colour on
+   * the bottom pixel's — and a named surface cannot be a pixel. A packed `0xRRGGBB` number here, so the
+   * view stays pure and the preview CLI can paint it too.
+   */
+  background?: unknown
 }
 
 export interface Row {
diff --git a/packages/review/src/core/view/state.ts b/packages/review/src/core/view/state.ts
index 0a0edc7c..d3ba366b 100644
--- a/packages/review/src/core/view/state.ts
+++ b/packages/review/src/core/view/state.ts
@@ -7,6 +7,7 @@
  * the thing that draws the whole screen.
  */
 
+import type { ImageLook } from "../image/looks.ts"
 import type { Run } from "./rows.ts"
 
 /**
@@ -98,4 +99,14 @@ export interface ViewState {
    * you have while reading, and the answer was previously only available by pressing the key.
    */
   waiting?: number
+  /**
+   * What is known about each changed image beyond its header — the pixel diff, the thumbs a preview
+   * is drawn from — by path. Filled in off the draw path (`image/looks.ts`); absent, a binary card
+   * shows its metadata and nothing it would have to guess.
+   */
+  looks?: ReadonlyMap
+  /** The pane's own colour, packed `0xRRGGBB`: what a transparent pixel in a preview shows. */
+  canvas?: number
+  /** `?`: every key and what it does, in the body's place. */
+  keys?: boolean
 }
diff --git a/packages/review/src/core/viewer.ts b/packages/review/src/core/viewer.ts
new file mode 100644
index 00000000..1b127237
--- /dev/null
+++ b/packages/review/src/core/viewer.ts
@@ -0,0 +1,138 @@
+/**
+ * `o`: both versions of a binary, in the system's own viewer.
+ *
+ * The honest answer to "what changed in this image": a preview at terminal resolution shows *where*,
+ * never *what* — text in a 2880-pixel screenshot at fifty columns is noise. The old bytes come out of
+ * git into a temporary file named `@`, so the viewer picks the right app and its
+ * title bar says which side it is; the new side is the working copy itself.
+ *
+ * Everything that touches the system is injected, so tests never launch a real viewer.
+ */
+
+import { spawn as nodeSpawn } from "node:child_process"
+import { mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises"
+import { tmpdir } from "node:os"
+import { basename, extname, join } from "node:path"
+import type { FileChange } from "./model/review.ts"
+
+/** The opener each platform has, as a command and the arguments before the file. */
+export function openerFor(platform: NodeJS.Platform): { command: string; args: string[] } {
+  if (platform === "darwin") return { command: "open", args: [] }
+  /** `start` is a shell builtin; its first quoted argument is a window title, hence the empty one. */
+  if (platform === "win32") return { command: "cmd", args: ["/c", "start", ""] }
+  return { command: "xdg-open", args: [] }
+}
+
+/** A launched process, as far as this file cares: it can fail to start, and it is not waited on. */
+export interface Launched {
+  on(event: "error", listener: (error: Error) => void): unknown
+  unref(): void
+}
+
+export interface ViewerDeps {
+  platform: NodeJS.Platform
+  env: Record
+  /**
+   * Finds a command on a PATH. `Bun.which`, given the PATH explicitly: `Bun.spawn` resolves a bare name
+   * from the *parent's* PATH, not the env passed to it — the spike's PATH shim was bypassed until the
+   * binary was resolved like this. It is also how "no opener here" is noticed and said.
+   */
+  which: (command: string, options: { PATH: string }) => string | null
+  /** Starts the opener without waiting for it. Never `spawnSync`: on the TUI thread it takes the renderer down. */
+  spawn: (command: string, args: string[], env: Record) => Launched
+  /** The old side's bytes, from git. */
+  readOld: (file: FileChange) => Promise
+  /** Where temporary copies go; one directory per pane, made on first use. */
+  tmp?: string
+  /** A launch that failed after `open` had already returned. */
+  report: (problem: string) => void
+}
+
+/** The real system: Node's spawn (it has an `error` event; Bun's throws or exits instead), detached. */
+export const systemSpawn: ViewerDeps["spawn"] = (command, args, env) =>
+  nodeSpawn(command, args, { env, detached: true, stdio: "ignore" })
+
+export const systemWhich: ViewerDeps["which"] = (command, options) => Bun.which(command, options)
+
+const PREFIX = "cockpit-review-"
+/** A leftover directory this old is from a pane that never closed cleanly — a crash, a kill. */
+const STALE_MS = 24 * 60 * 60 * 1000
+
+/** `shot@a1b2c3d.png`: the name, which side, and the extension the viewer goes by. */
+export function oldName(file: FileChange): string {
+  const path = file.from ?? file.path
+  const extension = extname(path)
+  const revision = file.binary?.revision ?? "old"
+  const short = /^[0-9a-f]{12,}$/.test(revision) ? revision.slice(0, 7) : revision
+  return `${basename(path, extension)}@${short}${extension}`
+}
+
+export interface Viewer {
+  /**
+   * Opens what there is of `file`: both sides, or the one an added or deleted file has. Resolves once
+   * the openers are launched — not when anything is viewed. Says what went wrong, if anything did.
+   */
+  open: (cwd: string, file: FileChange) => Promise<{ opened: number; problem?: string }>
+  /** Removes this pane's temporary copies. */
+  clean: () => Promise
+}
+
+export function createViewer(deps: ViewerDeps): Viewer {
+  let dir: string | undefined
+  let swept = false
+
+  /** Leftovers from panes that did not close cleanly, swept once — only old ones, never a live pane's. */
+  const sweep = async (root: string) => {
+    if (swept) return
+    swept = true
+    const names = await readdir(root).catch(() => [] as string[])
+    const now = Date.now()
+    for (const name of names) {
+      if (!name.startsWith(PREFIX)) continue
+      const full = join(root, name)
+      const info = await stat(full).catch(() => undefined)
+      if (info && now - info.mtimeMs > STALE_MS)
+        await rm(full, { recursive: true, force: true }).catch(() => {})
+    }
+  }
+
+  const directory = async (): Promise => {
+    if (dir) return dir
+    const root = deps.tmp ?? tmpdir()
+    await sweep(root)
+    dir = await mkdtemp(join(root, PREFIX))
+    return dir
+  }
+
+  return {
+    async open(cwd, file) {
+      const { command, args } = openerFor(deps.platform)
+      const PATH = deps.env.PATH ?? ""
+      const opener = deps.which(command, { PATH })
+      if (!opener) return { opened: 0, problem: `Nothing to open files with: ${command} is not on PATH` }
+
+      const targets: string[] = []
+      const binary = file.binary
+      if (binary?.before) {
+        const bytes = await deps.readOld(file)
+        if (!bytes) return { opened: 0, problem: `Could not read the old ${basename(file.path)} from git` }
+        const path = join(await directory(), oldName(file))
+        await writeFile(path, bytes)
+        targets.push(path)
+      }
+      if (!binary || binary.after) targets.push(join(cwd, file.path))
+
+      for (const target of targets) {
+        const launched = deps.spawn(opener, [...args, target], deps.env)
+        launched.on("error", (error) => deps.report(`Could not open ${basename(target)}: ${error.message}`))
+        launched.unref()
+      }
+      return { opened: targets.length }
+    },
+    async clean() {
+      const was = dir
+      dir = undefined
+      if (was) await rm(was, { recursive: true, force: true }).catch(() => {})
+    },
+  }
+}
diff --git a/packages/review/src/tui/index.tsx b/packages/review/src/tui/index.tsx
index c501e4cd..22cd7af6 100644
--- a/packages/review/src/tui/index.tsx
+++ b/packages/review/src/tui/index.tsx
@@ -3,13 +3,16 @@
 import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client/feature"
 import { bindingLookup, dualTui, type Host } from "@opencode-cockpit/client/host"
 import type { BoxRenderable } from "@opentui/core"
-import { headOf } from "../core/git/sources.ts"
+import { headOf, readBlob, readWorking } from "../core/git/sources.ts"
+import { createLooks } from "../core/image/looks.ts"
 import type { Source } from "../core/model/review.ts"
+import { matchesBinding, paletteBindings } from "../core/palette.ts"
 import { metrics } from "../core/perf.ts"
 import { reviewPaths } from "../core/store/paths.ts"
 import { createPersistence } from "../core/store/persist.ts"
 import { frameBounds, VARIANTS, type Variant } from "../core/view/frame.ts"
 import { FOOTER_ROWS, HEADER_ROWS } from "../core/view/geometry.ts"
+import { createViewer, systemSpawn, systemWhich } from "../core/viewer.ts"
 import { createStore } from "./data/changes.ts"
 import { createActions } from "./panel/actions.ts"
 import { paneLayer } from "./panel/keys.ts"
@@ -125,6 +128,25 @@ export function createReviewTui({ source = REVIEW_PACKAGE }: { source?: string }
 
     const { guard, notice } = createTrouble({ api, surface, store })
 
+    const directory = () => api.state.path.worktree || api.state.path.directory
+
+    /**
+     * The images under review, decoded off the draw path once git has been read: the pixel diff and
+     * the thumbs the preview is drawn from. A paint is asked for as each file is done.
+     */
+    const looks = createLooks(
+      async (file, side) => {
+        const read =
+          side === "after"
+            ? await readWorking(directory(), file.path)
+            : file.binary?.revision
+              ? await readBlob(directory(), file.binary.revision, file.from ?? file.path)
+              : undefined
+        return read?.whole ? read.bytes : undefined
+      },
+      () => draw(),
+    )
+
     const { draw } = createPainter({
       api,
       surface,
@@ -133,6 +155,30 @@ export function createReviewTui({ source = REVIEW_PACKAGE }: { source?: string }
       queries,
       notice,
       boxes: () => ({ backdrop, panel, pool }),
+      looks: () => looks.current(),
+    })
+
+    /** `o`: the old side out of git, uncapped — a viewer needs the whole file, not its header. */
+    const viewer = createViewer({
+      platform: process.platform,
+      env: process.env,
+      which: systemWhich,
+      spawn: systemSpawn,
+      readOld: async (file) =>
+        file.binary?.revision
+          ? (
+              await readBlob(
+                directory(),
+                file.binary.revision,
+                file.from ?? file.path,
+                Number.POSITIVE_INFINITY,
+              )
+            )?.bytes
+          : undefined,
+      report: (problem) => {
+        surface.said = { text: problem, at: Date.now() }
+        draw()
+      },
     })
 
     /** The branch can change under us, and the review that belongs to it changes with it. */
@@ -144,6 +190,7 @@ export function createReviewTui({ source = REVIEW_PACKAGE }: { source?: string }
       const [, threads, at] = await Promise.all([store.load(), persistence.load(), headOf(directory)])
       head = at
       surface.review = { ...surface.review, threads }
+      looks.sync(store.current().changes.files)
       /** Land on something worth reading rather than on an empty pane. */
       const files = queries.files()
       if (!surface.view.file || !files.some((file) => file.path === surface.view.file)) {
@@ -178,10 +225,15 @@ export function createReviewTui({ source = REVIEW_PACKAGE }: { source?: string }
 
     const close = () => {
       surface.open = false
+      surface.yielded = false
       clearInterval(watching)
       watching = undefined
+      clearInterval(waiting)
+      waiting = undefined
       panel?.blur()
       dropKeys()
+      /** The old versions `o` wrote out are only for as long as the pane is up. */
+      void viewer.clean()
       draw()
       /** The prompt wants its cursor back, exactly where the host had it. */
       const at = api.renderer.getCursorState?.()
@@ -191,6 +243,7 @@ export function createReviewTui({ source = REVIEW_PACKAGE }: { source?: string }
 
     const show = () => {
       surface.open = true
+      surface.yielded = false
       panel?.focus()
       takeKeys()
       draw()
@@ -201,6 +254,51 @@ export function createReviewTui({ source = REVIEW_PACKAGE }: { source?: string }
 
     const toggle = () => (surface.open ? close() : show())
 
+    /**
+     * Stepping aside for the host's command palette.
+     *
+     * The review's keys are a global layer (the only kind that fires), so with the pane open `ctrl+p`
+     * opened OpenCode's palette under it and every key typed into the palette moved the review
+     * instead. The palette's own key is let through untouched; the review hides and gives up its keys,
+     * and comes back when the palette closes — on OpenCode 1, which says how deep its dialogs are. On
+     * OpenCode 2 nothing says when the palette closes, so the review closes instead; the palette's
+     * "Open or close the changes" brings it back where it was.
+     */
+    let waiting: ReturnType | undefined
+    const paletteKeys = paletteBindings(api.v1?.state.config)
+    const stepAside = () => {
+      if (!api.v1) return close()
+      surface.yielded = true
+      dropKeys()
+      draw()
+      const since = Date.now()
+      let opened = false
+      clearInterval(waiting)
+      waiting = setInterval(() => {
+        const depth = api.ui.dialog.depth
+        if (depth > 0) opened = true
+        /** Closed again, or never opened at all: either way the review comes back. */
+        if ((opened && depth === 0) || (!opened && Date.now() - since > 1_000)) {
+          clearInterval(waiting)
+          waiting = undefined
+          if (!surface.open || !surface.yielded) return
+          surface.yielded = false
+          takeKeys()
+          draw()
+        }
+      }, 100)
+    }
+    api.lifecycle.onDispose(
+      api.keymap.intercept(
+        (context) => {
+          if (!surface.open || surface.yielded || !disposeKeys) return
+          if (paletteKeys.some((binding) => matchesBinding(context.event, binding)))
+            guard.run("palette", stepAside)
+        },
+        { priority: 10_000 },
+      ),
+    )
+
     const cycle = () => {
       surface.variant = VARIANTS[(VARIANTS.indexOf(surface.variant) + 1) % VARIANTS.length] ?? "right"
       draw()
@@ -225,6 +323,7 @@ export function createReviewTui({ source = REVIEW_PACKAGE }: { source?: string }
       sources: SOURCES,
       head: () => head,
       viewport,
+      viewer,
     })
 
     const pointer = createPointer({
diff --git a/packages/review/src/tui/panel/actions.ts b/packages/review/src/tui/panel/actions.ts
index 23de0b2c..3f098044 100644
--- a/packages/review/src/tui/panel/actions.ts
+++ b/packages/review/src/tui/panel/actions.ts
@@ -40,6 +40,7 @@ import {
   streamScroll,
   streamWindow,
 } from "../../core/view/stream.ts"
+import type { Viewer } from "../../core/viewer.ts"
 import type { Store } from "../data/changes.ts"
 import { askForBase, askForNote, noteFields, replyFields, submitFields } from "../view/dialogs.tsx"
 import type { Queries } from "./queries.ts"
@@ -75,6 +76,8 @@ export interface ActionDeps {
   head: () => string | undefined
   /** The pane's size, which is what the stream's heights are measured against. */
   viewport: () => Viewport
+  /** Opens a binary's two versions in the system viewer. */
+  viewer: Viewer
 }
 
 export interface Actions {
@@ -104,6 +107,14 @@ export interface Actions {
   cycle: () => void
   close: () => void
   reload: () => void
+  /** `o`: the file under the cursor, both versions, in the system's viewer. */
+  openExternal: () => void
+  /** `?`: every key, in the body's place — or back from it. */
+  toggleKeys: () => void
+  /** Back from the keys screen, if it is showing; true when it was. */
+  leaveKeys: () => boolean
+  /** `esc`: the keys screen first, then the review. */
+  quit: () => void
 }
 
 export function createActions(deps: ActionDeps): Actions {
@@ -678,6 +689,50 @@ export function createActions(deps: ActionDeps): Actions {
     draw()
   }
 
+  /** Said in the footer, where you are looking, for a few seconds. */
+  const tell = (text: string) => {
+    surface.said = { text, at: Date.now() }
+    draw()
+  }
+
+  /**
+   * Both versions of the binary under the cursor, in the system's own viewer.
+   *
+   * Launched and not waited on — nothing here may block the thread the pane draws on. What goes wrong
+   * later (the opener failing to start) arrives through the viewer's `report` and is said the same way.
+   */
+  const openExternal = () => {
+    const file = store.current().changes.files.find((each) => each.path === surface.view.file)
+    if (!file) return
+    if (!file.binary) return tell("o opens images and other binaries in your viewer; this file is text")
+    const cwd = api.state.path.worktree || api.state.path.directory
+    guard.task("open", async () => {
+      const result = await deps.viewer.open(cwd, file)
+      if (result.problem) tell(result.problem)
+    })
+  }
+
+  const toggleKeys = () => {
+    surface.view = { ...surface.view, keys: !surface.view.keys }
+    draw()
+  }
+
+  const leaveKeys = (): boolean => {
+    if (!surface.view.keys) return false
+    surface.view = { ...surface.view, keys: false }
+    draw()
+    return true
+  }
+
+  /**
+   * Escape closes the nearest thing first, and the close is deferred: closing disposes the layer the
+   * key is dispatching through.
+   */
+  const quit = () => {
+    if (leaveKeys()) return
+    setTimeout(() => close(), 0)
+  }
+
   return {
     move,
     scroll,
@@ -702,5 +757,9 @@ export function createActions(deps: ActionDeps): Actions {
     cycle: deps.cycle,
     close,
     reload: deps.refresh,
+    openExternal,
+    toggleKeys,
+    leaveKeys,
+    quit,
   }
 }
diff --git a/packages/review/src/tui/panel/keys.ts b/packages/review/src/tui/panel/keys.ts
index 823d4c17..c9c256d5 100644
--- a/packages/review/src/tui/panel/keys.ts
+++ b/packages/review/src/tui/panel/keys.ts
@@ -25,19 +25,30 @@ type Command = NonNullable[number]
  * Wrapped here, at the one place commands are registered, rather than at twenty call sites — a safety
  * net with a hole in it because somebody forgot a line is not a safety net.
  */
-const guarded = (guard: Guard, commands: Command[]): Command[] =>
+const guarded = (guard: Guard, actions: Actions, commands: Command[]): Command[] =>
   commands.map((command) => ({
     ...command,
     run: (...args: Parameters) => {
       metrics.count("keys")
-      guard.run(command.name.replace("cockpit.review.", ""), () => command.run(...args))
+      guard.run(command.name.replace("cockpit.review.", ""), () => {
+        /** Any key that acts on the review takes you back to it from the keys screen, then acts. */
+        if (!STAY_ON_KEYS.has(command.name)) actions.leaveKeys()
+        return command.run(...args)
+      })
     },
   }))
 
+/** The keys that mean something on the keys screen itself: leaving it, and the numbers. */
+const STAY_ON_KEYS = new Set([
+  "cockpit.review.pane.keys",
+  "cockpit.review.pane.quit",
+  "cockpit.review.pane.stats",
+])
+
 export function paneLayer(actions: Actions, guard: Guard): Layer {
   return {
     priority: 100,
-    commands: guarded(guard, [
+    commands: guarded(guard, actions, [
       { name: "cockpit.review.pane.down", title: "Down", run: () => actions.move(1) },
       { name: "cockpit.review.pane.up", title: "Up", run: () => actions.move(-1) },
       { name: "cockpit.review.pane.swap", title: "Switch pane", run: () => actions.swap() },
@@ -104,12 +115,15 @@ export function paneLayer(actions: Actions, guard: Guard): Layer {
       {
         name: "cockpit.review.pane.quit",
         title: "Close the review",
-        /**
-         * Escape closes the nearest thing first, and the close is deferred: closing disposes the layer
-         * this handler is dispatching through.
-         */
-        run: () => setTimeout(() => actions.close(), 0),
+        /** The keys screen first, then the review — see `actions.quit`. */
+        run: () => actions.quit(),
+      },
+      {
+        name: "cockpit.review.pane.open",
+        title: "Open both versions in the system viewer",
+        run: () => actions.openExternal(),
       },
+      { name: "cockpit.review.pane.keys", title: "Show every key", run: () => actions.toggleKeys() },
       {
         name: "cockpit.review.pane.stats",
         title: "Show what the review is costing",
@@ -139,6 +153,8 @@ export function paneLayer(actions: Actions, guard: Guard): Layer {
       { key: "w", cmd: "cockpit.review.pane.cycle", desc: "Width" },
       { key: "p", cmd: "cockpit.review.pane.stats", desc: "Numbers" },
       { key: "s", cmd: "cockpit.review.pane.submit", desc: "Submit" },
+      { key: "o", cmd: "cockpit.review.pane.open", desc: "Open in viewer" },
+      { key: "?,shift+/", cmd: "cockpit.review.pane.keys", desc: "Keys" },
       { key: "q,escape", cmd: "cockpit.review.pane.quit", desc: "Close" },
     ],
   }
diff --git a/packages/review/src/tui/panel/paint.ts b/packages/review/src/tui/panel/paint.ts
index 89f0f7d2..c563d606 100644
--- a/packages/review/src/tui/panel/paint.ts
+++ b/packages/review/src/tui/panel/paint.ts
@@ -10,6 +10,7 @@
 import type { Host } from "@opencode-cockpit/client/host"
 import type { BoxRenderable } from "@opentui/core"
 import type { Guard } from "../../core/guard.ts"
+import type { ImageLook } from "../../core/image/looks.ts"
 import { filesElsewhere } from "../../core/model/review.ts"
 import { waitingOnAgent } from "../../core/model/submit.ts"
 import { metrics } from "../../core/perf.ts"
@@ -17,7 +18,7 @@ import { frameBounds } from "../../core/view/frame.ts"
 import { layout } from "../../core/view/layout.ts"
 import { statsRuns } from "../../core/view/stats.ts"
 import type { Store } from "../data/changes.ts"
-import { type RowPool, solidSurface } from "../view/pool.ts"
+import { packed, type RowPool, solidSurface } from "../view/pool.ts"
 import type { Queries } from "./queries.ts"
 import type { Surface } from "./surface.ts"
 
@@ -37,8 +38,13 @@ export interface PaintDeps {
   /** What went wrong recently, if anything, for the footer to say so. */
   notice: () => string | undefined
   boxes: () => Boxes
+  /** What is known about the images under review, beyond their headers. */
+  looks: () => ReadonlyMap
 }
 
+/** How long something the review said stays in the footer. */
+const SAID_MS = 8_000
+
 export interface Painter {
   /** Asks for a paint. Several asks in one turn are one paint, of the state the turn ended on. */
   draw: () => void
@@ -53,15 +59,18 @@ export function createPainter(deps: PaintDeps): Painter {
     if (!backdrop || !panel) return
     const screen = { width: api.renderer.width, height: api.renderer.height }
     const frame = frameBounds(surface.variant, screen)
+    /** Stepped aside for the host's palette, the review is open but not drawn. */
+    const showing = surface.open && !surface.yielded
 
     backdrop.width = screen.width
-    backdrop.height = surface.open ? screen.height : 0
-    backdrop.visible = surface.open
+    backdrop.height = showing ? screen.height : 0
+    backdrop.visible = showing
 
     /** Opaque whatever the theme says, or the conversation shows through (transparent themes). */
-    panel.backgroundColor = solidSurface(api.theme.current, "panel")
+    const behind = solidSurface(api.theme.current, "panel")
+    panel.backgroundColor = behind
     panel.width = frame.width
-    panel.height = surface.open ? screen.height : 0
+    panel.height = showing ? screen.height : 0
     /**
      * No border, and no title.
      *
@@ -71,7 +80,7 @@ export function createPainter(deps: PaintDeps): Painter {
      */
     panel.title = ""
 
-    if (!surface.open) {
+    if (!showing) {
       pool?.clear()
       api.renderer.requestRender()
       return
@@ -86,7 +95,7 @@ export function createPainter(deps: PaintDeps): Painter {
      * back, so the last word has to be ours.
      */
     setTimeout(() => {
-      if (surface.open) api.renderer.setCursorPosition(0, 0, false)
+      if (surface.open && !surface.yielded) api.renderer.setCursorPosition(0, 0, false)
     }, 0)
 
     /**
@@ -97,7 +106,8 @@ export function createPainter(deps: PaintDeps): Painter {
      * pane with no explanation. A diagnostic written to a field nobody renders is worse than none: it
      * looks like the feature simply does not work.
      */
-    const trouble = deps.notice() ?? store.current().notice
+    const said = surface.said && Date.now() - surface.said.at < SAID_MS ? surface.said.text : undefined
+    const trouble = deps.notice() ?? said ?? store.current().notice
     const thread = queries.hereThreadId()
     const away = filesElsewhere(surface.review, store.current().changes)
     const rows = layout(
@@ -109,6 +119,8 @@ export function createPainter(deps: PaintDeps): Painter {
         waiting: waitingOnAgent(surface.review).length,
         ...(thread ? { thread } : {}),
         ...(away.length > 0 ? { elsewhere: away } : {}),
+        looks: deps.looks(),
+        canvas: packed(behind),
         /** Trouble outranks the numbers; both outrank the keys, and the footer stays two rows. */
         ...(trouble
           ? { notice: trouble }
diff --git a/packages/review/src/tui/panel/surface.ts b/packages/review/src/tui/panel/surface.ts
index 7d3514de..6f2b6e20 100644
--- a/packages/review/src/tui/panel/surface.ts
+++ b/packages/review/src/tui/panel/surface.ts
@@ -26,6 +26,13 @@ export interface Surface {
   variant: Variant
   /** Whether the footer is showing the numbers instead of the keys. */
   showStats: boolean
+  /**
+   * Open, but stepped aside for the host's palette: hidden, keys given up, back when it closes.
+   * Separate from `open` so a palette command that toggles the review still sees it open.
+   */
+  yielded: boolean
+  /** Something to say in the footer for a while — what `o` could not do, say. */
+  said?: { text: string; at: number }
 }
 
 export function createSurface(variant: Variant): Surface {
@@ -36,5 +43,6 @@ export function createSurface(variant: Variant): Surface {
     open: false,
     variant,
     showStats: false,
+    yielded: false,
   }
 }
diff --git a/packages/review/src/tui/view/pool.ts b/packages/review/src/tui/view/pool.ts
index 1bf123d5..bfb50407 100644
--- a/packages/review/src/tui/view/pool.ts
+++ b/packages/review/src/tui/view/pool.ts
@@ -42,7 +42,8 @@ const sameRow = (was: Row | undefined, now: Row): boolean => {
       before.bold !== after.bold ||
       before.italic !== after.italic ||
       before.faint !== after.faint ||
-      before.color !== after.color
+      before.color !== after.color ||
+      before.background !== after.background
     )
       return false
   }
@@ -208,23 +209,65 @@ const soften = (ink: RGBA, behind: RGBA): RGBA => {
   return out
 }
 
+/** A colour as packed `0xRRGGBB`, tolerant of both 0..1 and 0..255 channels. */
+export const packed = (colour: RGBA): number => {
+  const scale = Math.max(colour.r, colour.g, colour.b) > 1 ? 1 : 255
+  const channel = (value: number) => Math.max(0, Math.min(255, Math.round(value * scale)))
+  return (channel(colour.r) << 16) | (channel(colour.g) << 8) | channel(colour.b)
+}
+
+/**
+ * Packed colours as the host's own colour objects, made once each.
+ *
+ * A picture's cells arrive as numbers (the view is pure), and a screenshot repeats its colours
+ * heavily, so each is built once from the host's class — read off a theme colour, the way `soften`
+ * gets it, so nothing of OpenTUI is imported — and reused for every cell that shares it.
+ */
+const exact = new Map()
+let exactClass: unknown
+const EXACT = 8192
+
+const exactColour = (theme: TuiThemeCurrent, value: unknown): RGBA | undefined => {
+  if (typeof value !== "number") return value as RGBA | undefined
+  const Colour = theme.text.constructor as unknown as {
+    fromInts?: (r: number, g: number, b: number, a: number) => RGBA
+  }
+  if (Colour !== exactClass) {
+    exact.clear()
+    exactClass = Colour
+  }
+  const known = exact.get(value)
+  if (known) return known
+  if (typeof Colour.fromInts !== "function") return undefined
+  const made = Colour.fromInts((value >> 16) & 255, (value >> 8) & 255, value & 255, 255)
+  if (exact.size >= EXACT) exact.clear()
+  exact.set(value, made)
+  return made
+}
+
 /** One styled run becomes one chunk. An exact colour wins: a parser knows better than a tone does. */
 const chunk = (theme: TuiThemeCurrent, run: Row["runs"][number]): TextChunk => {
-  const fill = fillColour(theme, run.fill)
+  const background = run.background === undefined ? undefined : exactColour(theme, run.background)
+  const fill = background ?? fillColour(theme, run.fill)
   const base = run.tone === "inverse" ? inkOn(theme, fill) : toneColour(theme, run.tone)
-  /** Blend when there is a solid colour to blend toward; a see-through pane falls back to DIM. */
-  const behind = opaque(fill) ? fill : opaque(theme.backgroundPanel) ? theme.backgroundPanel : undefined
+  /**
+   * Blend when there is a solid colour to blend toward; a see-through pane falls back to DIM. A
+   * picture's background is its lower pixel, not a surface: both its pixels fade toward the pane.
+   */
+  const panel = opaque(theme.backgroundPanel) ? theme.backgroundPanel : undefined
+  const behind = background ? panel : opaque(fill) ? fill : panel
   const blend = run.faint && behind !== undefined
   const ink = blend && base ? soften(base, behind) : base
   const attributes = (run.bold ? BOLD : 0) | (run.italic ? ITALIC : 0) | (run.faint && !blend ? DIM : 0)
   // No ink reads on this block, so the block goes: the badge becomes its own colour, in words.
   if (run.tone === "inverse" && ink === undefined)
     return { __isChunk: true, text: run.text, fg: fill ?? theme.text, bg: undefined, attributes } as TextChunk
+  const colour = run.color === undefined ? undefined : exactColour(theme, run.color)
   return {
     __isChunk: true,
     text: run.text,
-    fg: run.color ? (blend ? soften(run.color as RGBA, behind as RGBA) : (run.color as RGBA)) : ink,
-    bg: fill,
+    fg: colour ? (blend ? soften(colour, behind as RGBA) : colour) : ink,
+    bg: background && blend ? soften(background, behind as RGBA) : fill,
     attributes,
   } as TextChunk
 }
diff --git a/packages/review/test/git/binary.test.ts b/packages/review/test/git/binary.test.ts
new file mode 100644
index 00000000..1bb32556
--- /dev/null
+++ b/packages/review/test/git/binary.test.ts
@@ -0,0 +1,128 @@
+import { afterAll, beforeAll, describe, expect, test } from "bun:test"
+import { copyFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+import { branchChanges, readBlob, runGit, withCounts, worktreeChanges } from "../../src/core/git/sources.ts"
+
+/**
+ * Binaries through a real repository: what git hands over, read as bytes.
+ *
+ * The bug this guards: images were read as UTF-8 — a PNG under 400 KB became a "+412 −268" diff of
+ * U+FFFD, and one over it was skipped with "too large", silently absent from the review.
+ */
+
+let repo: string
+const fixtures = join(import.meta.dir, "..", "image", "fixtures")
+
+const git = async (...args: string[]) => {
+  const result = await runGit(args, repo)
+  if (!result.ok) throw new Error(`git ${args.join(" ")} failed`)
+  return result.out
+}
+const put = (fixture: string, path: string) => copyFileSync(join(fixtures, fixture), join(repo, path))
+/** A PNG's signature and IHDR in front of 600 KB of noise: over the text cap, under the binary one. */
+const bigPng = (seed: number) => {
+  const noise = new Uint8Array(600_000)
+  for (let index = 0; index < noise.length; index++) noise[index] = (index * 7919 + seed) & 255
+  return Buffer.concat([readFileSync(join(fixtures, "before.png")).subarray(0, 33), noise])
+}
+
+beforeAll(async () => {
+  repo = mkdtempSync(join(tmpdir(), "ck-review-binary-"))
+  await git("init", "--initial-branch=main")
+  await git("config", "user.email", "test@example.com")
+  await git("config", "user.name", "Test")
+  put("before.png", "shot.png")
+  put("still.gif", "gone.gif")
+  put("blob.bin", "data.bin")
+  put("baseline.jpg", "photo.jpg")
+  writeFileSync(join(repo, "big.png"), bigPng(1))
+  writeFileSync(join(repo, "notes.txt"), "one\ntwo\n")
+  await git("add", "-A")
+  await git("commit", "-m", "first")
+  await git("checkout", "-b", "feature")
+})
+
+afterAll(() => rmSync(repo, { recursive: true, force: true }))
+
+describe("uncommitted binaries", () => {
+  test("a changed PNG is a binary change with both headers, not a text diff", async () => {
+    put("after.png", "shot.png")
+    const files = withCounts((await worktreeChanges(repo)).files)
+    const shot = files.find((file) => file.path === "shot.png")
+    expect(shot?.before).toBe("")
+    expect(shot?.after).toBe("")
+    expect([shot?.additions, shot?.deletions]).toEqual([0, 0])
+    expect(shot?.binary?.before?.image).toEqual({ format: "png", width: 64, height: 40 })
+    expect(shot?.binary?.after?.image).toEqual({ format: "png", width: 64, height: 40 })
+    expect(shot?.binary?.before?.size).toBe(278)
+    expect(shot?.binary?.after?.size).toBe(300)
+    expect(shot?.binary?.revision).toBe("HEAD")
+  })
+
+  test("over the text cap, an image is still in the review — described, not skipped", async () => {
+    writeFileSync(join(repo, "big.png"), bigPng(2))
+    const result = await worktreeChanges(repo)
+    expect(result.errors.join(" ")).not.toContain("big.png")
+    const big = result.files.find((file) => file.path === "big.png")
+    expect(big?.binary?.after?.size).toBe(600_033)
+    expect(big?.binary?.after?.image).toMatchObject({ format: "png", width: 64, height: 40 })
+  })
+
+  test("added, deleted, a JPEG, and a binary that is no image", async () => {
+    put("anim.gif", "new.gif")
+    rmSync(join(repo, "gone.gif"))
+    put("progressive.jpg", "photo.jpg")
+    writeFileSync(join(repo, "data.bin"), Buffer.concat([Buffer.from([0, 1, 2]), Buffer.alloc(5000, 9)]))
+    const files = (await worktreeChanges(repo)).files
+    const by = (path: string) => files.find((file) => file.path === path)
+    expect(by("new.gif")?.change).toBe("added")
+    expect(by("new.gif")?.binary).toEqual({
+      after: { size: 594, image: { format: "gif", width: 64, height: 40 } },
+    })
+    expect(by("gone.gif")?.change).toBe("deleted")
+    expect(by("gone.gif")?.binary?.after).toBeUndefined()
+    expect(by("gone.gif")?.binary?.before?.image?.format).toBe("gif")
+    expect(by("photo.jpg")?.binary?.after?.image).toEqual({ format: "jpeg", width: 64, height: 40 })
+    expect(by("data.bin")?.binary?.before).toEqual({ size: 1028 })
+    expect(by("data.bin")?.binary?.after).toEqual({ size: 5003 })
+  })
+
+  test("text stays text", async () => {
+    writeFileSync(join(repo, "notes.txt"), "one\nTWO\n")
+    const notes = (await worktreeChanges(repo)).files.find((file) => file.path === "notes.txt")
+    expect(notes?.binary).toBeUndefined()
+    expect(notes?.after).toBe("one\nTWO\n")
+  })
+})
+
+describe("branch binaries", () => {
+  test("committed on the branch, read against the fork point", async () => {
+    await git("add", "-A")
+    await git("commit", "-m", "second")
+    const result = await branchChanges(repo, "main")
+    const shot = result.files.find((file) => file.path === "shot.png")
+    expect(shot?.binary?.after?.size).toBe(300)
+    /** The fork point's id, so the old bytes can be read again — by `o`, by the pixel diff. */
+    expect(shot?.binary?.revision).toMatch(/^[0-9a-f]{40}$/)
+  })
+})
+
+describe("reading a blob as bytes", () => {
+  test("whole, and byte for byte what was committed", async () => {
+    const read = await readBlob(repo, "main", "shot.png")
+    expect(read?.whole).toBe(true)
+    expect(Buffer.from(read?.bytes ?? []).equals(readFileSync(join(fixtures, "before.png")))).toBe(true)
+  })
+
+  test("past the cap: the head, the true size, and no more", async () => {
+    const read = await readBlob(repo, "main", "big.png", 4096)
+    expect(read?.whole).toBe(false)
+    expect(read?.size).toBe(600_033)
+    expect(read?.bytes.length).toBeLessThanOrEqual(64 * 1024)
+  })
+
+  test("not there: nothing", async () => {
+    expect(await readBlob(repo, "main", "no-such-file.png")).toBeUndefined()
+  })
+})
diff --git a/packages/review/test/image/decode.test.ts b/packages/review/test/image/decode.test.ts
new file mode 100644
index 00000000..3cd9c457
--- /dev/null
+++ b/packages/review/test/image/decode.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, test } from "bun:test"
+import { readdirSync, readFileSync } from "node:fs"
+import { join } from "node:path"
+import { decodeGif, decodeGifSoon } from "../../src/core/image/gif.ts"
+import { decodePng, decodePngSoon } from "../../src/core/image/png.ts"
+
+/**
+ * Each fixture beside a `.rgba` reference: for the PNGs computed by the generator from the samples it
+ * wrote (not by any decoder), for the GIFs by PIL. Every row filter is used, rows cycle through them.
+ */
+const dir = join(import.meta.dir, "fixtures")
+const bytes = (name: string) => new Uint8Array(readFileSync(join(dir, name)))
+const references = readdirSync(dir)
+  .filter((name) => name.endsWith(".rgba"))
+  .map((name) => name.slice(0, -".rgba".length))
+
+/**
+ * Where two RGBA buffers first differ, for a failure that says something.
+ *
+ * A fully transparent pixel has no colour worth comparing: PIL keeps the palette entry under alpha 0,
+ * this decoder leaves it black, and both draw nothing.
+ */
+const firstDifference = (a: Uint8Array, b: Uint8Array): number => {
+  if (a.length !== b.length) return Math.min(a.length, b.length)
+  for (let index = 0; index < a.length; index += 4) {
+    if (a[index + 3] !== b[index + 3]) return index + 3
+    if (a[index + 3] === 0) continue
+    for (let channel = 0; channel < 3; channel++) if (a[index + channel] !== b[index + channel]) return index
+  }
+  return -1
+}
+
+describe("PNG decodes byte-exact", () => {
+  const pngs = references.filter((name) => name.endsWith(".png"))
+  test("every colour type and depth is covered", () => expect(pngs.length).toBeGreaterThanOrEqual(19))
+
+  test.each(pngs)("%s", (name) => {
+    const pixels = decodePng(bytes(name))
+    expect([pixels.width, pixels.height]).toEqual([23, 13])
+    expect(firstDifference(pixels.data, bytes(`${name}.rgba`))).toBe(-1)
+  })
+
+  test("an APNG decodes to its default image", () => {
+    expect(firstDifference(decodePng(bytes("anim.apng")).data, bytes("rgb8.png.rgba"))).toBe(-1)
+  })
+
+  test("in slices, the same pixels, and the event loop gets turns", async () => {
+    let turns = 0
+    const pixels = await decodePngSoon(bytes("rgba16.png"), async () => {
+      turns++
+    })
+    expect(firstDifference(pixels.data, bytes("rgba16.png.rgba"))).toBe(-1)
+    expect(turns).toBeGreaterThanOrEqual(0)
+  })
+
+  test("a damaged file says so instead of drawing garbage", () => {
+    const cut = bytes("rgb8.png").subarray(0, 60)
+    expect(() => decodePng(cut)).toThrow()
+    expect(() => decodePng(bytes("still.gif"))).toThrow("not a PNG")
+  })
+})
+
+describe("GIF decodes its first frame byte-exact", () => {
+  test.each(["still.gif", "interlaced.gif", "anim.gif"])("%s", (name) => {
+    const pixels = decodeGif(bytes(name))
+    expect([pixels.width, pixels.height]).toEqual([64, 40])
+    expect(firstDifference(pixels.data, bytes(`${name}.rgba`))).toBe(-1)
+  })
+
+  test("and counts the frames", () => {
+    expect(decodeGif(bytes("anim.gif")).frames).toBe(3)
+    expect(decodeGif(bytes("still.gif")).frames).toBe(1)
+  })
+
+  test("in slices too", async () => {
+    const pixels = await decodeGifSoon(bytes("interlaced.gif"), async () => {})
+    expect(firstDifference(pixels.data, bytes("interlaced.gif.rgba"))).toBe(-1)
+  })
+})
diff --git a/packages/review/test/image/fixtures/after.gif b/packages/review/test/image/fixtures/after.gif
new file mode 100644
index 0000000000000000000000000000000000000000..1ea3e49dc110dbc4e8484f3b7e193f2e3a9f3d2d
GIT binary patch
literal 264
zcmV+j0r&n#Nk%v~VL$*V0D%AirsJWBwEFXls2(0JEC2ui06+jJ06+-g00#&hNU)&6
zg9sBUTqrPr!-o(fN|cyTfW?a#85*3(vExI7078l!NwTEIlPF87Tp1Fj%akldp7cnQ
zB0`Kf1-u+kv!_FaF=cA>*|T8KqdSutJ-Sk<)2C3QN*zeFX2qNrcUGmTQRU069uYDf
z$q;NukYi0wg_&0E+OBQMzI7RQE>y2}^XlEpx9?E16R%>``4zCugM%$GQ@;w|5^@kFm6kQn}06#3;=ZK(WFcJC74?1YJsgkza9`<^=#UyZ?pES8!gk`
Om?;AvPP};3fB-v0xpRmB

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/after.png b/packages/review/test/image/fixtures/after.png
new file mode 100644
index 0000000000000000000000000000000000000000..497c182ff9afff79652963fa7aef44900447d212
GIT binary patch
literal 300
zcmeAS@N?(olHy`uVBq!ia0vp^4nVBI!2~1^uF2j9q&|DPIEGZrc{|gZ?~s9ji~8a@
z%Hj@-7Cly)!{{>g*dqoFUePA$ix-RJZ)tSR<#qhX_oQ@U`CG}g`@I4~vo)MMKrncl
zJ>U1|<<>AFzg{IJNcGtM0;Pa~O=ZCfg8wz=kf7nn5BvRtvd{oss4hvjE2
tdp|2zXCgOqa0K6`I|7dkwl}QY!x(8}C0DQ9VhHp;gQu&X%Q~loCIEy|f3yGq

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/alpha.webp b/packages/review/test/image/fixtures/alpha.webp
new file mode 100644
index 0000000000000000000000000000000000000000..00a7f222adc34a8014bb4f697db7e8d69979681d
GIT binary patch
literal 424
zcmV;Z0ayM~Nk&GX0RRA3MM6+kP&il$0000G0000#001Wd06|PpND}}6009p${}B;F
zESUI9FNMyC{-^E%09H^qAZ7so0MH2lodGI904M-Hfi{y!B_koBI=*NCpb!aU0J}7D
zgaxX0%X8SgKn%&O$i6wf1JDEYfS>k%hH~lBZc3&R0u81;wY9w!RmQdqVxSo*e#t7L
z^LLnV0RG^6Kbih15r1YITXh!D_XXkyGz0An@T~C18!d99CwByuSPe0MZ`}-fNG>P1
zMlaEEOK7d}xtm-!JMZ-coz2G2Do|r`?;*AJ*h{m2F!s&h{YL?!V1NIq){}6d8`9m^
zYApjyH!fp`WlJ8pt-(6T5BZCS)(CGw>|X7eJD`Ejtc>7p<9i!v7!)Bd$}W;y+&Su1
z%q<5uR3}y%v9}rT#Rcdoy78NyV1~b#g~-oY%m?S
z|B-|1eR+=|qvDDOe4VFFVc*^kkeqQN+#;mJb4&W6;+`@EpEFM0kn2Q8fE`2+deEp@
STRFzGhP?wyIis%-0000`1Gq^5

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/anim.apng b/packages/review/test/image/fixtures/anim.apng
new file mode 100644
index 0000000000000000000000000000000000000000..30c589809827eebbabbfd8c46645db6bcc038ba2
GIT binary patch
literal 1010
zcmV`l#00008VPjNG00002
z00000^No{m0005ZNkl^2gQIsX$zA19y?Ak06N9uK4j6>@95YIy
z0%e%tHSwjsgU3I=_QF=-dPxTFV-iqsC%;GJF~tie=dGo8^gFvrsR3~vTy`ylWj^+X
z_IW!DNS@2?P~si#z?fwwGNyIWkJWuz9>AQeO&g<(iNf~lOl3a+QcHcfOtl%2d5hQ2
z850Pz)0t!f=uGN+Q-`^k`bslggL0Q#d~Zon-FC#-1)-M0Qs=r)!^b|Q90oQ}Iq~i0
zHMVzsGRyYQ`|peMxTZgaYf0p11M7B{cWq;Zm(qB&)9iP`Cl
zP^I9j#kA-A-2hD0k2u2T>XWpUw8+Gy1OLL~nO>Vp-@$e*sil0TXxar07=!+qpWoca
z?Y9)O=LI5lGSR=yPGHyh&Ec;vSGl6QCZAaWIF_%Uy5*A)Kl8q0SNEJz?92U{E--9F
zGE(m(@MlW(!?plq0005aNkloKK`Cd+aYa;nsp
znAv|=Kl2a|vM5^}n7a;KuDJk*B~(a-~GdMzyVj0i8sQGu`9PN1p9_0
zAT)3Br~EStC+%1Tb#dN|u?2pGsXXqA9oxnB0qd^1Vt(#E^b)K@O$0aRjg}5-w1R6U
z72gr~P9`&*i4L6?8D&AD!1WF}Nc1L$h1HAso%8iPru=l1A6#3r7kFC5G=dCS)UdWN
zmEuLAij+G5?9XXBlm@u@X%-%jn`Ahi({*
z8%Tjm(?*E|30FFFy*;iW8)6Q5LHn?(;!e-pZ@Cj*Z{4}b$HLdk+S#fCqeT<37d@O^
z7obQvybU@Sr@dcTG;g*eLn>RMH#0^Yf+pV?btP9P6D7NcsS=>?Q5bEEI1&~v#z-_J
g2kf>(PK#W7Joab6ueSWE!vFvP07*qoM6N<$f^i?((*OVf

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/anim.gif b/packages/review/test/image/fixtures/anim.gif
new file mode 100644
index 0000000000000000000000000000000000000000..de1d540a6b6522b26cf1203ed0bb2f4a74089e0c
GIT binary patch
literal 594
zcmZ?wbhEHbbYRe6Xk=hm_IP3EmS1vmdJGHVf-e|GKf}gnS4(6pE&eVb0D(c@F>ip)oAL=DqVm)YxwY5yv?d1=}5
zMQIWhTVG$_kbHVxuYm%{hm2edEDSmtK!S^bjiC_|B%ixi07Hd?AKAA$5&}^5%nUj#
zK=l?33NZDMAmsQ2Rqxo$ETk1uQSjhUJF^rQgTqE8hE66$qbVy2lO4MiG>hJJY)p1)
zRtz>XiJYW*aEbx{t{BT=r86^Fvz6vlZcg``EXTR)jc15PKo64%m&&Wl3;kwsPd7R`
zbD~Pna-QX8zN@~hx;Qy1KdJZC*YqPBLKLS>trW{Wu~Gf{zb!AXDc$KP>boVWWgHu-S0R{9NrvJ3%=%8G5f>2``p$3Yk(RGQE;;c3Q7#!GKHZeFZlPq~1HCgo6gxieIn9p_l
zu4`b*VBKW?z0UP3^Iwvo=Ty8p_drLWum&OH1`=G%?WicUvrzb2eo
zcJTSxKU;sxh}9fmVEFg$@BX`$hyPn1_xoO2W3s}^OR!zz<(_}1UM8eY;nENZsjK1q
zeE*+e{h|dtifsqewVaeXR~UZn`org*=W#Zr$8<+z2v6nLy1?`sZj1Ju^-?JLpr~s0
zaAADHgej9*$`-mbr=0z&zj>|TrA2J_J#PDy1clzb^xiG=)P(P|m+Lxj+_z)ff#*jo
zwKuKmbL1{v7SA+8`;a>OquWcR@^Wt&HJiS_UwkL%Z_TrL+n23A%bCKR^5UYs?aceX
z#gio;l!_*OpMOGA_~VtZIZGQJTz<;(=yP;;`fq!~cL{S-H=PUFw7q!QZ)WC9iTc+Q
z^Z)Ni?7XneZc}Y6@1OgtCVaLMoS<5kcuQfk_`N3vcB)c)Kf70(J=;F-W{z2*Wu*H1
zEk0_ZGbSirSiC>XLptHyL(|>C4l@`xt=a9lh2e$$4(DqRZ;QX%*w)9#$j;qZvv8J-
zObIBKeckMU(JBItdQJugRt5%OyfT2|RSig+FoNQhivg4p)KnKRFc&cV`!Hd#f7<{5
P^Jf0Pv;S}IE1(Pj+$@kyRo%T0jiAgxjE
zB2N)O5C9Nu0XPJ<0wSJ3B;bie0+B=_k}*9DqfjuK?#DWMhBPB1LmGp@G~t*rSF%|Q
zhPk~t+uFv~&eqs$)tXh@H5{%jcX!
zQW4k&%(QzFkoN(92En1)eMw{rwnE#W&;vLGMR9nPK)~a*-KpAlfTt4lE%;lA27X~A
z%Q%`thV%lN?N--J_wSjp5`@QRQZR<0kuj5FZNs&-a}+wQb#_^|b(_1$W4ou<&Vaz6
z;E>Q=5&QN>MjeQb`6eOpaFX~)^0!&Xj-SXrDaku?HeV+H{@nSJ(u-x~6+c#1UAbDX
zXt;L$=Nm1pZS5Vm@7%rj@Ymi)zxDMG433VCPdu4aJ)N4KoqIX|O1-f7`pvQn0q6&o
z_Wl6-hl{Fp;qZ7APg-^%xFqdCsd$0~pQyjZj}#VXVCj%SrnyNk)HPGs0{{k-AUu*!goWt_^%zRUh}R`~pe
z^d(x8#-nM@2ZDw`*Qik&^&x3$s^;3HMnZR#5Cg+czT3L{V1xW80$Eu85RP>jRs$1l3$#kBmQ1xIwxO=bjqOiQH!ZU7k
z+tJ;Ny>!KqPd%IjQkhjXE0^_rL`O$vif(Wi>ato(RqhnEo38b*snI2y&zq|&yy|1B
z+Y+WbN|ZasN0V|LA;8k3Avn;QxOz}oTj*9Dap;}Oev^{d2*I)D;`FY~qJo
zZYTb(l+6NB=ia%8wi7xc^rN1Uqv8B#od>5pCnb+5*4L$$$AzEFttSNfI(XU?qc>BE
zW_{YE;?j=b;D$V`@(CvJ;4><4#$v`TF

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/before.gif b/packages/review/test/image/fixtures/before.gif
new file mode 100644
index 0000000000000000000000000000000000000000..c07f1647efde9c824157d619d91543781f59bd72
GIT binary patch
literal 241
zcmVa4rBSNAdFIL6K5vxe27QLPX
z8&+&qvLnr+HEMFL+qZDz%0;-9tK7O1=i-$acW=anIY;`1n9%Fa!8g0ARfsq-L9=J+
rHuecQ@>~IxEzixY^D<`606Y)PEc!9z(u#qizS?rN>({W81_S^*$KYo0

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/before.png b/packages/review/test/image/fixtures/before.png
new file mode 100644
index 0000000000000000000000000000000000000000..b69a2499ddcbbd65d74a6aa77b56d4d81a86f6bb
GIT binary patch
literal 278
zcmeAS@N?(olHy`uVBq!ia0vp^4nVBI!2~1^uF2j9r0#mUIEGZrc{_8r;2{G6m+RK+
z4yy`GyeQbBqUFe`SmNRqxMbeJUlNjcbstZ^xo>ir`||#`iL>|u+63BxFnvyZjNJO%
z=>7Bie;Bq{K;RMkwN7D2o`3zEW^tE==ZNad&+e|$Usmq3YnylVy33`VzmjbJu0Ee0
z=v1o~UMH_`WTDsJB~cUP8#jLbef*igBM97(o<7z6PpIx2MC+6cQE@6%&_`l#-T_m6KOcR8m$^Ra4i{)Y8_`
z)zddHG%_|ZH8Z!cw6eCbwX=6{baHlab#wRd^z!!c_45x13RUzF>}`JIdkXDU$Ah|;w4L$Enl&6)#^2C*R9{Mant54TeofBv2)k%J$v`ky{uTfL

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/bottomup.bmp b/packages/review/test/image/fixtures/bottomup.bmp
new file mode 100644
index 0000000000000000000000000000000000000000..d4e6c2d6e520976f2b92b54d893f1304afb7e119
GIT binary patch
literal 7734
zcmeH_F>=B{3`NZasHt41qNHZhb1;sPCMU=dxCysNm7SRJX#TFGZ?tl?9vfED_x}$K
zpSR0A{a=^z`B9$h>-#XB%Bfss{x~kr(wL_6Jdb;9U~FJ);Oz~3J-_SwZMQvNtg)f9
zC(PC?K-J2vw)^Nc+Gc+rBtE4HX*YTEY^BX#hvz!GY>dE7$rCVmuExOo{kiwOA`1_vz&`oO%D
zpkYc#kC#knM@<|U96GWwR*)Vqnb3}!I50SLWMix#Jzg@Q9W`-aaOlW3ti}e$2I>v`
E0g1wQ>Hq)$

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/core.bmp b/packages/review/test/image/fixtures/core.bmp
new file mode 100644
index 0000000000000000000000000000000000000000..5460cb0837959a7a4c5c2bd4bcb31cb80477a3d2
GIT binary patch
literal 30
acmZ?rm1BSaDImoI#Ow^L42%pCU_k&E!2tOH

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/exif.jpg b/packages/review/test/image/fixtures/exif.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..9e6ae07ceb5eb6cf3be4e47ebd466100e3a8b22e
GIT binary patch
literal 31370
zcmeI*=~EMD9Ki8sv$>^|ND?4&NeBdj)exef;!O}b$RP-7wFMLett}KJqQF4$ia0i?
zNwo|rh?Gl<60{PfoL-}##d6JvfD*yc0%F7?Y?F4TebX0hr!RlMo!yz8XJ==>pZxO@V#II6PccjU8rhlEgXYaoK2Nd~7jviB~zde4U
zsQ66D+0yUJ$}e23(bQhL{Nt6zrskH`>o;!Ry8m;>gI_wkx_kNu1|L5e(mfp>85@5&
z@k;-C^37XQ7l!aZvW(Zi?P3|bNMtfjrkT1hQkp58MW)OYQ>QNtr9~%MI(cT(*~^rt
zs~Z@6N!TbSCixzd>+IDp7&E2)UiQzza{g1^&m4GGyXiBg%+c60_}S^jX=5e
z&j~b4pxAe_)dcd`eW&+QR*m>jeN`%fMoS1Z;!B|XEW?Gv-LI!&`}DsONF(YJ^)*G8
zg`X)Alnr&B598RzOf1ZrV%Hn|>&Jr$R2$(N-|wp5uFS|VTpBW{7%tuVpt{;J@4HhH
zh5pv{G4*=|PiK&~5vZs~Q)sR@n7H_OL6UTRpzbz1;;Lv>-2;uaN6J(CffqY^Z4)|9
zK1Da3N0D=9`!%ac>8Z5|H>8g<0u3ED^kajEW)$Qy!<9E|w&`pdb)1(EDjL`)v|HHRFzS~EpYv8lRfCC|m^IuJX};g-6RQV?Xj5*|KQ%ET`}Tkcef4)3@IKm
zTrMk}_DVk*UqFcn@eFW1g+cuEw6h7Y1a$36L3s?my
zoa}>o^?FWjaG{Rx$BwK_pXtXA!8YjjOu5UCH1#A$wFop%Dif!3>hh`&$mEp3e4kc^
zH^Q?0IFj7BDnasQjv&FQG%u6CbxNdpCz$x5x7)6ysw$+&hx|=ce$*@5cw0O#uhY(t
REo0@>MC6D=*Z33NzX2YKnz*nBxL)@F$>1|RHUKXg>CjGKxccr6x`vPL7G#%SGmhyK1N(dRV7Z*$hh(Id|NZQNiae#Xod#4y&0y7}=N4zbk6l!n^B#1;d_IzYT*w-6+
z0B4b~FVn=PlWRY>GGeKQsApON4}Zv{H65oCvzZ(R2{+dakjM7dpWt=2We9lZCrEe_
z4N$%9#-JUD!SBBcA(JYuQ?j86J0hJWDdIE&E7ig|BK3(byFJ~Bs{sbs$7d8KUC7+*U--ZAH0%A!-K~%uYkcuE#
zBxjwco}|nIsj_SUy($y*4R5%&pBfJ#v53eS3TRM%S72$Y3StOlZm$A4OEH$|X|%LP
z&iMD$Fq*h@cmw=)a&}f9XcG*T37)>TNf?6)pjGZIQ75ytwz7qm+zN
z_R!0VEzK-!;XPVAhkF=ZiBAi$|R>tkiBj&aPUA-!U`zdSAT#Xv-TY@RUzT8V4
z6xqANZWf``1JqyZVF1O?5Pr>3Ot`nu&}mmjOG4tN!?HCF^8$#JK*D(XylT$+KWLpb
z2g{)*3|eH*{urW!3gjUj_$({$1pC$7lYVF{(Qt_&5GxRyYx-4%_pX^taB7jX{OhJ|
zDUlHM8_-#UX6Y{o5sXBk(RpXh)bt`q&+Yyw`o)8tte1U8Ng6QAp6E)CZ*m!lq&{Xw
zC{j?LU>Ry%djR1Hc4XXx@KA9&Bd*;nEDgK8kOfTgv>Bs{q&ET6
z0Q`q9tWecKK^-u8@(Pv^qc%bL%$bNAlhOc~-Iq+7t)B1@v{bmAJvd)GguMF3mMfm8
z?$JdMtmNc@{~5SrD{q+K@^Wak1-rxK@?3FHBDx+AE_%SpkGvWJ9)B#O@(OTGKoaDc
zLQ&0=P5!EwqSU;(ZCYLieJkA!-G93G6%{VC$A+WNm#w8R)x2Hs8M4PV1LV~y^OCx|
zm1_%*4fgq@Yla6%JUFgTy0zWGGp}+00000NkvXXu0mjfRX%ax

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/grey-alpha16.png.rgba b/packages/review/test/image/fixtures/grey-alpha16.png.rgba
new file mode 100644
index 0000000000000000000000000000000000000000..a3eb15109026632b58222ca2fbb9c651be2ff349
GIT binary patch
literal 1196
zcmWku2`r>%7-np&aYZKTXb{&J_pvfmT%(S#NOTFcno;5!lepGR9OJ5ul_J8p>Kvp@
z$B|vBv24W=M_aenZPyVDGmf${q|g4zpFdx|?|`xPb(@aIxsXe{D`NqudnZOe0=<-fq}u2v$HdgMx(9A#>QqQCMIU#<;uy)
z&6kyxRcdHxJY8K~Wlv2_u>%7GMaRd-g38Lud1Yl~tMl{oj?mCh#rymF;MCOA*CQh%
z-*k0#NevDT_D@bu+V%GKc4OV(>FN0r^|a5<&YBYv5~z}rlB02PaSMfoh28o2`3k^L
zGBh+q9v>esAQFjvD=RDVH8nLX85tSAy1KenMn=YorKM%BzP>)IzrX*PmzNji#8WYnzgkl=KWW>^L0Gc1ugkmA}9LyhtSS
z>*(nCyM9*p#p)R3YfnG|M#eu
zk&~12X?lA4VP|K@D<&o;naAUG>*(m{H#9UjTwPuL0BtTLBqaU>N7B~T){nqQi;j-w
zp)QBXWWEaz4==`=kei#^4vr)?oBb46<>AX3u)c*>N6?w%=jSJAZf^eR;^IQKs;Y_$
zy@lo%uuiI$w4yoNrRrKP1jA0MAyc=tVYEJ1#>>gwv^vHu^w!@xizlgW0-pR25_
z?2CtohZj;(Qia3A!{ZDF;}$*^WoKt|+}+)m;oDGWXJ?1Ew>J?u#MnzSGBOgOHskj8
z*4@d;>Da-+fdHTBVPRn(^z`&(A|fJc;jw*CP|!!@-##iTYP`I>JQ8#9m%Y9H3V7!u
zgNyL@ALPd$eaeB?6Z(sheFnUyqu(@zLJC3;hl|E#sOHSXx>
z=*;^1y1$Bw3L9R>LD$*6y}i`U%}pV=>0@60g@&czyi-zA@_=)npzk}>5h81iwY9ZW
zoVf*7ePG?iy!KyTU%&aHwVRvU31-9;n*0oGgW%VWT4Dl$;Dd}a_%6&`pY1C7A7g1QxBV`F^y_y&9v@9yqihlGTD27e~*3WMe4Wfn5%01e)X#o{Dn
z+7P|@1qB7Zz(~V9`s3e6UtixOWa&%Jq>#f|_>o>wQDIYEU9Fy%mlu$kndxzQdg_Ln
YPmoI{^4^4dRA9gp~nC
z;v=t;&1ykr5I};Afy%D6yX6XFD4?w0Lh;&ahdK3iHr+{evT&=Dlh1NAR|m`D(>p5T(G
zya52NdlFV7n#+NKx2Mm|dKI-n_PxC?33zAlauGmqguN^TZ-B1us4fqPfS^ap))tx3
z*oeWTMt~ZSFo!OG3K(KOHE%J%*$mYOUpsx!FiimaSxU}B*N%uQdOPd7GF@&)2rsC1
zpmaYN)?5)U^!IGgnrTVx@KqJCYO6eJ;Q=;0bq{Sm>vPl}YmJyMNCsHkPLdLdLb@sd
z00B2iL_t(KP)=!^t8_J8RM=j$tdSn2MsE60g5(LO=cG76twGnr|$
zY@l&A&2Y_fPt>5pG3tM5vH)M-t%-hAms>bl4|}}xMb__B6}~J9qDc)NUO}6dH&D?}
zm!nxk{GIH^qI-M=Th4+~K
z=@YU7>oC&-0e>Q0rm$=)U?5Qa_t%hj+0I73oPgIdcfb)1MBlWHwuMY~7ObDjj@wN^
ZW_WDbB$GVY`bq!*002ovPDHLkV1kqpIRgLy

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/grey-alpha8.png.rgba b/packages/review/test/image/fixtures/grey-alpha8.png.rgba
new file mode 100644
index 0000000000000000000000000000000000000000..cdc5713e32661f1d08e626e0ea0eb85f2ec3064c
GIT binary patch
literal 1196
zcmWMlX)NP;7#?(tt}A328Drwu4}zfX&{>AE8S3~@q1H97P&L+7=Pqjr
z7R=AjvpPCDGzkQPOd^qpTUuIFEEemB=H_O`$jHd&v9YmP8ylO4`uh3{ZEbD;F&K;w
z>FMc8Lqo&!%*@Q5i;If~6%`fl0s;c|)6&xF@9yqmlarHwUtV4oYHMqgnwpx16A}`F
zA|oR=8X6kBW@cuBNF)-ktgLK<&1N6h*VhZn%gZ&hva)*f^71;-E2*lg66)ya{8m?2
zr^(@P0+>wZq?ec1lu#&q9vvOMgI>FXg9BA-YwM(uk&$p~YpV+16)KgAPoYqL#6C_1
z1qDChGDu2FD)jaBy$2Q*W}9Ib*7o+cB0fI;G0roxwYB9A4-b=XZf>Y_I-NwNQvdYv
z@!#nY@
z#^7!K_cL_RTVG#i0|(pR
z-~R;srk9kI1nKGNvHkq~ZZ|eIL~3ekA2v5P&2YX7b^oC53wEIg1_rL6W~-*A<_&NZ
zfg}0E#Dr5!Ow4QOq2=JgMJ~#&ZyQ-fC!Qd;!0v;E=zwv$JJsX}MceR3utlTnxf{rC2O(wzs#J
z0#BExr{~iB{rw?$d_FljX^6TgFU0r6-r1R&5fKrs(C`gB^(#CQ+Su4w1)iBS8ZAL4llf(2Wc0y{OYqK$LZMg+4GoH@PPy#D^@C%?~jj
zdy9vLhJ+y@A)n-OxiNO*q1GAse>yrkx&kbsqoX4(JoXLn*(N3?O1{R0yhcM0dt|T_
zx%h5>fB!*YVd0aUoScWahs@5-Qp3W+mXWC%b#-+X&g0$Q-WtHOACb8uc&rzGQ$jz&
E|K?9r3;+NC

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/grey1-adam7.png b/packages/review/test/image/fixtures/grey1-adam7.png
new file mode 100644
index 0000000000000000000000000000000000000000..434bd5310b6f6fa2950353ee979c6dbe07f75473
GIT binary patch
literal 154
zcmeAS@N?(olHy`uVBq!ia0vp^;vhC7kYJRR{*eZxwLD!MLn>}DdNFMM&!E8Az@)%D
zfklMDhtYxQ2Xh7s2ZQ4S#+~X+yC*Pv&u01WU@a881gJ|Jq>CYsf${lHCYc4yr+2Ze
zZD5%8u7&Zy@2gA`mTzEAU$~BiEBg(@kvl~qLQ)C}ZOtE9J#QVd{EhfSGIxIw
z>NGe!?s}WMzvwYKUwLcH^nUI1OrGdD?!1{F=PqmTM)Y9qtUh)!lixnPwZopw)rTJ*
HzY*;OfD_46

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/grey1.png b/packages/review/test/image/fixtures/grey1.png
new file mode 100644
index 0000000000000000000000000000000000000000..568cca382338e08ac3fe6e09dd7f2096a04eae3a
GIT binary patch
literal 132
zcmeAS@N?(olHy`uVBq!ia0vp^;vhC7kYLD?Iq?BV$$PpuhE&{QG+{XXpJ9cc_!EWHnH!0!S&NLbfy0@^&`n!_oIy+8BiyA_QX?3n%Ym
b+WPFgTe~DWM4f@-!&J

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/grey1.png.rgba b/packages/review/test/image/fixtures/grey1.png.rgba
new file mode 100644
index 0000000000000000000000000000000000000000..5c0660c1d315db0b0fb19d5c8f39a9fcb0209e3b
GIT binary patch
literal 1196
zcma)#3krZR3`2MSOHZRbc!W+YXxjX?_ugx*`~PD0>4y93qX&E*&>Mey$TiXnw(fp*
zBYX1GbZ_6zxWm5FPS(0Pb|c;s#v6^n#~qwF^eJoqw8N2|Zu&ty+(tC|(3`Outm9{$9^(_@^n);oLhhi9A#*SCGqE^3g>
APyhe`

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/grey16.png b/packages/review/test/image/fixtures/grey16.png
new file mode 100644
index 0000000000000000000000000000000000000000..b593582e1113a4563327b1e7fe41db1c2f88c4b5
GIT binary patch
literal 691
zcmV;k0!;mhP)1^tH_4W8s&&D-$IAfms@WdE4&BGR_ujOc;jc%QGlmgVnxRUs5p9b!bbt{
zVF6nbhzd{AN0{Cw+0XS{^j}*r|MN7)(*5i#g#A<;I$pV$4#pP&7zTwgi2@~bG5!>z
z6d2n!?^J{`SOIQbS85V~9{eeK1d!NP_9{V1M7QgNH8mqlQ=w4j1DX6vyLVm*JsB^p8dSNj>D{wW{&1b^?y=!U5AM-7p%7g@S3zbGfR7DXUE_#m^0^#
zu-HF3HM>`(%QNm>w25|Wa`0dAykf=u=Ydr~3cdg-Y5NB?h^IZa6EL^UbIJ%T9@rra
zM47|V}-~(
ztKFGx8HXw3%M=PsL#}xf3;<~J^OFcay=AY8(i!RSYmiwxm;vF`Ta6F;GjPJ@5fFKX%^%^VP?
z3aVkSK*dY~?mSWZPOj!|4Il3pF5wl&BW`00J2`hkLK+29g_Dthckkx*UyB8Vg%~a3
Zb_nGmCl*)*_L2Yq002ovPDHLkV1f{6I+y?e

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/grey16.png.rgba b/packages/review/test/image/fixtures/grey16.png.rgba
new file mode 100644
index 0000000000000000000000000000000000000000..c5200fa516fc4425dc356b544b51b58f02c0891c
GIT binary patch
literal 1196
zcmW+$XDIb?6gD!lbrTs0MIy=e1yK^2i7!@?kxgcZ%nu4FL`orwvWbxFL_#6^lL#R*
z((}I0x%KaN@BN+gbj~yW3WWl`zP=b489_rs158a#(bUw0>+5SYHa4QOvlF?wxo~iB
zfQE(!Vq#)YUtf>X(o&?Qr6Dph5<^2nNKQ`1*47q=hlf#KUXIn(RlK~s;Pv$ts;a8c
z)zuZul9Cei_VywxD+}A(+px2Kp|-Xbt*xyvF)_j7
z;vxnH2JrUwCRm*9=;%mhBzMG{n3xa_Sf7)V11BdZspY<`QBqP84mpdNnHf}6R4_R?
ziQnH}I6FJbnUIixzrR1hBJbq8t*s5&+1V&AE(Sl*(b4eo@`Am+z4W-dyNf2Xv$KM;
zx3`C#ogHv*?xdrmgXQIA;ijjjM|3|wKNlUS8#Q`(cz~aupYX+*sBw6BIBsumF*rCV
zK6rY1!p+T%@WNSGPai!#KBBCwOgup^b$545FZ-zpv4}~1=_CG5N=iaqT^-!q++-h~
zDJdzSmZ71c2nh)hoUX1eTwPtExw%>HJv}`|>z0-l*=KWe6P$}$tE;PHcXwB~&d<*m
zFY``YTU$IuJ@@zbh4+ez3S3-V;N;{4MMXvO#Kpx)Htz23aCv!&zP>*31GPOqJ_dV;
z9}y9O>gsCY_Ur2l_xJapXATYy5FZ~e`>Cm^Nj`pleq>(7|J<>M*&+}0E;*qe*-y{X
zTbi1hSXx@b*w`2j4-e%oGcyzP1@q(M;{)n!Zf=g6ni}C{ZEa2Fvb3}m4s300WnS(!
zJv}YCCT|4=1)%=)M_yi@;88nflb-VT_lJ#*jp)cM7Zw&uPKiOUKR-W%d7t7YrAknQmIC!Z=ChWYTh&cAF;i)p1NO
zr!`i^mM||%ke3Qy&eHA~!Npd?p!3#onfeDt{}~rASv_HTd-2r8>pk<$)c$<<^Em^k
Oox#)9&t;ucLK6TDp*zz6

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/grey2.png.rgba b/packages/review/test/image/fixtures/grey2.png.rgba
new file mode 100644
index 0000000000000000000000000000000000000000..67a94efd90bedd9287357581b7945eb5c3a68499
GIT binary patch
literal 1196
zcmY+<0Sdz~3`5agGMDTnv;DyX&5+8LB_+N0-sgGlIp@Cb``6>bDom@I9b6eze7(#?
zT3M^}mawxsnPJtg+L=dA`{lOJxASW2%coh4mmlwq`n-Btn2(n(jN3|Ih8%qwS#k2>
zs^yyH@X*L@E~EV(IqP4JFQl=T7sjpDj%PJ-Ri54BXhVm#S?w;aEPFe%o%_o3=FQuj
z_W9kNmtQWFFOybwxLJH*TKmvkmN%@oy*Kf@_ZRg2(1%;RaqbD_K)cO}b2gVpra9F6
N7rCK-$d|5)`vA{l+d%*T

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/grey4.png b/packages/review/test/image/fixtures/grey4.png
new file mode 100644
index 0000000000000000000000000000000000000000..19c151e07645e5468f46fba5717ea368c03ed9fb
GIT binary patch
literal 249
zcmeAS@N?(olHy`uVBq!ia0vp^;vhB)kYHH!$GrhaMR~e7hE&{QT*(mjpF#1S=SSDS
zD`)gn{xM(-y?F66bEM9T3xV8KOzj&kZs*SXpu4Du%Yk`)n|v)x){mQWYSk@Rw67SP
zFtVI^e}6!w1H(kNzZ$jMKd#-AFe!oY+kzLrPDUk3!)%dG>I$kp_3jLl^s$^>#|x59
z=bhIHV6O5@p3c3X^y@l_X;Ccp3~S81x~B9^xG2oPVE?A~%U4eKRj<{K7BF7&ZPN?=
sCw?&U+bmC}zopr0Qezd_y7O^

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/grey4.png.rgba b/packages/review/test/image/fixtures/grey4.png.rgba
new file mode 100644
index 0000000000000000000000000000000000000000..0b7e5bc12af4d2c0b5152983f91e02b373baac46
GIT binary patch
literal 1196
zcmZ9~4Xpw(5Ju6pfQ7ZN7O;SYwSWaIU;ztj0Sj2bLg6&IFXS~TZD+opnOW9#9jDW2
zyk4*Ie!s{0d>;4veOTh&m&;|oVIGgi%){Jnx4ECs=eS<4e?0GP+a?cp{d(;CJ~iO@
z&~eh_Auk7uBk%4MKaOL1!7a-&d3^B2+|>iGZp#}leR^W_;O%PA4_(;iF-5*V;<({)
zXz0<2V|&ZN(nt@+n+Kd4)D3UL%l-C&gKx{xtv@--8IB%YzO*dy-@d)gBn@~oE3U_8
zOb<6LXLaH?2X%?-#q#~qr^`d%wB6GeD=&`MjMda^`aj^g^M&ULuOE1EdAz=v+VlX!
zOO8%F8sacCEqQo)?zFuvy72m|pHBF6_~LrU@B8uT?o>ndI%zjcn)KoLTIOcy6&_y<
zk5{u$GynR9hxy+xyqtJ5=NDHj?`BM=?UvHyF+U005#%L_t)?d*Qm%C;~d~LeB)R7y_wU-D>}0~^WvSYcgb@B`RX`BQX&`vFmRHc38x6;(YkbW39(`vkqCALig9aH>b7u>((O
z#eTH|ZB++)S^&dlcb*-}?rDMq#p^7!Aa~1zr1WQCYyo1;EjT6q7k)CB=EHZ0FFYnv
zZlT|MCIYWHoY~CS4CQ7mhE#W`ZR07*qoM6N<$f@Jck
A=>Px#

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/grey8-key.png.rgba b/packages/review/test/image/fixtures/grey8-key.png.rgba
new file mode 100644
index 0000000000000000000000000000000000000000..7c12780716b64676ff3029b69b5af3df1d4b0d64
GIT binary patch
literal 1196
zcmW-gNhri|9K|PV9PCTTk|f($A`T?W!M=n!C|Rav>^Ztfk|iO@?m(6lWjhGj6AE##
zhU6fM>ot;TtcXxMWWo2P*ZVo0UCNMTOhOMnFMn^~C
z@9!@?K0iODhq<}A%y@Wsh=_;?jE|4YnT3S~OiWD3Y}BH)wH23_mzbQK#Odj&Xku?~
zkM{O+H3gmb0&Hie&8iRv_=;`S}WMm|Ke0*SKWrf|{
zUF`4g!_Ll5?)iU6NQmsr+4J)9@b>lwCnqPYuC5{>Apu@qUMMLkk@xTJ?vR<8DL!#`
z3kwUJoSewBfq?-Q7Z=6jzP>)x)YPE2w-?#j*>Z*&3=Iv5P8Sy!aBy${wdGu#n{_zb
z_4PGeU0sEd&CN}uq@>792L}fT3JQ{WUS3{grlh1KIY&=)babGnr-$(Ha43~Zl$Djq
zeNefZ3ba6f8!ets^D(i3XJZ)Sw~ATtVuLU^sKt3yLWgRqmDnhI-cYqYhsAvQKv
zyliS};(C%P?n^$`*Vm!dYNZ}~F(2F8+roQcVWH?iT{$mxAxq@Y#l=P5+1c3GHa0fGF0&OJ9E|1VWjs7Qz|+%Hyr-t7
zrl!I<^_h(W0B`YEsi
z#J7`x3$-Foel^f@&iHmV;!;%0*hn)2#e9jk=u-|RmCAey>`Y!f^uk-zqWbj&S&btr
zHF4JAPI|v)3Z|l!n`^zMw}heqw?9mNVigmmJjdyr*PVaa$Gqg=TMGdJ`t*uy2630#
zYNC8!{Qv*}qDe$SRO}yiCEs(jG5{{2=d95yuO1GdvzMNL9T-}5>ILym5jfVq%-!lwV2s@sYd$`g9iy
z8v(VOHnwupnnY;3^HD?xd`#v6G4CosL%Mz!moOQF)vOi??Zc27*vuFL)kOYDnN3uJ
mZMGbBufWOA9T@uh!)@A

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/grey8.png.rgba b/packages/review/test/image/fixtures/grey8.png.rgba
new file mode 100644
index 0000000000000000000000000000000000000000..9e111a8e38624dd474732356dce4c545eaf1d5e5
GIT binary patch
literal 1196
zcmW-hXDH}l6vnwiLfQLfe6T{vOk@{QW`vNLe34CwD3qk+Mm92xVo4
z_+X@rp4)T2{d>=O&wHNVdCvdo=?OYII&gJ$#jjt#@cR0Sl9CeK+}vPcVF7x2dT4EJ
zMQLd%e0+RhY;26Qv@~R7WWd_m8uRn>NJ>hQ{My=DC@Cqy-Q68yV`Iq6%Y&}2E@o$E
zF)}iOkdP1<7#JWYCkHh(H4@{uva&LUhK8`ayDRllQc}>{+Y1{T8_Ctu(!$Tr4?aFV
zU}0f_ot+)HxVT_#Z4D6-5pZ&HLQG5y*4Nk3(9j@tnwy)^-QA6Yg9DhDm`J^ei3#lQ
z@5`B*ni{63r;(YNiItTVY;SMNTkQYr>`_
zHg)lBM@L7@&CMY`J|1CVVHh191^bAOj+QekD=T<-c;N5fzxew4LRMB5IyySw=jSI}
zsIR}jAEu_JSYBR+gM)+I&Ck!5{@Clw%L|f|lZCCmzCJcKHl$b9oSdA5udgq{!^2To
zS&8)YbTEfA7#tjgx3@R`{riXe`+IbCb>Z;vP#9HJRpI#fSYn*j_4PF_E-qxQ>gwuH
zR8++0=O><@pHWs;Ch>=d2Z^&E_Q07_pTEDq^l@@>f|;2aVRw0XDL$f?$j#o~9({d%
zu(h=ntxHQw2o4U0rluw=EiK{c=_&pS3=9-bEiEmGjg194Fivk(S63q{DoS)UHa3c;
z(9lrv)%*K9a&vQ0S67Gf@^TCe42ZAjb22Btt*tF#+tkz~-dkK;L|j}P>g(%e=0-+F
zn3|fx-Q6AB+}z~7)zww0!yM+573a*n;o)J}+1Y_wi;Ihqn3xFGXlrYeyFEQUqFF&f
z0ovNyqJ?w%^XCsvPfx{@^cU|?H+dHo6~WNZ5S#%$!8weNkE64*Q+U(Q1qB7z+uIW#
z1qB6(hsm9LOg=oLPb(@a#22@>w{UiLmOWzc+?UPGP0`IgG&3_3zE@XQ`2PL|y+QWu
zo4adnZVn9%4e?`YYO3(%JHLPb7EUTEDza1D3HFtpoh>_;kdOc};QqF^w~NlVw>SA;
ZkmdRLx%i6=$(;Fxg@sa+{}uj|{sWNI)Bpeg

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/interlaced.gif b/packages/review/test/image/fixtures/interlaced.gif
new file mode 100644
index 0000000000000000000000000000000000000000..12de986a64259d66d4e5236ede22ef8e3decfd58
GIT binary patch
literal 249
zcmVa4rBSNAdFIL6K
z5vxe27QLPX8&+&qvLnr+HEMFL+qZDz%0;-9tK7O1=i-$acW=anIY;`1n9%Fa!8g0A
zRfsq-L9=J+HuecQ@>~IxEzixY^D<`606Y)PEc!9z(u#qizS?rN>({W81_S^*{>^9+

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/interlaced.gif.rgba b/packages/review/test/image/fixtures/interlaced.gif.rgba
new file mode 100644
index 0000000000000000000000000000000000000000..fa22feba6e80e2bf117da46df4c09bb56ae018f1
GIT binary patch
literal 10240
zcmeI1%MHUY3`0AKN9dj%qnA$5jj}~A{oHbJWQsK`2H?R!VN;;?J@nb%=kp2H{{e)oh4A%ZAh;{?!_UgVj{Cq5dpiREK&i=pqjG
zOo0o%1IrlIq24OGh$VZb!j!#7mb0hNdaCJrOz55(3ws}wcdui;RO|L^qu;6wGwv0!
U&v?F{k^d&+%$%8L;G8q?0ohBbp#T5?

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/lossless.webp b/packages/review/test/image/fixtures/lossless.webp
new file mode 100644
index 0000000000000000000000000000000000000000..5022f48b8a3974730bdd329e8f25216574081555
GIT binary patch
literal 124
zcmV-?0E7QhNk&F=00012MM6+kP&iCz0000lKfnn97a$NwOj6i(cClcJNtR#&fy5~7
zWrmvnkiP_MBU!D(5R$tI`f)!hg-~;CV(YIwqW=>h`@f&h74VJ#ZL45yfHxI@rbyaS
eldVMd5&!3@$ySP_EhR}a;B6JGEkN4@mlFe36Eh|N

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/lossy.webp b/packages/review/test/image/fixtures/lossy.webp
new file mode 100644
index 0000000000000000000000000000000000000000..cdff431b049629acfafb30c8edadae9f16e7358d
GIT binary patch
literal 374
zcmV-+0g3)nNk&F)0RRA3MM6+kP&goB0RRAS2>_h|DnI}z06u{>lSm~aA)z|HXaJxP
z31k4fG;)Lms&>nB*o-=C#r}cl0s6pLwU?!v)6RaHaK!1?cFnRMU%Sm!iOU8mBVeNY
z)X-xiqU|sM{`y@*p9i<2+y`=I=_pcN2f
zIFZQCx-mIxEESO6BERKzyPJ)!{D3^u!4)+j{qHq#{QG9`e&`?0vq58p6(I9>tb$VQ
zoiSOpf>@h6iY`$1)%a5(U3iZ9i-*=IZ$a!{?U_5EfzPar;CMmWSAe2v^AER~n`Ln0
zu<8@^h&j5UYD*x#0eyaYRmMAY<2O9P3j|NlY+BbMxJ&Z(qq7}TC^5}CXP2lmDb(3X
z8y9$Oo%QN~Ju!uCbUXnI$PA~>*`ZAR!FX^<4@2dlhx2>6>pT_^-aC3nDPBA-sIsR0
UXSB7`!se|Rr@>V2<}VNc0GN-p#{d8T

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/palette1.png b/packages/review/test/image/fixtures/palette1.png
new file mode 100644
index 0000000000000000000000000000000000000000..82c6312a8204a28c4e2e967326aedd6b2f87ed1a
GIT binary patch
literal 150
zcmeAS@N?(olHy`uVBq!ia0vp^;vhC7GmxxUnJx#U*aCb)T$PXA|E|=yarY|)poqMu
zi(^Q|Ek+ZD)BhRjHNqH`*iJEt&vjuoJ+O~u|C?0|-vrf~AL=9N&w4D
z*`|=7H4M*ms&35L!MOF|-#W&BOxJn;IY@5ES5g&NwKsSsP&YYS}jcYs7_M;{u0uKR)T*vy;;t#-%{yhbaD@+hr5U0001C
zNkl~A)
zj0(YD&Gcw}kWusS13wPW@&omTchsuN0R#ne`3n6UFVWG87eD}3=AX)S#Fe8KsIjmw
W-%_@y)Zy;{0000zENG%5krX0|4@VUagT!@S2J4{>Q__wZnYE(_Gfm$}hcqrUlf;>
zPWi#npDy2G`Q<)o!A$>~*F1@Lw1a$2
z?qcVB(Q1$Ge3>CnFnTTZc;*+rw|f6(v+>l9>A)82p9cTc<>S$(sqUCT3|n4bcyZs7
zf1YXS2cuR!e{_7KcIoZlxTi0W-CItb$6~r>qVXT4Jl6{V

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/palette4.png b/packages/review/test/image/fixtures/palette4.png
new file mode 100644
index 0000000000000000000000000000000000000000..39d9f37a442ba8ce93cc4c9a74541cb217fb75d9
GIT binary patch
literal 309
zcmeAS@N?(olHy`uVBq!ia0vp^;vhB)GmzZi`63TU83g!*xZbqr^1i)`n^*eQ(V%_1
zUAFIS@(2#_^|7!IoM-s&@>_PQv}n;yy^_cajEjxD#GWwzjC?JoIEm@D(Gm-R1*z6Y3#KtJKMGmC-0>9W*c=DY0Y(+`Ac>eFt&7_a9O}&`vhi7RtC?d%QcVE
zn9?WSo%?HnV4x!FTnA>W{)R{2+UK`1>g+IMx#X(Ev2#ybv{lB=-wfPVOT-c?&B}in
zE_%RdZmutU*z)3ErnzN_Oe@!UtqQuiS4#04v%@zLf
zWmqCvUxYa+S@NdR=Frtqnu0z9A$F
zJsDAzj+rcI;g13yl60Le_esO;!0BXgkBWXooXQt+FmK_!?6UX`w#{o%vHs6cPT5}Z
zYh{15Fo%t@KK78nZ=v4+nu10^SCuf{@892GGf&c3Ormk##*R!gU13u5V&V8Q2xj9G
zG~REVbXc*zHT5A-olb!>9vNkSX)A((EQb1BfB>27a=v`iI&uU$s5j^P3v+9;5}?M?
z$a0_6CE?%mj9bvqY#DCseLN_<+yn-M`A}l;Lz&)aNy!113&Y~3cB~>n7$}k!mz+~O
zE+Ux>yj#lDK0NK2`QyYk-gLrdr1incjyM(NhG$OU$Bns8SJ_O9c|Hi$DG}qF;UF|O
z&zcFqx86>CD=_`8b-X&F
zm7TwS(~NuG?t^GWK?f*+`Cgyk1&YEl+IptTazaa;TG
z;Ki{{KkXV$jxM7lxE2oss-)^i
zr!fSnP6r<#D*`gTB>A<^-0cPgVty*Kil;#NDA^8z5@Yof9OF6cQSSNxyK)JCkb*R+
z4JZy+OcXaff5%L5iD-xc6Zf%I9Da*?XG+FkIF1js6KTd&#?0md_zJ;8+!8YIqvkm4
ieD~943Y&1vN*|*j#Df+^*9LH%UPd#iZv@xWn$PTB66EQMMj%ssPBa+mva6(BQ5|W*(II|O_k`&sg
ztxMumlcX%Aho#ebNOCKYr^u(?->v_CcfarV^ZC5r@7L#-ams$SN2_?zyic9FijK@I
zf9DYA;sfGOB{OfxB#zVFX0Ai!!Xk45w?!-Ax<0wAo;a
z3?KK>)^O6ASwjf?5DP(e!vvZ>FQTkC&ST;W^&o&MZTU^Ts}8&l5?kCBdUrl0*m
zHMb|s`EmP>aB`?pQOmum-vjwaNNxW$Z>}TIBuZRG8UJR)JVUa&1$aLBny1x!&BJKI
zL_qO^+5yanp6+^GtVe!)>GEgN4YbQ*-H5m3(p=X{^&@LfQ`XyI((mR^QRMu&pOG$$
z^e0$NoH26J5Y7ab$vRGiL}~Lo1xKIe-KUqQk_o;PmF%Ro{KFI#pr)J^yG4D}M;rQPLhLk;*04bZ2lo7!4gP)x=8N=iFqmpkGmcMIJeOx}|lxVVFv
z7Aj+z=eQ%E?ju!;)OWjhH6c&TSwB1An&IQI%SYBlm<4{W$vH&us?5}po~%<5`oLrK
zK@Vt~4%|15bI2oE^__7bMNMgq!hX@qA8C3XaCV5g89=7w*)njOv1>^a|-kW;{iP}zSbk=-%Dw^v}z
z!YopMZ{`-KGkJC8!G)Y`+`;bPe=q^>z%Y0eSPN&m3>8<<1KhxS@Q=OFMW&y996l0tOkF!vFvP

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/progressive.jpg b/packages/review/test/image/fixtures/progressive.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..1b01b79ccb7e1534e17e9744de130b3737d21953
GIT binary patch
literal 1213
zcmex=^lOiET&UP@Y7ModgWM?qOlT~kX_QeM|USHnP6LsJ7}2qQZ?I~NC+Fc+7w
zhLo6;2Fc+6AqGJX1`P%WW=16jCP7AKLB{__7$g}Om>EH?MF2K-R%RBE6e9yO5F!Ic
z7A9ua|F;-;m>C(E1egVYo(#V(qd(KWdwai4YI~HtRp(AehqPy}6!+SG@RlmBof%xY
zt8=NA-#rJ>3k+-@RFBIr15H*CU;vqfWH>V;6ALSYD4T$Su!srJ0uG>)7+IOtw|@KV
zJuAl4|Cqi^{m%IloC`h0X7I?oN?dVObal8vVQ~S^9cKP4A?98~k-bH1$~iWHr*bMS
zR`q&&YHFp=*tRLTFSYZjxjoQM89}gbkp08R#4KniAZ!G-4;TvejJuacIh}Nknv@yN
z-fO=3s#^SF@tUpsIx`^pnV5iz5W&I7#K0`51T+Awp9!RY-PY8TZBvdb?}$5EaHl(4
zAXvs^F7!%Fj#_Ip)bI|#K6MD#>omZ2W*Ovp`(DXf}+tx7A0k4r$rkNJ`5^q
zZ2AZd2sV4By1mmbYaOqD^0V(h!^zKlzh2$qe0X#Iuir1{f0Os%)QLIHD7!!7=lcas
zcg0sdJ{)_DBhksIbJl@luM%a1D>)v_KH+kN+i1;_MB)1e4A1{u4tyNozkJ(CVX2#W
znm;diOEJzdUb%|*^wtEeR|Y#0#ddPd_j&7uZ2dM%t^ame!t)R5yRUj~Ef5jXp4sqL@cvdd&2>eu9(?zOSa=y!9(Tu_Bx-fYM-T9V5IbUu11*k63q|WLXEYq{Sy`~RC=tmQ{wA}Q(Pej
zM2@xixE}u&r@*3N?EFfb{iQPVd24&1t>9D$PDBFLhZ>z!7x5T82D(~O5SXA*(ljH3
zprU{w*qxwg7AToBt?j;(aN13VH^M$~e;Jz?TY>td1c8Yfn6ME5s1NAdjbM$S_!Y2{
zZt;B0r}n^|V`pI7Ml;LF88Kq5{y@FT0>G5P#K_Eyh*v>HLk0oIK!wCYgNYjtet?IP
zz`q^rE%T=b@b8A!{!@S?I7i=2NI9r-%3Z#7|#yI*Swx5fMH_J@!W^z6Jgb``|+q{sV3hlpz#F
z>qBK?ZkGKddZ~v}o1M8oPVB6|T6J~D@66M1?)ltv?sv|G*49o!2s%dyl3fK907yOJ
z$udwuBLTV_1FVjWE)h}(gQK%5$PNyVt00@yv7>}13i+`@J|{fC4nVRqP(fB@nsx&)
zA=TC@(P8Bi0NL8W1dyCT<*flg$8a^(MrxyN!@&T`BH?lrhgx`i8$b$K<5VCo-s(Mj
zrISle8U6b9$JCud@%f@qV!r@uoK)ia?SY=YjwsY5DeZV~dwX}w-Jyv$lSS#TGH8nI
zi0Eg$8>5%I&L0hzBWIm+hz{eD5t82=9q77n%o>*-5Qpa6)$X4DjyTlSm09Qc;@WVZ
z3Owgs($0bkcBA~GF;#2si#Mm~=
zxyRp6e_8sq0u)jLxMXur7w-*C#F1uV!*~GT`C{hryYZ2{wXQOjQvS(O@tMgY0Wvt!
zpKWdcQhsz~UOk*2AGsfwG~=sVhsuSMPDb0N=7!UkPKaa{eV+YZeD)C$>89503s5$d
vbtblD=MZF!F~O<^cKT^r5oJ{RwGQ$hbRh+*S9`M~00000NkvXXu0mjf6KOaS

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/rgb16.png b/packages/review/test/image/fixtures/rgb16.png
new file mode 100644
index 0000000000000000000000000000000000000000..0401651ca4bba238101b247fd485029c315db93b
GIT binary patch
literal 1887
zcmV-l2cYIEW>~gil`EOzf0@!umiLkJdHY&urvxYO@`sj2VM
z#+bDgpz{L|tH<|+s^xSC3V?rF>o*rXN~%eWi<-pLi}E0VhAjcMvPU-&cX|>Z%*nr>3O?ng0?bdwzVQzc21?GvLAi_>|k4`AWAp(3z9e`?0?NHncKBe~TY|9>j%o(~;Ja_NwS=H>N
zk(0SS)yMo1@9RuhDhsx5olK#~T;gST-*0Bul=sQso0lHuBHTk`2x?0H`?-ti4lJ`p
zuESe3Dpv|#AraGm9CHOY=_UGUb47==eX!w;o63Xp8+zR|IyW%J6ZJVg61eO&Cj(Bx
zQwF_d16x&_;PIx$+`Wn$9s^l4N#}S|$A)n3JkvYzjh1|WBpqUjR8s#bm&3K-vR?}5
zOf!NmH}=DyF2xu#m`c#hDwj-T!Mt%b#lk$9@V1eC09V$BFmgT&)Didwom8?Tn*Na4
z|2w?^O0cCmT55P>^(~SkfGcgv@T(3!?MZn#2`NZpJUgRng~3
zJ_4|MYJy|I0eeMdmpghaauGTiSV+;UHcq=rfIQL+#82XRD5zah7}YG~kAn7c`OFIYZK4(qI1{T3#q*uylrINK^d
z{?YDE!~uZ9z!Nm0Isx{FW-=`kcXaal`>&4o`yoxj^pU<`B>g22IlGr1kJ$hJZ+u?
z0!fz?Ldu|1SAm#P{sqjkJxHD?_r`ub;HdTGhzFnS>bN0u_$0Fu%NmCr+UzqJMJQCe
z10@3sDkl|#`4@wu6p=mR&_lACN$Yo|5fQ=>*R5CMcCsnr^GvNCryxY-SNnrnCm5!kOHzh)!m@!
zkt^hF1_YpTTC2nI18zx^h0_cP+T`KhwOpCz`amASaaX=MiwO$=6-GA^RGq2CL9WXK
zpE31{gFY+J-+l0(wVE$E5z7ROX#<~*Vu=^dmB<(|kQ9;!0IWz@D~|+WZ+j~p;JJqH
z0$zu2Qd+%fN#}C}R@UTJHnA6yc8weCj09y-c1e)>A7H>-V=ukfxU6f#*Nejg45^Ex
zz~U&jC7@U^;a`L>RkCF&9uf3Rv2-X*h|4F!hQuB|ko0ItO~{P|r&HYFQ*`VVXWbzv-!SFuj4
zhd~e2*%rxKI3JaJJ<}^JZ)|tOe6t_WebOaotg$jS6&r2`0akyOdI00Fe#ToFulc*08Ae768$W*R}cpxalP~QY?~i#
zv=)i?nSpc7<4i~L2#AxN4c6{z=(#w<-Phkmyj^WwOX&1Fg;*NoTy&oQI+xZjLQjm&
z?UuXxSq0gBDYDz%;I7grAXRSyKxJR^Svw7$2ljcw&C>VA7*2XRn>s*HtuSg!bQhr^
zU}8?RL!ro#8ceoiz!J|Vh9)X}a3F0>o%}vAVw!O~?;(4qiaew3AG^2C%hy=tdlCrq
zMfzo8bkZilJEXw1`p#PpXSWKLI&#iP)@#iAg?+qDUMJsWH1Wnz$a#&ZC~FgwL?vSY
Z`bzzNa;y@G6}|uf002ovPDHLkV1hbVi2(or

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/rgb16.png.rgba b/packages/review/test/image/fixtures/rgb16.png.rgba
new file mode 100644
index 0000000000000000000000000000000000000000..6d46dd84df329ff64bbb565e2336bc963b5c2360
GIT binary patch
literal 1196
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

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/rgb8.png b/packages/review/test/image/fixtures/rgb8.png
new file mode 100644
index 0000000000000000000000000000000000000000..c8f9b62e19c63336fd5c3679214d771f34287589
GIT binary patch
literal 990
zcmV<410np0P)`l#0005ZNkl^2gQIsX$zA19y?Ak06N9uK4j6>@95YIy0%e%tHSwjsgU3I=_QF=-dPxTF
zV-iqsC%;GJF~tie=dGo8^gFvrsR3~vTy`ylWj^+X_IW!DNS@2?P~si#z?fwwGNyIW
zkJWuz9>AQeO&g<(iNf~lOl3a+QcHcfOtl%2d5hQ2850Pz)0t!f=uGN+Q-`^k`bslg
zgL0Q#d~Zon-FC#-1)-M0Qs=r)!^b|Q90oQ}Iq~i0HMVzsGR
zyYQ`|peMxTZgaYf0p11M7B{cWq;Zm(qB&)9iP`ClP^I9j#kA-A-2hD0k2u2T>XWpU
zw8+Gy1OLL~nO>Vp-@$e*sil0TXxar07=!+qpWoca?Y9)O=LI5lGSR=yPGHyh&Ec;v
zSGl6QCZAaWIF_%Uy5*A)Kl8q0SNEJz?92U{E--9FGE(m(@MlW(!?plq0005aNkloKK`Cd+aYa;nspnAv|=Kl2a|vM5^}n7a;KuDJk*B~(a-~GdMzyVj0i8sQGu`9PN1p9_0AT)3Br~EStC+%1Tb#dN|u?2pG
zsXXqA9oxnB0qd^1Vt(#E^b)K@O$0aRjg}5-w1R6U72gr~P9`&*i4L6?8D&AD!1WF}
zNc1L$h1HAso%8iPru=l1A6#3r7kFC5G=dCS)UdWNmEuLAij+G5?9XXBlm@u@X%-%j
zn`Ahi({*8%Tjm(?*E|30FFFy*;iW8)6Q5
zLHn?(;!e-pZ@Cj*Z{4}b$HLdk+S#fCqeT<37d@O^7obQvybU@Sr@dcTG;g*eLn>RM
zH#0^Yf+pV?btP9P6D7NcsS=>?Q5bEEI1&~v#z-_J2kf>(PK#W7Joab6ueSWE!vFvP
M07*qoM6N<$f{L@Y~+j$+a$9W|9ods!cf|9-z|O>WpFonW`^bwZy6aA}X6)svFYSQ%md^E;+E
z`fyxtBiXud`M02kqy`Ua-_{|~De*SwXM>49+rO^G>+wA5$IhhpbOE~uzN5;!2)Shl
zy*`gvmVBGmy?%^O$)b3bijU66dG=1ka?33Yq`Q$MyU0V8lIVF2Wd0q9n_2o
zdj#oW7w8n;Vq0nm;*gyLN6f-z=pez7V#1Hf5KOVnu|N`
zEn*Z3UtTn=WOPFZCXHWEd0Iv9pAS#bnF;g9k%h5A*_-C?N+D53=d^D!52u2=5FSiK?Lt`R>he`I{NUOUK
z{_~A+oEgN&{s&AoD8M`VJgJfiBs)x_H2fdx$5^nv(F~o1lK35F1g!oY{b#w{P8`BI
zwgGut9yi{PCDvv&K^1j`>1WbdDWbT(nC9so+$oFW>iT{TpN*z#;;)P=@gwGYdm>U6
z(cP~kpjyM$mOt5?{f147UKXo#RPSwO%R+bTQtC1CQDfL(!z%noO|+Low{
zh1@t)PV>vfq^>chs;rH1FXGW^90)xkB4xXf7U2pWY|O>4PKbJ~g5iCgl#XyE-PD{o
zmr0!X@hmp&*O8~$QF^zVl3oiU?=NCdYKAH}kUgq+tRxkjYqdwF7PI8TJECLs>G3Qh
z$8#w$rxtKm;>U}YNJN^{
z`HZTtBRwIGSz;r`HTsZM9frgykCS!lC_k~2>Lr&*R9a#>znRas4si#6a%L|@5h$ix
zYl~3#AM3Z(Fml3OybsS{O2=uYJsQQDm6yrV>!vi~HFFC3xbLTBwp9_?9o4)Q=WxbI
ziu}nOd~4dd5Tm0}B|;G*!&RQhTxkM>iswYR&gSM0T1#4@&8aap
j2*3KHQZxlnqPzstEb}`

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/rgba16.png b/packages/review/test/image/fixtures/rgba16.png
new file mode 100644
index 0000000000000000000000000000000000000000..e2f68446311bece6bd0d6b7b5fdcf2cec37c46d7
GIT binary patch
literal 2485
zcmV;m2}<^fP)8Md
z)1MKIpLxm6yxgU58`Z&`Q+rbh<%HLm$j*SxBE1+lal
z&`tWDtH5HAt|(l`b$p}@``o!N|FS$Y)=qO42B74^i{k4T4P53h;^attRtvu38#6|EdrjOryw*_0b~AQKDgWl>lCE472g+?Hbo9WN#q08
z2-j&RMTZOUS&t(x*UJFuIl&gT5ukhb5rir&kAkbUUovGl^1_-RZ?%4XQ$eN
zjdjT|lKJHP1Ua@Q0G1myuf+gDXHl%-k-{`cc0Q}YjvvYB5~S9WmXr8ZGN1Gh^&*}+
z3Pqd8xC_3xO#;yRpqCc3)T|D;nIh~u1i>aq1A4Rs6zxP}V3fT!*s&q4>{r&a=Tt&8
z^b+WRLQ+lI>0Kk%0)U6kyCcvv=H4n9!`#Sn1c{MIA$DMDo#tA*4KE+HYeE&3YMXS6
zP!OsNIxOBcV*E{^oW^E$ohMXp&XYf&vVu+q9VwW*|&dG
z@&`B3JXu5|G=-hShKWzuvpuC*5U9iGdlI?mBy4eyli8O9QWOK^#IHsnUJ$|iMhb;#
zHNa^cr4-;Vdsqr?oYCqu>J`mmEAPRUY@!h6
zD#mX7ABGy}4+$%GxJ!H9($^bMVp;osf$@UAJvy(ajE`S~@W@Jwt~RIL2ez5`OQtrT
z2T{7dIEGGuWX@Vre9x2w>_gVZ&|*V)8f-vyQ02LXOIJtYS%*o1XWFbD&tquRH#1wl
zcsGO06*hO5iv#PB
z(bYc{GWKxLpKOKVe60|8O#3X$`pH$I0FT>Vi5Qz2
zuBLW|YPh>$RKQ4Nfersr;2Sa}K;cbnd1Y_xd
zsy3bz95$4jG>IIdPFM)%n3MY;o4)=gfT9((Buw~~9~NxcK>q|azC?-`nxpKi%OQ-6
zVNcC6Vp1f|5Yen0ge>Z`DG|5m8KIc|Ma!L<9M8b8m9TECAl=2y(rbz;ezm4xEA)ME
z^^t5i9(=PqDftUv1A3NmL-%}k4gs4ks2YzJ$*)i9zwBI%F)>7@V&Qpapmod0QN8?-
zF@Of>Y~`I~wV{$5ldgivd@?9`4s52UK!tg>$@at?mVW|ZwC;e+TV1k-kbxK_xJEP^
z)G_o_dtl41L8w2K000EINklFSdh
zw#{jJl#peun5YfXLG%~Ift!*upKCZ)>+J@9ZqZO+H*eTX48~H8C#euuVt?o<#gD%u
znx5kV3Ezk$?*amK;@YCwaDGW_m^V9sqq>nt&tg1!A)BVceRq*GTz-Do`~<=qOBW@T
z<|`4L$(61FO|%ym3uvic%|^-B6KMjmP-k3X6)e$xi2Sxndp$UU=Po2HPh*AeIvez@
z56kExbZxDbqkuVW^|&L}yrYppuUiQFMb{AkbZs7hgs<3$kWUIdx4VP>xS21ff6JT_
zNGTs3FV$o;K^GVz1Zvk>7<=-|CnM5GC)Be+wdHtK>mnvLQ8JqY;okVTAX_9D^f-qV
zdVH-@I&4EdXPk^~YY(LNehJ3ibA7{TDr%*u;c27*)GGOS(H4k1k6Pyfdb)j3xOrb%
z+aj?i9idllSM;A=`K~6>&J1Tyr
z%uuj9pkk6%;{H;iC2<6Y)+8Ad=q*uv0`!d1Yca)oXGY
zur9eaIQnCIoL-i@IlK?r%J3>GHoI=17r?S>3BkJF+?dei;kb9-
zt6PX=we*>#{?6Rr5$_b<-)y8p%^nlH`w~waW9YXK-UzhPQAmD3Q*@d&v&JmgW#)Bm
z=zK&;XJ3_gPb!q@{!)21I_hvsVe8sb?5YJ0?D<~w&^t3qt*qd?qD2pF5CF2R7+ZFs
z#(VG}5@NcOJt1A3PtPHXhzxh=Fr#CB(Whrmqgo!qp1$7Ai4rOtW)o1Ica
z%x15-?(gUkI1;Fi^Fkylm_GagU#!^VZhjOdPSl5;Z=-NZ?R
z)?@y0s5fQ7;bmn_ma1^u`wK`*kF&=NpZ_waOB%rMGNwUxxY5b(fU0@lZmLTTrImv{$6&kJZz7Txu
zvWxW@i`Ow6E!(qMaRkov2RsPVi^K2B=T3C>%}m*&2XmP;apJwYPp)~~Q?k7qEJgDI
z-r^F)Sl!6|uNG>T`}YQ_;dcqTOc7C&&u#z}$~sf>K5qg%I_~KJ2C>@_rAAxAO0Fd~
z9jJzzvMfZS3Bpmc4*@ndqt?(c6r(FYdpw_!d*l+qJ}ccUz*lK+H!*_Z#9S2@C%OKW
z`LtUKo7e^CqKJcHJ*jLz%*e`X5LjL6S1TWpk;_o7`JIE)a#=%#HG%FbuSwxG1j>U;
z&#qMQa)5B{-c0>0BLHN_l#lG`Jc}^7ZD?Q^P%x{5@f78d00000NkvXXu0mjfh&{i~

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/rgba16.png.rgba b/packages/review/test/image/fixtures/rgba16.png.rgba
new file mode 100644
index 0000000000000000000000000000000000000000..ef361fac085ffdd688cfb9b2da06cc8787170f23
GIT binary patch
literal 1196
zcmV;d1XKHuSoFE~<@|gCpJ^4%t%lc2w$@6wW%#SptrBcQ>mM#$!bRS>9)ommc%}w5
z`PZriw9xviVy;|ur2D!5Jl1mt`#b2*{UkNERTbZ2i}br%Mu?%Ts0n#3o~JZqei`@I
zZ(Kg(eAYrRl0+f#3{B-}f?=EPWf`FY8Td9wTHFjCfn)rwur!RWARc)hw{cAmZ~@`a<4}!A+Jl9xuz~2uMN+e~MOg@&Wl0
z_#@1#c1hhkPCTQD)W{rh9i$MiqQjX7y;JwYijv95@lUUHFiq19h{Z0IY3rg6BYvz)
zS#ZMn}D<<~r8PjCP1
zy87zd`FUD;6~m{QW4-(}k97i`3MO*J`NzlPeYw_?yRS~tXdhiDnMK3@aDYGLUIDg`
zUKkpthPYwCWDqCR*aX(lw0Y541X0sN^=+SHfHo60n~9=W=aV45CZe@Wl@{6mHAEPr
zt09a}GE&ab94xdE=%N10n$NJXtliCPDz#wraFIBCJNaOGarbtcsE^6&XY2Yd$z+=O
z8eq%+hW&t%8Grn_CnY>G@afVtq-ibYG=b$B2z6lPQ7%6_Z5$@5Q^Rl$GeudU>8|Yt
zfx9jp0!__T?Nd9YBr8YvOFOCG5c)&H8GrO1
z+dQO@Ho!aO3(Hw!Fv}vGHlf4RY}*Q5+Oeh=3z-v7jzBP>RxX^5(D>;aoAj7x7H|;R
zJ58}s#sR>^?Et
zqSx)zO;H(rOo*T?y@cwhhSft|ZrDm!T9gJD_{uBts0vZHMK?>qu=puv%(VHzj;Nk8
zfj0%v!_VX5GAs$Knqx^I&na^TblYwlO$C!}gYV8*)m)La;Cb7IT^w4n7a^nEYSYiOlud(;=@~gW&q&;}3KU&+DE6
zoXy}H6i!Svio@=d;aA})2G}-o1&{%{`>-=KFN3urO?b$XeCbXyh
z@n0(M4+UD!)9gd++o?B6dh7t;5Cfd{Be)9|^
zgN4u;HxLLf6n}aI@KFm>q-WE;Hp)k&oB_T8ET6O>qq+ZZ(6LFnm8kI~(f){~GAy&}
z!Ln|+Y;t!O+&vmX@SE}_wm4_>#Zeve&ZZscIJYLB3BDNK4KIKVdZC4%VN1+|pl<}m
z09D-#LSny?iJ;#`*-HG3XFiYHmW>rAYfq*fC{Oulz+RkV7SDsDc{Chc$)=~&11D%zLt76Hq=dWA7#!o{)Z
zyq+D7<%ULao_`@bvL!<97p-63ba?~cr<#dPgI$?>+ydqe_`(1H0%A!-K~y-Y`@T4s
zkpcL0VT*=q?}OtqJkF}|2qZ@ZD1G^tjpw(GoE1$j|KMLecMylwJ{|&XeT%5*e;^Yj
zS2JZ+KsF-Z1QRxoM#U7`C$N(~i>`)o&Nx#_?(Yo7?oy_ce7$kp
zIhvR`705CJy(UZGA?JG?Cqyt({#xOacu$*&@1YF?-Azzn05_^2sCXlioG89uXs?NP
z{B|;`x;vlZPhBjO10skMO>UqwNs0bZO>Q|y=`xdbCf`~pi!U?eDRUi^8jyylOemqm
z-Qv6;(6Yhcmqh4OH6N;C`JcqD1Rc_p&T`mZU6R7om=+Pe7zQ{|AusA`>SalA^}6-P
zr%94;njUQ9{6FZB2wwv*I4G=M@+9ZBI~JhPLqHM*k#D&~c4!KKR-Lz!bQg>(
z28yI;Uj=bvbO6XqYai?C0|XY^J%rMrlC^I8mhF9?zM@f;z&N8x^3>q7x$V@*xLUnO
z0~}_eu3Gc_L(+48q64Rm3~QE$f6qz)3UwMOk=RNnbWmJ>dnFVpY+zXvERs$lQAYtR
z!y@Y0BggE*@e#=XODW0ZAPn~fjM=JbkK+btJy#!AHi$_9q_`D+HQQo4B7kM
zpR6rwE6*i#;C=|obg3qzK_a>l_omH<{EvkUFBVg}y*SlPT2G*tkyn2wc-gTak5_GP
z

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/rgba8.png.rgba b/packages/review/test/image/fixtures/rgba8.png.rgba
new file mode 100644
index 0000000000000000000000000000000000000000..97372b093a8f84360b2a43c875438e45e16e0e8f
GIT binary patch
literal 1196
zcmV;d1XKIwPlGx}es1PfHK6IG!CYTXkIn}7Fd-_~QgStPco?NDCdRcg5kNBX+H@Cme#5bYcJ$pNIbWh@Evw|`PB$Pd56G!_!X=Lt&|usy6i)bW%{4_qxI&iu4|coHi>k_)Mf2
z+xBW*CGkO?l{)VHLBtEQ!xV!gy;1smkl;E|h?a7K1=78_Cy4sq<|7-oB}yOugmNFQ
z3$2Z3H8Qu9CO++ax{VrrBW=gop1r!KYy>9!CSbbq2V-gTdgCe_GXm!
z9vUC*rzcsr#=9eH;p>g{NNGwd&)pUt&_)+Zh1WOj-qxqij~`(}vtzLovASHD-EE>h
zkNeW8m2&B|+t(|XIrdAkUR|ztcYR&$Nq{OP{M+F!y7Rv~B`wS#zn{K=3Tr_jdB5pZ
z-3&rvzmkcd-$vO={ETNlkK2}w6((y>rX46x`DnmioMRYvZ$=O*{K`jz|fGeIpQz(w7xMfUAgaP^-AzxX{&qX)SCXt2G-1S
z_ulZi{042Zlao|wATMdQPA*9mi=|ndVB>c&(
zR`gN@pt}=7W+f3EQe%e$KWw+4EAc|H<2n2#SN$P)P*-oHKX7EL45lKyxh%Mnw0h5m
zM{}77Oo%F|1N8JIPlSZ|X;OCXx&euZ1Udcil;kB>{z99FILrzpf`1TW?#ZQ9%>QnM
z{ZXxrCtL&Lm)~i#?;j_Ug6K>Tn9@Om-oECs1vo4MhtoDEe`2C06)SzNbU~)zT0}2<
z^C$-vJLW`)L9n43#)Org*P%UKHj})zAU5=g%cki=CCN|*R{%=B!H`E8
z+dYKRppvz2`>pKj@LKr3!C#^+W3FR?ifUC*0JL`p+pE{B5o
zo6Rkl90sB#kk%?EJFjX=T5#4$vs}a404UZU3p+z!I+>+<4>)3D&lE59z8G96CJo6s
zd$)lM?}Oi&!5qmh*F!eUkIMza9cFxX2x3{Dv^)wfGP5G(4iR9dqf7-V2Fli3vTlK2
Ks^Qk}KX)cypFJ1=

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/still.gif b/packages/review/test/image/fixtures/still.gif
new file mode 100644
index 0000000000000000000000000000000000000000..df2201b5f9ae59038d30f9b786bf2a737950f3a6
GIT binary patch
literal 244
zcmV87>Y6em%>ef>rSY*g@4!bJ@aM(k5DLdHKG
uKZY!MGULhzAp^yH6)|Vac0VsxEBbKR(x#`PW*B(2>({Vj%btxeAOJfEQ)6QQ

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/fixtures/still.gif.rgba b/packages/review/test/image/fixtures/still.gif.rgba
new file mode 100644
index 00000000..c9e7b0de
--- /dev/null
+++ b/packages/review/test/image/fixtures/still.gif.rgba
@@ -0,0 +1 @@
+.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ‰´úÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ¦ã¡ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ.ÿ
\ No newline at end of file
diff --git a/packages/review/test/image/fixtures/topdown.bmp b/packages/review/test/image/fixtures/topdown.bmp
new file mode 100644
index 0000000000000000000000000000000000000000..a6988771e16e3944097b67ca87e0b8fb9c93acc7
GIT binary patch
literal 7734
zcmeH_F^N
zurVVhB2U8*x|%#Z*V$!b1a3;6fWdP$d3dg~%f<-Ylso~0=V}bR-`jWJ8?szBMgX3&
z>3tIb4)VgV#$SI`9OKILb*R;@SzNIzez~G<-
zK_8gc5;RN+>G6^Y?Wl
I*g(C3KSk+#mjD0&

literal 0
HcmV?d00001

diff --git a/packages/review/test/image/pixels.test.ts b/packages/review/test/image/pixels.test.ts
new file mode 100644
index 00000000..9f6984e6
--- /dev/null
+++ b/packages/review/test/image/pixels.test.ts
@@ -0,0 +1,227 @@
+import { describe, expect, test } from "bun:test"
+import { readFileSync } from "node:fs"
+import { join } from "node:path"
+import { percentText, pixelText, sizeText, summaryText } from "../../src/core/image/describe.ts"
+import { decodeGif } from "../../src/core/image/gif.ts"
+import { analyse, createLooks, MAX_PIXELS } from "../../src/core/image/looks.ts"
+import { comparePixels, FADE, mix, shrink, toCells } from "../../src/core/image/pixels.ts"
+import { decodePng } from "../../src/core/image/png.ts"
+import { finish } from "../../src/core/image/steps.ts"
+import type { BinaryChange, FileChange } from "../../src/core/model/review.ts"
+
+const bytes = (name: string) => new Uint8Array(readFileSync(join(import.meta.dir, "fixtures", name)))
+
+/** `after.png` is `before.png` with one rectangle repainted: x 40–49, y 4–9 (fixtures generator). */
+const RECTANGLE = { x: 40, y: 4, width: 10, height: 6 }
+
+describe("pixel diff", () => {
+  test("finds exactly the rectangle that changed", () => {
+    const diff = finish(comparePixels(decodePng(bytes("before.png")), decodePng(bytes("after.png")), 32, 20))
+    expect(diff.total).toBe(64 * 40)
+    expect(diff.changed).toBe(60)
+    expect(diff.box).toEqual(RECTANGLE)
+    expect(pixelText(diff)).toBe("2.34% of pixels changed · 10×6 at 40,4")
+  })
+
+  test("marks the change on the mask, and only there", () => {
+    const diff = finish(comparePixels(decodePng(bytes("before.png")), decodePng(bytes("after.png")), 32, 20))
+    const marked: number[] = []
+    diff.mask?.data.forEach((on, index) => {
+      if (on) marked.push(index)
+    })
+    /** Mask cells are 2×2 pixels: x 20–24, y 2–4. */
+    expect(marked.length).toBe(15)
+    for (const index of marked) {
+      expect(index % 32).toBeGreaterThanOrEqual(20)
+      expect(index % 32).toBeLessThanOrEqual(24)
+      expect(Math.floor(index / 32)).toBeGreaterThanOrEqual(2)
+      expect(Math.floor(index / 32)).toBeLessThanOrEqual(4)
+    }
+  })
+
+  test("works on GIF too", () => {
+    const diff = finish(comparePixels(decodeGif(bytes("before.gif")), decodeGif(bytes("after.gif")), 32, 20))
+    expect(diff.box).toEqual(RECTANGLE)
+  })
+
+  test("identical pixels: nothing changed, no box", () => {
+    const pixels = decodePng(bytes("before.png"))
+    const diff = finish(comparePixels(pixels, pixels, 8, 8))
+    expect(diff).toEqual({ changed: 0, total: 2560 })
+    expect(pixelText(diff)).toBe("Same pixels — only the encoding or metadata changed")
+  })
+
+  test("refuses two sizes rather than report a percentage that lies", () => {
+    expect(() =>
+      finish(comparePixels(decodePng(bytes("before.png")), decodePng(bytes("resized.png")), 8, 8)),
+    ).toThrow()
+  })
+})
+
+describe("cells", () => {
+  test("a picture fits its cells, two pixels to a cell, aspect kept", () => {
+    const thumb = finish(shrink(decodePng(bytes("before.png"))))
+    expect([thumb.width, thumb.height]).toEqual([64, 40])
+    const cells = toCells(thumb, 32, 8, 0x1e1e2e)
+    expect([cells.cols, cells.rows]).toEqual([26, 8])
+    expect(cells.top.length).toBe(26 * 8)
+  })
+
+  test("transparent pixels show the pane's colour", () => {
+    const thumb = finish(shrink(decodePng(bytes("rgba8.png"))))
+    const clear = { width: 1, height: 2, data: new Uint8Array([255, 0, 0, 0, 0, 255, 0, 0]) }
+    expect(toCells(clear, 1, 1, 0x123456).top[0]).toBe(0x123456)
+    expect(thumb.width).toBe(23)
+  })
+
+  test("with a mask, what did not change fades toward the pane", () => {
+    const before = decodePng(bytes("before.png"))
+    const after = decodePng(bytes("after.png"))
+    const thumb = finish(shrink(after))
+    const diff = finish(comparePixels(before, after, thumb.width, thumb.height))
+    const plain = toCells(thumb, 64, 20, 0)
+    const lit = toCells(thumb, 64, 20, 0, diff.mask)
+    /** Top-left is untouched: faded. Inside the rectangle: as it was. */
+    expect(lit.top[0]).toBe(mix(plain.top[0] as number, 0, FADE))
+    const inside = 3 * 64 + 44
+    expect(lit.top[inside]).toBe(plain.top[inside] as number)
+  })
+})
+
+describe("in words", () => {
+  const png = (width: number, height: number, size: number) => ({
+    size,
+    image: { format: "png" as const, width, height },
+  })
+  const cases: [BinaryChange, string][] = [
+    [
+      { before: png(2880, 1800, 826_548), after: png(2880, 1800, 807_358) },
+      "PNG 2880×1800 · 807 KB → 788 KB",
+    ],
+    [
+      { before: png(2880, 1800, 826_548), after: png(1440, 900, 220_000) },
+      "PNG 2880×1800 → 1440×900 · 807 KB → 215 KB",
+    ],
+    [{ after: png(96, 96, 12_904) }, "PNG 96×96 · 12.6 KB"],
+    [{ before: png(96, 96, 900) }, "PNG 96×96 · 900 B"],
+    [
+      {
+        before: png(640, 400, 91_165),
+        after: { size: 40_000, image: { format: "jpeg", width: 640, height: 400 } },
+      },
+      "PNG 640×400 → JPEG 640×400 · 89.0 KB → 39.1 KB",
+    ],
+    [{ before: { size: 12_595 }, after: { size: 14_336 } }, "binary · 12.3 KB → 14.0 KB"],
+    [{ after: { size: 13_849_151 } }, "binary · 13.2 MB"],
+  ]
+  test.each(cases)("%#: %s", (binary, said) => expect(summaryText(binary)).toBe(said))
+
+  test("sizes", () => {
+    expect([0, 1023, 1024, 102_400, 1_048_576 * 13.5].map(sizeText)).toEqual([
+      "0 B",
+      "1023 B",
+      "1.0 KB",
+      "100 KB",
+      "13.5 MB",
+    ])
+  })
+
+  test("percentages never round to a lie", () => {
+    expect(percentText(1, 5_184_000)).toBe("<0.01%")
+    expect(percentText(5_183_999, 5_184_000)).toBe(">99.99%")
+    expect(percentText(10, 10)).toBe("100%")
+    expect(percentText(132_710, 5_184_000)).toBe("2.56%")
+    expect(percentText(1_000_000, 5_184_000)).toBe("19.3%")
+  })
+})
+
+describe("looking at an image change", () => {
+  const change = (before?: string, after?: string): FileChange => ({
+    path: "shot.png",
+    before: "",
+    after: "",
+    additions: 0,
+    deletions: 0,
+    binary: {
+      ...(before
+        ? { before: { size: 1, image: { format: "png", width: 64, height: 40 } }, revision: "HEAD" }
+        : {}),
+      ...(after ? { after: { size: 1, image: { format: "png", width: 64, height: 40 } } } : {}),
+    },
+  })
+  const reader = (names: { before?: string; after?: string }) => {
+    const reads: string[] = []
+    return {
+      reads,
+      read: async (_file: FileChange, side: "before" | "after") => {
+        reads.push(side)
+        const name = names[side]
+        return name ? bytes(name) : undefined
+      },
+    }
+  }
+  const now = async () => {}
+
+  test("both sides: thumbs and the pixel diff", async () => {
+    const { read } = reader({ before: "before.png", after: "after.png" })
+    const look = await analyse(change("b", "a"), read, now)
+    expect(look.diff?.box).toEqual(RECTANGLE)
+    expect(look.before?.width).toBe(64)
+    expect(look.after?.width).toBe(64)
+    expect(look.problem).toBeUndefined()
+  })
+
+  test("decoded once: the same bytes again come from the cache", async () => {
+    const { read } = reader({ before: "before.png", after: "after.png" })
+    const first = await analyse(change("b", "a"), read, now)
+    const again = await analyse(change("b", "a"), read, now)
+    expect(again.after).toBe(first.after as NonNullable)
+    expect(again.diff).toBe(first.diff as NonNullable)
+  })
+
+  test("one side: a thumb and no diff", async () => {
+    const { read } = reader({ after: "after.png" })
+    const look = await analyse(change(undefined, "a"), read, now)
+    expect(look.after).toBeDefined()
+    expect(look.before).toBeUndefined()
+    expect(look.diff).toBeUndefined()
+  })
+
+  test("too large to decode: said, and nothing read", async () => {
+    const { read, reads } = reader({ before: "before.png", after: "after.png" })
+    const huge = change("b", "a")
+    const side = { size: 1, image: { format: "png" as const, width: 5000, height: 5000 } }
+    huge.binary = { before: side, after: side, revision: "HEAD" }
+    expect(5000 * 5000).toBeGreaterThan(MAX_PIXELS)
+    const look = await analyse(huge, read, now)
+    expect(look.problem).toContain("Too large to preview")
+    expect(reads).toEqual([])
+  })
+
+  test("a damaged file: said, not thrown", async () => {
+    const look = await analyse(change(undefined, "a"), async () => bytes("before.png").subarray(0, 50), now)
+    expect(look.problem).toContain("could not")
+    expect(look.after).toBeUndefined()
+  })
+
+  test("the pane's looks: pending at once, done later, a paint asked for each time", async () => {
+    let paints = 0
+    const { read } = reader({ before: "before.png", after: "after.png" })
+    const looks = createLooks(read, () => paints++, now)
+    looks.sync([change("b", "a"), { path: "x.ts", before: "a", after: "b", additions: 1, deletions: 1 }])
+    expect(looks.current().get("shot.png")?.pending).toBe(true)
+    expect(looks.current().has("x.ts")).toBe(false)
+    for (let turn = 0; turn < 50 && looks.current().get("shot.png")?.pending; turn++) await Bun.sleep(1)
+    expect(looks.current().get("shot.png")?.diff?.changed).toBe(60)
+    expect(paints).toBeGreaterThanOrEqual(2)
+  })
+
+  test("a later sync wins over one still working", async () => {
+    const { read } = reader({ before: "before.png", after: "after.png" })
+    const looks = createLooks(read, () => {}, now)
+    looks.sync([change("b", "a")])
+    looks.sync([])
+    await Bun.sleep(20)
+    expect(looks.current().size).toBe(0)
+  })
+})
diff --git a/packages/review/test/image/sniff.test.ts b/packages/review/test/image/sniff.test.ts
new file mode 100644
index 00000000..010b9d3b
--- /dev/null
+++ b/packages/review/test/image/sniff.test.ts
@@ -0,0 +1,129 @@
+import { describe, expect, test } from "bun:test"
+import { readFileSync } from "node:fs"
+import { join } from "node:path"
+import {
+  decodable,
+  formatName,
+  looksBinary,
+  readBmp,
+  readGif,
+  readJpeg,
+  readPng,
+  readWebp,
+  sniff,
+} from "../../src/core/image/sniff.ts"
+
+/**
+ * Fixtures are small files written by PIL and by hand (every PNG colour type and depth from a raw
+ * writer, so the reference RGBA is computed independently of any decoder); sizes checked with `sips`.
+ */
+const fixture = (name: string) => new Uint8Array(readFileSync(join(import.meta.dir, "fixtures", name)))
+
+describe("binary, by git's rule", () => {
+  test("a NUL in the first 8000 bytes is binary", () => {
+    expect(looksBinary(fixture("blob.bin"))).toBe(true)
+    expect(looksBinary(fixture("rgb8.png"))).toBe(true)
+  })
+
+  test("text, however odd, is not", () => {
+    expect(looksBinary(new TextEncoder().encode("héllo — ✓ \t\r\n\u001b[31m"))).toBe(false)
+    expect(looksBinary(new Uint8Array(0))).toBe(false)
+  })
+
+  test("a NUL past 8000 bytes does not count, as in git", () => {
+    const late = new Uint8Array(9000).fill(97)
+    late[8500] = 0
+    expect(looksBinary(late)).toBe(false)
+    late[7999] = 0
+    expect(looksBinary(late)).toBe(true)
+  })
+})
+
+describe("PNG", () => {
+  test.each([
+    "grey1.png",
+    "grey16.png",
+    "rgb8.png",
+    "rgb16.png",
+    "palette4.png",
+    "rgba16.png",
+    "rgb8-adam7.png",
+  ])("%s: size from IHDR", (name) => {
+    expect(readPng(fixture(name))).toEqual({ format: "png", width: 23, height: 13 })
+  })
+
+  test("an acTL before the image data is an APNG", () => {
+    const info = readPng(fixture("anim.apng"))
+    expect(info).toEqual({ format: "png", width: 23, height: 13, animated: true })
+    expect(formatName(info as NonNullable)).toBe("APNG")
+  })
+
+  test("a scene", () => expect(readPng(fixture("before.png"))).toMatchObject({ width: 64, height: 40 }))
+  test("not a PNG", () => expect(readPng(fixture("still.gif"))).toBeUndefined())
+})
+
+describe("GIF", () => {
+  test.each(["still.gif", "interlaced.gif", "anim.gif"])("%s: the logical screen", (name) => {
+    expect(readGif(fixture(name))).toEqual({ format: "gif", width: 64, height: 40 })
+  })
+})
+
+describe("WebP", () => {
+  test("lossy (VP8)", () =>
+    expect(readWebp(fixture("lossy.webp"))).toEqual({ format: "webp", width: 64, height: 40 }))
+  test("lossless (VP8L)", () =>
+    expect(readWebp(fixture("lossless.webp"))).toEqual({ format: "webp", width: 64, height: 40 }))
+  test("extended (VP8X), with alpha", () =>
+    expect(readWebp(fixture("alpha.webp"))).toEqual({ format: "webp", width: 64, height: 40 }))
+  test("extended, animated", () =>
+    expect(readWebp(fixture("anim.webp"))).toEqual({ format: "webp", width: 64, height: 40, animated: true }))
+})
+
+describe("JPEG", () => {
+  test("baseline", () =>
+    expect(readJpeg(fixture("baseline.jpg"))).toEqual({ format: "jpeg", width: 64, height: 40 }))
+  test("progressive", () =>
+    expect(readJpeg(fixture("progressive.jpg"))).toEqual({ format: "jpeg", width: 64, height: 40 }))
+  test("behind a 30 KB EXIF block", () => {
+    const bytes = fixture("exif.jpg")
+    expect(bytes.length).toBeGreaterThan(30_000)
+    expect(readJpeg(bytes)).toEqual({ format: "jpeg", width: 64, height: 40 })
+  })
+  test("cut off before its size: nothing, not a guess", () => {
+    expect(readJpeg(fixture("exif.jpg").subarray(0, 4096))).toBeUndefined()
+  })
+})
+
+describe("BMP", () => {
+  test("bottom-up", () =>
+    expect(readBmp(fixture("bottomup.bmp"))).toEqual({ format: "bmp", width: 64, height: 40 }))
+  test("top-down: a negative height is still a height", () =>
+    expect(readBmp(fixture("topdown.bmp"))).toEqual({ format: "bmp", width: 64, height: 40 }))
+  test("the OS/2 core header's 16-bit sizes", () =>
+    expect(readBmp(fixture("core.bmp"))).toEqual({ format: "bmp", width: 7, height: 5 }))
+})
+
+describe("sniff", () => {
+  test("names each format by its bytes, not its extension", () => {
+    const names = ["rgb8.png", "anim.apng", "still.gif", "baseline.jpg", "lossy.webp", "bottomup.bmp"].map(
+      (name) => {
+        const info = sniff(fixture(name))
+        return info ? formatName(info) : "?"
+      },
+    )
+    expect(names).toEqual(["PNG", "APNG", "GIF", "JPEG", "WebP", "BMP"])
+  })
+
+  test("a binary that is not an image is not one", () => {
+    expect(sniff(fixture("blob.bin"))).toBeUndefined()
+    expect(sniff(new Uint8Array(4))).toBeUndefined()
+  })
+
+  test("only PNG and GIF decode; the rest are described", () => {
+    expect(
+      ["rgb8.png", "still.gif", "baseline.jpg", "lossy.webp", "bottomup.bmp"].map((name) =>
+        decodable(sniff(fixture(name))),
+      ),
+    ).toEqual([true, true, false, false, false])
+  })
+})
diff --git a/packages/review/test/image/viewer.test.ts b/packages/review/test/image/viewer.test.ts
new file mode 100644
index 00000000..78efe077
--- /dev/null
+++ b/packages/review/test/image/viewer.test.ts
@@ -0,0 +1,150 @@
+import { afterEach, describe, expect, test } from "bun:test"
+import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, utimesSync } from "node:fs"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+import type { FileChange } from "../../src/core/model/review.ts"
+import { createViewer, type Launched, oldName, openerFor, type ViewerDeps } from "../../src/core/viewer.ts"
+
+/**
+ * `o`, with the system stubbed out: nothing here ever launches a real viewer. The opener is "found"
+ * by a fake `which`, "launched" by a fake `spawn` that records what it was asked to open.
+ */
+
+const roots: string[] = []
+afterEach(() => {
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+const OLD = new Uint8Array([137, 80, 78, 71, 1, 2, 3])
+
+const setup = (overrides: Partial = {}) => {
+  const tmp = mkdtempSync(join(tmpdir(), "ck-viewer-test-"))
+  roots.push(tmp)
+  const launched: { command: string; args: string[]; env: Record }[] = []
+  const listeners: ((error: Error) => void)[] = []
+  const reported: string[] = []
+  const looked: { command: string; PATH: string }[] = []
+  const deps: ViewerDeps = {
+    platform: "darwin",
+    env: { PATH: "/shim:/usr/bin", HOME: "/home/x" },
+    which: (command, options) => {
+      looked.push({ command, PATH: options.PATH })
+      return `/shim/${command}`
+    },
+    spawn: (command, args, env): Launched => {
+      launched.push({ command, args, env })
+      return {
+        on: (_event, listener) => listeners.push(listener),
+        unref: () => {},
+      }
+    },
+    readOld: async () => OLD,
+    tmp,
+    report: (problem) => reported.push(problem),
+    ...overrides,
+  }
+  return { viewer: createViewer(deps), launched, listeners, reported, looked, tmp }
+}
+
+const file = (binary: FileChange["binary"], path = "media/shot.png"): FileChange => ({
+  path,
+  before: "",
+  after: "",
+  additions: 0,
+  deletions: 0,
+  ...(binary ? { binary } : {}),
+})
+const both = file({
+  before: { size: 7 },
+  after: { size: 9 },
+  revision: "0123456789abcdef0123456789abcdef01234567",
+})
+
+describe("o opens both versions", () => {
+  test("the old one from git into a named temp file, the new one where it is", async () => {
+    const { viewer, launched, tmp } = setup()
+    const result = await viewer.open("/repo", both)
+    expect(result).toEqual({ opened: 2 })
+    expect(launched.map((each) => each.command)).toEqual(["/shim/open", "/shim/open"])
+    const [old, now] = launched.map((each) => each.args.at(-1) as string)
+    expect(old?.startsWith(tmp)).toBe(true)
+    expect(old?.endsWith("shot@0123456.png")).toBe(true)
+    expect(new Uint8Array(readFileSync(old as string))).toEqual(OLD)
+    expect(now).toBe("/repo/media/shot.png")
+  })
+
+  test("the opener is found on the PATH the spawn is given, not the parent's", async () => {
+    const { viewer, looked, launched } = setup()
+    await viewer.open("/repo", both)
+    expect(looked[0]).toEqual({ command: "open", PATH: "/shim:/usr/bin" })
+    expect(launched[0]?.env.PATH).toBe("/shim:/usr/bin")
+  })
+
+  test("an added file has only its new side, a deleted one only its old", async () => {
+    const added = setup()
+    await added.viewer.open("/repo", file({ after: { size: 9 } }))
+    expect(added.launched.map((each) => each.args.at(-1))).toEqual(["/repo/media/shot.png"])
+    const deleted = setup()
+    await deleted.viewer.open("/repo", file({ before: { size: 7 }, revision: "HEAD" }))
+    expect(deleted.launched.map((each) => each.args.at(-1)?.endsWith("shot@HEAD.png"))).toEqual([true])
+  })
+
+  test("no opener on this system: said, nothing launched", async () => {
+    const { viewer, launched } = setup({ platform: "linux", which: () => null })
+    const result = await viewer.open("/repo", both)
+    expect(result.opened).toBe(0)
+    expect(result.problem).toContain("xdg-open is not on PATH")
+    expect(launched).toEqual([])
+  })
+
+  test("the old side unreadable: said, nothing launched", async () => {
+    const { viewer, launched } = setup({ readOld: async () => undefined })
+    const result = await viewer.open("/repo", both)
+    expect(result.problem).toContain("Could not read the old shot.png")
+    expect(launched).toEqual([])
+  })
+
+  test("an opener that fails to start is reported, not swallowed", async () => {
+    const { viewer, listeners, reported } = setup()
+    await viewer.open("/repo", both)
+    expect(listeners).toHaveLength(2)
+    listeners[1]?.(new Error("spawn EACCES"))
+    expect(reported).toEqual(["Could not open shot.png: spawn EACCES"])
+  })
+
+  test("closing the pane removes its temp copies", async () => {
+    const { viewer, launched } = setup()
+    await viewer.open("/repo", both)
+    const old = launched[0]?.args.at(-1) as string
+    expect(existsSync(old)).toBe(true)
+    await viewer.clean()
+    expect(existsSync(old)).toBe(false)
+  })
+
+  test("a crashed pane's leftovers are swept on the next open — only old ones", async () => {
+    const { viewer, tmp } = setup()
+    const stale = join(tmp, "cockpit-review-stale")
+    const live = join(tmp, "cockpit-review-live")
+    mkdirSync(stale)
+    mkdirSync(live)
+    const day = (Date.now() - 2 * 24 * 60 * 60 * 1000) / 1000
+    utimesSync(stale, day, day)
+    await viewer.open("/repo", both)
+    const left = readdirSync(tmp)
+    expect(left).not.toContain("cockpit-review-stale")
+    expect(left).toContain("cockpit-review-live")
+  })
+})
+
+describe("the opener for each platform", () => {
+  test("open, xdg-open, start", () => {
+    expect(openerFor("darwin")).toEqual({ command: "open", args: [] })
+    expect(openerFor("linux")).toEqual({ command: "xdg-open", args: [] })
+    expect(openerFor("win32")).toEqual({ command: "cmd", args: ["/c", "start", ""] })
+  })
+
+  test("the temp name keeps the extension and says which side", () => {
+    expect(oldName({ ...both, from: "img/old-name.gif" })).toBe("old-name@0123456.gif")
+    expect(oldName(file({ before: { size: 1 }, revision: "HEAD" }, "Makefile"))).toBe("Makefile@HEAD")
+  })
+})
diff --git a/packages/review/test/panel/palette.test.ts b/packages/review/test/panel/palette.test.ts
new file mode 100644
index 00000000..dc5728aa
--- /dev/null
+++ b/packages/review/test/panel/palette.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, test } from "bun:test"
+import { matchesBinding, paletteBindings } from "../../src/core/palette.ts"
+
+/** The review steps aside for the host's palette: it has to recognise the palette's key exactly. */
+describe("the palette's key", () => {
+  test("ctrl+p unless the config says otherwise", () => {
+    expect(paletteBindings(undefined)).toEqual(["ctrl+p"])
+    expect(paletteBindings({ keybinds: {} })).toEqual(["ctrl+p"])
+    expect(paletteBindings({ keybinds: { command_list: "ctrl+k, F2" } })).toEqual(["ctrl+k", "f2"])
+    expect(paletteBindings({ keybinds: { command_list: "none" } })).toEqual([])
+  })
+
+  test("leader chords are left to the host", () => {
+    expect(paletteBindings({ keybinds: { command_list: "p,ctrl+p" } })).toEqual(["ctrl+p"])
+  })
+
+  test("every modifier exactly", () => {
+    expect(matchesBinding({ name: "p", ctrl: true }, "ctrl+p")).toBe(true)
+    expect(matchesBinding({ name: "p" }, "ctrl+p")).toBe(false)
+    expect(matchesBinding({ name: "p", ctrl: true, shift: true }, "ctrl+p")).toBe(false)
+    expect(matchesBinding({ name: "k", ctrl: true, shift: true }, "ctrl+shift+k")).toBe(true)
+    expect(matchesBinding({ name: "f2" }, "f2")).toBe(true)
+    expect(matchesBinding({ name: "p", meta: true }, "alt+p")).toBe(true)
+  })
+})
diff --git a/packages/review/test/view/grid.test.ts b/packages/review/test/view/grid.test.ts
index 701414ec..116611dd 100644
--- a/packages/review/test/view/grid.test.ts
+++ b/packages/review/test/view/grid.test.ts
@@ -38,13 +38,13 @@ const scenario = (name: string) => {
   })
   review = open(review, { file: first }, "a whole-file note", "you", 3)
   review = toggleRead(review, first)
-  return { changes: fixture?.changes, review, file: second }
+  return { changes: fixture?.changes, review, file: second, looks: fixture?.looks }
 }
 
 describe("the grid holds", () => {
   for (const name of Object.keys(FIXTURES)) {
     test(`${name}: every row is exactly the width of the pane`, () => {
-      const { changes, review, file } = scenario(name)
+      const { changes, review, file, looks } = scenario(name)
       if (!changes || changes.files.length === 0) return
       for (const width of WIDTHS) {
         for (const pane of ["files", "diff"] as const) {
@@ -52,7 +52,15 @@ describe("the grid holds", () => {
             const rows = layout(
               changes,
               review,
-              { context: 3, collapsed: new Set(), pane, file, line: 2, ...(thread ? { thread } : {}) },
+              {
+                context: 3,
+                collapsed: new Set(),
+                pane,
+                file,
+                line: 2,
+                ...(thread ? { thread } : {}),
+                ...(looks ? { looks } : {}),
+              },
               { width, height: 28 },
             )
             for (const row of rows) expect(rowWidth(row)).toBe(width - 2)
diff --git a/packages/review/test/view/image.test.ts b/packages/review/test/view/image.test.ts
new file mode 100644
index 00000000..9b36970f
--- /dev/null
+++ b/packages/review/test/view/image.test.ts
@@ -0,0 +1,146 @@
+import { describe, expect, test } from "bun:test"
+import { FIXTURES } from "../../src/core/fixtures.ts"
+import { emptyReview } from "../../src/core/model/review.ts"
+import { binaryRows } from "../../src/core/view/image.ts"
+import { layout } from "../../src/core/view/layout.ts"
+import { type Row, rowWidth } from "../../src/core/view/rows.ts"
+
+const images = FIXTURES.images
+const files = images?.changes.files ?? []
+const looks = images?.looks ?? new Map()
+const byPath = (path: string) => files.find((file) => file.path === path)
+const text = (rows: readonly Row[]) => rows.map((row) => row.runs.map((run) => run.text).join(""))
+const card = (path: string, width = 90) => {
+  const file = byPath(path)
+  if (!file) throw new Error(`no ${path} in the fixture`)
+  return binaryRows(file, looks.get(path), width)
+}
+
+describe("a binary's card says what is true about it", () => {
+  test("changed, same size: metadata, how much and where, the key, both pictures", () => {
+    const said = text(card("media/dashboard.png"))
+    expect(said[1]?.trim()).toBe("PNG 288×180 · 788 KB → 771 KB")
+    expect(said[2]?.trim()).toMatch(/^\d+\.\d+% of pixels changed · \d+×\d+ at \d+,\d+$/)
+    expect(said[3]?.trim()).toBe("[o] Open Both")
+    expect(said.some((line) => line.includes("before") && line.includes("after · changes lit"))).toBe(true)
+  })
+
+  test("resized: says so, and no percentage", () => {
+    const said = text(card("media/thumbnail.png")).join("\n")
+    expect(said).toContain("PNG 288×180 → 144×90")
+    expect(said).toContain("Resized")
+    expect(said).not.toContain("% of pixels")
+  })
+
+  test("added and deleted: the one side there is", () => {
+    expect(text(card("media/logo.png")).join("\n")).toContain("after — new")
+    const deleted = text(card("media/old-banner.gif")).join("\n")
+    expect(deleted).toContain("GIF 288×180 · 561 KB")
+    expect(deleted).toContain("[o] Open Old Version")
+    expect(deleted).toContain("before — deleted")
+  })
+
+  test("a JPEG is described, and says why there is no picture", () => {
+    const said = text(card("media/photo.jpg")).join("\n")
+    expect(said).toContain("JPEG 4032×3024 · 360 KB → 333 KB")
+    expect(said).toContain("No preview for JPEG")
+  })
+
+  test("too large: described, with the way to see it", () => {
+    const said = text(card("media/poster.png")).join("\n")
+    expect(said).toContain("PNG 12000×9000 · 174 MB → 175 MB")
+    expect(said).toContain("Too large to preview")
+    expect(said).toContain("[o] Open Both")
+  })
+
+  test("not an image: size, and nothing it would have to guess", () => {
+    expect(text(card("assets/font.woff2")).map((line) => line.trim())).toEqual([
+      "",
+      "binary · 12.3 KB → 14.0 KB",
+      "[o] Open Both",
+    ])
+  })
+
+  test("still decoding: says so where the picture will be", () => {
+    const file = byPath("media/dashboard.png")
+    if (!file) throw new Error("fixture")
+    expect(text(binaryRows(file, { pending: true, stamp: 99 }, 90)).join("\n")).toContain("Comparing pixels…")
+  })
+})
+
+describe("the pictures are rows like any other", () => {
+  test("every row exactly the width, at every width", () => {
+    for (const path of files.map((file) => file.path))
+      for (const width of [30, 44, 60, 90, 140, 200]) {
+        const file = byPath(path)
+        if (!file) continue
+        for (const row of binaryRows(file, looks.get(path), width)) expect(rowWidth(row)).toBe(width)
+      }
+  })
+
+  test("measuring builds the same number of rows as drawing, without the cells", () => {
+    for (const file of files) {
+      const drawn = binaryRows(file, looks.get(file.path), 90)
+      const measured = binaryRows(file, looks.get(file.path), 90, undefined, false)
+      expect(measured.length).toBe(drawn.length)
+      expect(measured.flatMap((row) => row.runs).some((run) => run.background !== undefined)).toBe(false)
+    }
+  })
+
+  test("half blocks with an exact ink and background, identical cells merged into one run", () => {
+    const rows = card("media/dashboard.png", 140)
+    const picture = rows.filter((row) => row.runs.some((run) => run.text.includes("â–€")))
+    expect(picture.length).toBeGreaterThan(5)
+    for (const row of picture)
+      for (const run of row.runs.filter((each) => each.text.includes("â–€"))) {
+        expect(typeof run.color).toBe("number")
+        expect(typeof run.background).toBe("number")
+        expect(run.text).toMatch(/^â–€+$/)
+      }
+    /** A flat UI screenshot is long runs, not a chunk per cell: the cost driver for the row pool. */
+    const runs = picture.reduce((sum, row) => sum + row.runs.length, 0)
+    const cells = picture.reduce((sum, row) => sum + rowWidth(row), 0)
+    expect(runs).toBeLessThan(cells / 4)
+  })
+
+  test("too narrow for a picture: the words keep the room", () => {
+    const narrow = card("media/dashboard.png", 18)
+    expect(narrow.some((row) => row.runs.some((run) => run.background !== undefined))).toBe(false)
+  })
+})
+
+describe("the review around it", () => {
+  const draw = (state: Parameters[2], height = 40) =>
+    text(
+      layout(images?.changes ?? { source: "branch", files: [] }, emptyReview(), state, {
+        width: 160,
+        height,
+      }),
+    )
+
+  test("the footer offers [o] on a binary", () => {
+    expect(draw({ pane: "diff", file: "media/dashboard.png", looks }).at(-1)).toContain("[o] Open")
+    const turn = FIXTURES.turn?.changes
+    if (!turn) throw new Error("fixture")
+    const plain = layout(
+      turn,
+      emptyReview(),
+      { pane: "diff", file: turn.files[0]?.path },
+      { width: 160, height: 30 },
+    )
+    expect(text(plain).at(-1)).not.toContain("[o]")
+    expect(text(plain).at(-1)).toContain("[?] Keys")
+  })
+
+  test("[?] Keys: every key in the body's place, and the way back", () => {
+    const shown = draw({ keys: true }, 40)
+    expect(shown.some((line) => line.includes("KEYS"))).toBe(true)
+    expect(shown.some((line) => line.includes("[o]") && line.includes("system's viewer"))).toBe(true)
+    expect(shown.at(-1)?.trim()).toBe("[esc] Hide Keys")
+  })
+
+  test("[?] Keys in a short pane says how many are below", () => {
+    const shown = draw({ keys: true }, 14)
+    expect(shown.some((line) => /↓ \d+ more keys/.test(line))).toBe(true)
+  })
+})
diff --git a/packages/review/test/watch/stats.test.ts b/packages/review/test/watch/stats.test.ts
index 3848b94d..8aeaa5bb 100644
--- a/packages/review/test/watch/stats.test.ts
+++ b/packages/review/test/watch/stats.test.ts
@@ -91,7 +91,7 @@ describe("the keys, at any width", () => {
 
   test("with nothing to review, only the keys that act", () => {
     const row = keys(100, {}, true)
-    expect(row.trim()).toBe("[b] Source   [B] Base   [esc] Close")
+    expect(row.trim()).toBe("[b] Source   [B] Base   [?] Keys   [esc] Close")
   })
 
   test("the action has one name: viewed", () => {

From 4c192232298388092c8f5baa80f31a6aa4c4b7be Mon Sep 17 00:00:00 2001
From: Codestz 
Date: Sat, 3 Oct 2026 09:23:13 -0500
Subject: [PATCH 08/90] Review: two edits more than 2,000 lines apart are two
 edits, not a rewrite

The trim only removed what was identical at the very ends, so edits at lines 40 and 2,900 left a
2,860-line middle past the alignment table's cap, drawn as every line out and back in. A stretch
past the cap is now split on lines unique to both sides (patience diff's anchors, longest run in
order) and each piece aligned on its own; only a stretch with no such line is drawn as a rewrite.
Below the cap nothing changes.

Co-Authored-By: Claude Opus 5.5 
---
 packages/review/src/core/diff/hunks.ts  | 175 +++++++++++++++++++-----
 packages/review/test/diff/hunks.test.ts |  55 ++++++++
 2 files changed, 195 insertions(+), 35 deletions(-)

diff --git a/packages/review/src/core/diff/hunks.ts b/packages/review/src/core/diff/hunks.ts
index dae99075..d34deaf6 100644
--- a/packages/review/src/core/diff/hunks.ts
+++ b/packages/review/src/core/diff/hunks.ts
@@ -91,8 +91,8 @@ function common(before: string[], after: string[]): { a: number; b: number }[] {
  *
  * The table below is `(n+1)×(m+1)` 32-bit entries, so the cost is the *product*: two 5,000-line files
  * would allocate ~100 MB inside the TUI's worker thread, which is not a price a diff view gets to
- * charge. The trim above means this is reached only by two genuinely different large files — a
- * wholesale rewrite — and those are shown as all-out-then-all-in, which is what they are.
+ * charge. A stretch past it is split on its unique lines first (`align`); only one with none left —
+ * a wholesale rewrite — is shown as all-out-then-all-in, which is what it is.
  */
 const LIMIT = 2000
 
@@ -166,39 +166,7 @@ function diffUncached(before: string, after: string): Line[] {
     lines.push({ kind: "context", before: index + 1, after: index + 1, text: old[index] as string })
   }
 
-  const oldMid = old.slice(head, old.length - tail)
-  const nowMid = now.slice(head, now.length - tail)
-
-  /**
-   * Only a genuine rewrite reaches the cap now, and a rewrite really is "all of it out, all of it in"
-   * — aligning two unrelated files line by line produces noise, slowly.
-   */
-  if (oldMid.length > LIMIT || nowMid.length > LIMIT) {
-    lines.push(...replaced(oldMid, nowMid, head, head))
-  } else {
-    let a = 0
-    let b = 0
-    for (const pair of [...common(oldMid, nowMid), { a: oldMid.length, b: nowMid.length }]) {
-      while (a < pair.a) {
-        lines.push({ kind: "remove", before: head + a + 1, text: oldMid[a] as string })
-        a++
-      }
-      while (b < pair.b) {
-        lines.push({ kind: "add", after: head + b + 1, text: nowMid[b] as string })
-        b++
-      }
-      if (pair.a < oldMid.length) {
-        lines.push({
-          kind: "context",
-          before: head + a + 1,
-          after: head + b + 1,
-          text: oldMid[pair.a] as string,
-        })
-        a++
-        b++
-      }
-    }
-  }
+  align(old.slice(head, old.length - tail), now.slice(head, now.length - tail), head, head, lines)
 
   for (let index = 0; index < tail; index++) {
     const beforeAt = old.length - tail + index
@@ -213,6 +181,143 @@ function diffUncached(before: string, after: string): Line[] {
   return lines
 }
 
+/**
+ * Two stretches of the files, aligned line by line, onto `out`. `beforeAt` and `afterAt` are where
+ * each stretch starts in its file, so the numbers point at the real lines.
+ *
+ * Small enough, it is the table above. Too large for the table, it is split first — which is what
+ * makes two edits far apart a diff of two edits. The trim in `diffUncached` only removes what is
+ * identical at the very ends, so an edit at line 40 and another at line 2,900 left a 2,860-line
+ * middle; past the cap that was drawn as every line out and every line back in, a rewrite of a file
+ * that had two lines changed.
+ *
+ * The split is patience diff's: lines that occur exactly once on each side are almost always the
+ * same line, so the longest run of them in order is a set of fixed points, and the stretches between
+ * them are aligned on their own — each far smaller, and each split again if it is still too large.
+ * Only a stretch with no such line left is a genuine rewrite, and drawn as one.
+ */
+function align(old: string[], now: string[], beforeAt: number, afterAt: number, out: Line[]): void {
+  /** Identical ends of the stretch are context, and cost nothing to take off first. */
+  let head = 0
+  while (head < old.length && head < now.length && old[head] === now[head]) {
+    out.push({
+      kind: "context",
+      before: beforeAt + head + 1,
+      after: afterAt + head + 1,
+      text: old[head] as string,
+    })
+    head++
+  }
+  let tail = 0
+  while (
+    tail < old.length - head &&
+    tail < now.length - head &&
+    old[old.length - 1 - tail] === now[now.length - 1 - tail]
+  )
+    tail++
+  const oldMid = head === 0 && tail === 0 ? old : old.slice(head, old.length - tail)
+  const nowMid = head === 0 && tail === 0 ? now : now.slice(head, now.length - tail)
+  const at = { before: beforeAt + head, after: afterAt + head }
+
+  if (oldMid.length === 0 || nowMid.length === 0) out.push(...replaced(oldMid, nowMid, at.before, at.after))
+  else if (oldMid.length <= LIMIT && nowMid.length <= LIMIT) aligned(oldMid, nowMid, at.before, at.after, out)
+  else {
+    const anchors = uniqueAnchors(oldMid, nowMid)
+    /** Nothing in common to hold on to: a rewrite, honestly drawn as one. */
+    if (anchors.length === 0) out.push(...replaced(oldMid, nowMid, at.before, at.after))
+    else {
+      let a = 0
+      let b = 0
+      for (const anchor of anchors) {
+        align(oldMid.slice(a, anchor.a), nowMid.slice(b, anchor.b), at.before + a, at.after + b, out)
+        out.push({
+          kind: "context",
+          before: at.before + anchor.a + 1,
+          after: at.after + anchor.b + 1,
+          text: oldMid[anchor.a] as string,
+        })
+        a = anchor.a + 1
+        b = anchor.b + 1
+      }
+      align(oldMid.slice(a), nowMid.slice(b), at.before + a, at.after + b, out)
+    }
+  }
+
+  for (let index = 0; index < tail; index++) {
+    const a = old.length - tail + index
+    const b = now.length - tail + index
+    out.push({ kind: "context", before: beforeAt + a + 1, after: afterAt + b + 1, text: old[a] as string })
+  }
+}
+
+/** A stretch within the table's cap, aligned by the common subsequence. */
+function aligned(old: string[], now: string[], beforeAt: number, afterAt: number, out: Line[]): void {
+  let a = 0
+  let b = 0
+  for (const pair of [...common(old, now), { a: old.length, b: now.length }]) {
+    while (a < pair.a) {
+      out.push({ kind: "remove", before: beforeAt + a + 1, text: old[a] as string })
+      a++
+    }
+    while (b < pair.b) {
+      out.push({ kind: "add", after: afterAt + b + 1, text: now[b] as string })
+      b++
+    }
+    if (pair.a < old.length) {
+      out.push({
+        kind: "context",
+        before: beforeAt + a + 1,
+        after: afterAt + b + 1,
+        text: old[pair.a] as string,
+      })
+      a++
+      b++
+    }
+  }
+}
+
+/**
+ * Lines that occur exactly once in each stretch, paired, and cut down to the longest run that is in
+ * order on both sides — patience sorting, `n log n`. These are the fixed points the stretch is split on.
+ */
+function uniqueAnchors(old: readonly string[], now: readonly string[]): { a: number; b: number }[] {
+  const once = (lines: readonly string[]) => {
+    const seen = new Map()
+    lines.forEach((line, index) => {
+      seen.set(line, seen.has(line) ? -1 : index)
+    })
+    return seen
+  }
+  const inOld = once(old)
+  const inNow = once(now)
+  const pairs: { a: number; b: number }[] = []
+  for (const [line, a] of inOld) {
+    const b = inNow.get(line)
+    if (a >= 0 && b !== undefined && b >= 0) pairs.push({ a, b })
+  }
+  pairs.sort((x, y) => x.a - y.a)
+
+  /** Longest increasing run of `b`: piles of their smallest tops, each card linked to the one before. */
+  const tops: number[] = []
+  const previous = new Int32Array(pairs.length).fill(-1)
+  for (let index = 0; index < pairs.length; index++) {
+    const b = (pairs[index] as { b: number }).b
+    let low = 0
+    let high = tops.length
+    while (low < high) {
+      const mid = (low + high) >> 1
+      if ((pairs[tops[mid] as number] as { b: number }).b < b) low = mid + 1
+      else high = mid
+    }
+    if (low > 0) previous[index] = tops[low - 1] as number
+    tops[low] = index
+  }
+  const run: { a: number; b: number }[] = []
+  for (let at = tops.at(-1) ?? -1; at >= 0; at = previous[at] as number)
+    run.push(pairs[at] as { a: number; b: number })
+  return run.reverse()
+}
+
 /**
  * The changed parts, with a few unchanged lines either side for orientation, and the long runs of
  * untouched file left out — which is the whole reason a diff is readable at all.
diff --git a/packages/review/test/diff/hunks.test.ts b/packages/review/test/diff/hunks.test.ts
index 6fa88255..3e1898d7 100644
--- a/packages/review/test/diff/hunks.test.ts
+++ b/packages/review/test/diff/hunks.test.ts
@@ -212,6 +212,61 @@ describe("large files", () => {
     expect(added?.text).toBe("line 1500 — changed")
   })
 
+  /**
+   * The 0.8 limitation: the trim takes only what is identical at the very ends, so two edits 2,860
+   * lines apart left a 2,860-line middle — past the table's cap, and drawn as every line out and
+   * every line back in. It is split on its unique lines now, and reads as the two edits it is.
+   */
+  test("two edits more than 2,000 lines apart are two edits, not a rewrite", () => {
+    const lines = Array.from({ length: 3000 }, (_, index) => `field ${index + 1}`)
+    const edited = [...lines]
+    edited[39] = "renamed 40"
+    edited[2899] = "renamed 2900"
+
+    const started = performance.now()
+    const diff = diffLines(lines.join("\n"), edited.join("\n"))
+    const elapsed = performance.now() - started
+
+    expect(countChanges(diff)).toEqual({ additions: 2, deletions: 2 })
+    expect(numbered(diff.filter((line) => line.kind !== "context"))).toEqual([
+      "40||field 40",
+      "|40|renamed 40",
+      "2900||field 2900",
+      "|2900|renamed 2900",
+    ])
+    expect(toHunks(lines.join("\n"), edited.join("\n"))).toHaveLength(2)
+    expect(elapsed).toBeLessThan(250)
+  })
+
+  test("far apart, with lines inserted and removed between: every number still points at its file", () => {
+    const lines = Array.from({ length: 6000 }, (_, index) => `row ${index + 1}`)
+    const edited = [...lines]
+    edited.splice(4500, 2) // rows 4501–4502 removed
+    edited.splice(100, 0, "new a", "new b", "new c") // three inserted after row 100
+    const diff = diffLines(lines.join("\n"), edited.join("\n"))
+
+    expect(countChanges(diff)).toEqual({ additions: 3, deletions: 2 })
+    expect(numbered(diff.filter((line) => line.kind !== "context"))).toEqual([
+      "|101|new a",
+      "|102|new b",
+      "|103|new c",
+      "4501||row 4501",
+      "4502||row 4502",
+    ])
+    /** Context either side of the far edit is numbered from each file. */
+    const after = diff.find((line) => line.text === "row 4503")
+    expect([after?.before, after?.after]).toEqual([4503, 4504])
+  })
+
+  test("a stretch with no line unique to hold on to is still a rewrite, bounded", () => {
+    const repeated = Array.from({ length: 4400 }, (_, index) => (index % 2 ? "}" : "{"))
+    const before = ["start", ...repeated, "end"].join("\n")
+    const after = ["START", ...repeated.slice(1), "x", "END"].join("\n")
+    const lines = diffLines(before, after)
+    expect(lines.length).toBeGreaterThan(0)
+    expect(lines.filter((line) => line.kind !== "add").at(-1)?.before).toBe(4402)
+  })
+
   test("a rewrite is reported as everything out then everything in, still numbered from the file", () => {
     const before = Array.from({ length: 2500 }, (_, index) => `old ${index}`).join("\n")
     const after = Array.from({ length: 2500 }, (_, index) => `new ${index}`).join("\n")

From 509198d412f3b7e4f4092eab080dc309a19518b5 Mon Sep 17 00:00:00 2001
From: Codestz 
Date: Sat, 3 Oct 2026 09:29:22 -0500
Subject: [PATCH 09/90] Review: thinner decode slices, and the README says what
 a binary card shows
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Shrink and compare yield twice as often: the longest event-loop stall analysing a 2880×1800 pair
went from 13 ms to 9 ms (the pair blocked 114–124 ms inside OpenCode before slicing). README:
`o`, `?`, ctrl+p while the review is open, and the images section.

Co-Authored-By: Claude Opus 5.5 
---
 packages/review/README.md                | 29 ++++++++++++++++++++++--
 packages/review/src/core/image/pixels.ts |  4 ++--
 2 files changed, 29 insertions(+), 4 deletions(-)

diff --git a/packages/review/README.md b/packages/review/README.md
index 5aeeec79..d98fcda7 100644
--- a/packages/review/README.md
+++ b/packages/review/README.md
@@ -47,7 +47,13 @@ its own with `review_open`, which appear in the panel beside yours.
 | `B` | what the branch is compared against: its nearest parent, or one you pick |
 | `w` | half the window, or all of it |
 | `p` | what the panel is costing, in the footer |
-| `q` | close |
+| `o` | open an image or other binary in your system's viewer — both versions |
+| `?` | every key, in the panel |
+| `esc` / `q` | close |
+
+`ctrl+p` still opens OpenCode's command palette while the review is up: the review steps aside for
+it (on OpenCode 1 it comes back when the palette closes; on OpenCode 2 it closes, and the palette's
+"Open or close the changes" brings it back where you were).
 
 The mouse works too: click a file in the list to jump to it, click a heading to fold it, or its
 `+ note` / `[ ] viewed` buttons; the wheel scrolls whichever side it is over.
@@ -59,6 +65,24 @@ The mouse works too: click a file in the list to jump to it, click a heading to
 | **uncommitted** | everything not committed, *including files git has never seen*. The default, because it is what you are looking at nine times in ten |
 | **branch** | everything this branch changes against its *nearest parent* — what its pull request would show. On `main � feature � X`, X is compared with `feature`, not `main`. `B` picks a different base, remembered per branch |
 
+## Images and other binaries
+
+A binary is never drawn as text — git's rule decides it (a NUL in the first 8000 bytes). Its card
+says what is true about it:
+
+```
+PNG 2880×1800 · 807 KB → 789 KB
+2.56% of pixels changed · 601×221 at 1900,300
+[o] Open Both
+```
+
+PNG, APNG, JPEG, GIF, WebP and BMP are named with their dimensions; anything else is
+`binary · 12.3 KB → 14.0 KB`. PNG and GIF are also decoded — in slices, off the draw path — for the
+pixel diff and a small before/after preview in half blocks, with what changed lit. A preview shows
+*where*; `o` opens both versions in your system's viewer for *what*. Binaries are read up to 32 MB
+(text stops at 400 KB) and decoded up to 16 megapixels; past either, the card still names and sizes
+the file.
+
 ## Seeing it without OpenCode
 
 ```sh
@@ -66,7 +90,8 @@ bun packages/review/src/cli/preview.ts --fixture sprawl --width 200
 ```
 
 Draws the whole view against sample change sets — forty files, a three-thousand-line file, a created
-file, a deleted one — with no OpenCode running. This is where the design is made.
+file, a deleted one, every state of a changed image (`--fixture images`) — with no OpenCode running.
+`--keys` draws the keys screen. This is where the design is made.
 
 ## When the code moves
 
diff --git a/packages/review/src/core/image/pixels.ts b/packages/review/src/core/image/pixels.ts
index e412b15f..531b0715 100644
--- a/packages/review/src/core/image/pixels.ts
+++ b/packages/review/src/core/image/pixels.ts
@@ -99,7 +99,7 @@ export function* shrink(source: Pixels, maxWidth = THUMB_WIDTH, maxHeight = THUM
       out[o + 3] = Math.round(weight / ((n[tx] as number) || 1))
     }
     work += (y1 - y0) * source.width
-    if (work >= SLICE) {
+    if (work >= SLICE / 2) {
       work = 0
       yield
     }
@@ -158,7 +158,7 @@ export function* comparePixels(
       }
     }
     work += width
-    if (work >= SLICE * 4) {
+    if (work >= SLICE * 2) {
       work = 0
       yield
     }

From 96fb37875219507560885c9bb1579aed764044bf Mon Sep 17 00:00:00 2001
From: Codestz 
Date: Sat, 3 Oct 2026 09:30:11 -0500
Subject: [PATCH 10/90] bun.lock: the trail workspace

Co-Authored-By: Claude Opus 5.5 
---
 bun.lock | 18 ++++++++++++++++++
 1 file changed, 18 insertions(+)

diff --git a/bun.lock b/bun.lock
index 1245fa73..207a125b 100644
--- a/bun.lock
+++ b/bun.lock
@@ -86,6 +86,10 @@
     "packages/shell": {
       "name": "@opencode-cockpit/shell",
       "version": "0.8.0",
+      "bin": {
+        "opencode-shell": "./dist/cli/preview.js",
+        "shell": "./dist/cli/preview.js",
+      },
       "dependencies": {
         "@opencode-ai/plugin": "1.18.31",
         "@opencode-cockpit/client": "workspace:*",
@@ -135,6 +139,18 @@
         "solid-js": "1.9.12",
       },
     },
+    "packages/trail": {
+      "name": "@opencode-cockpit/trail",
+      "version": "0.8.0",
+      "bin": {
+        "opencode-trail": "./dist/cli/preview.js",
+        "trail": "./dist/cli/preview.js",
+      },
+      "dependencies": {
+        "@opencode-ai/plugin": "1.18.31",
+        "@opencode-cockpit/client": "workspace:*",
+      },
+    },
     "packages/trust": {
       "name": "@opencode-cockpit/trust",
       "version": "0.8.0",
@@ -289,6 +305,8 @@
 
     "@opencode-cockpit/subagents": ["@opencode-cockpit/subagents@workspace:packages/subagents"],
 
+    "@opencode-cockpit/trail": ["@opencode-cockpit/trail@workspace:packages/trail"],
+
     "@opencode-cockpit/trust": ["@opencode-cockpit/trust@workspace:packages/trust"],
 
     "@opencode-cockpit/updater": ["@opencode-cockpit/updater@workspace:packages/updater"],

From 8e00dc9eeab63aaad7c4201c345c19ba4266a965 Mon Sep 17 00:00:00 2001
From: Codestz 
Date: Sat, 3 Oct 2026 09:47:11 -0500
Subject: [PATCH 11/90] =?UTF-8?q?client/design:=20warnRows=20=E2=80=94=20a?=
 =?UTF-8?q?=20settings=20notice=20wraps=20in=20a=20sidebar=20and=20keeps?=
 =?UTF-8?q?=20its=20fix?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Co-Authored-By: Claude Opus 5.5 
---
 packages/client/src/design.ts       | 40 +++++++++++++++++++++++++++++
 packages/client/test/design.test.ts | 12 +++++++++
 2 files changed, 52 insertions(+)

diff --git a/packages/client/src/design.ts b/packages/client/src/design.ts
index 463fe339..441cfd88 100644
--- a/packages/client/src/design.ts
+++ b/packages/client/src/design.ts
@@ -273,6 +273,46 @@ export function emptyBlock(title: string, width: number, hideWhenEmpty = false):
   ]
 }
 
+/**
+ * A warning a block says about itself — a settings notice — as rows exactly `width` wide: `!` in the
+ * warning tone, then the words, wrapped at spaces with the rest indented under the first word. A
+ * sidebar is 24–36 cells and the sentence is wider, so it wraps rather than losing its last words
+ * (the fix it names). At most `maxRows`; the last one ends in `…` when there was more.
+ */
+export function warnRows(text: string, width: number, maxRows = 3): ToneRun[][] {
+  const room = Math.max(0, width)
+  const prefix = `${GLYPH.warn} `
+  const indent = " ".repeat(prefix.length)
+  const room1 = Math.max(1, room - prefix.length)
+  const lines: string[] = []
+  let line = ""
+  for (const word of text.split(/\s+/).filter(Boolean)) {
+    const next = line ? `${line} ${word}` : word
+    if (next.length <= room1 || !line) line = next
+    else {
+      lines.push(line)
+      line = word
+    }
+  }
+  if (line) lines.push(line)
+  const shown = lines.slice(0, Math.max(1, maxRows))
+  const cut = lines.length > shown.length
+  return shown.map((words, i) => {
+    let body =
+      words.length > room1 || (cut && i === shown.length - 1)
+        ? `${words.slice(0, room1 - 1)}${GLYPH.more}`
+        : words
+    body = body.slice(0, room1)
+    const lead: ToneRun = i === 0 ? { text: prefix, tone: "warning" } : { text: indent }
+    const used = Math.min(room, prefix.length + body.length)
+    return [
+      lead,
+      { text: body, tone: "warning" },
+      ...(room > used ? [{ text: " ".repeat(room - used) }] : []),
+    ]
+  })
+}
+
 /** Whether a block draws at all: always, unless it is empty and asked to hide then. */
 export const blockShown = (count: number, hideWhenEmpty = false): boolean => count > 0 || !hideWhenEmpty
 
diff --git a/packages/client/test/design.test.ts b/packages/client/test/design.test.ts
index 78e0d3e6..640c8947 100644
--- a/packages/client/test/design.test.ts
+++ b/packages/client/test/design.test.ts
@@ -17,6 +17,7 @@ import {
   STATE_TONE,
   stateMark,
   summaryRuns,
+  warnRows,
 } from "../src/design.ts"
 
 /** The shared grammar: one tone per state, and a key row that keeps its way out and says what it cut. */
@@ -191,6 +192,17 @@ 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)
+    expect(lines(rows)).toEqual([
+      '! settings: "statusline" is no',
+      "  longer read — run".padEnd(30),
+      "  /cockpit-setup".padEnd(30),
+    ])
+    expect(rows[0]?.[0]).toMatchObject({ text: "! ", tone: "warning" })
+    expect(lines(warnRows("one two three four five six", 9, 2))).toEqual(["! one two", "  three…".padEnd(9)])
+  })
+
   test("is as tall as the same block with one single-row item", () => {
     const withOne = [
       ...headingRows("Shells", [{ text: "1 running", tone: "accent" }], 30),

From 43b83d4c357e658714bf3bd66aafa2e0a606ef07 Mon Sep 17 00:00:00 2001
From: Codestz 
Date: Sat, 3 Oct 2026 10:10:45 -0500
Subject: [PATCH 12/90] Status: the sidebar table is the default, settings come
 from `status`, and /status-setup
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Settings through baySettings("status"): the `status` section only. `statusline`, root keys,
  a bay-level `maxRows` and `sidebarOrder` are old names — `!` rows, not read. `sidebarRows` is
  the column's cap; `sidebar: false` means the bottom; order from the top-level list.
- The `sidebar` preset is the budget table, and the default surface is the sidebar. Its rows are
  built-ins now: title, in, out, cache, write, sep, spend, avail, git, plus `context` style
  `solid` and `tokens` style `row`. spend/avail read a proxy's file and draw nothing without
  one; a hairline draws only between two rows.
- examples/sidebar.ts, sidebar-full.ts, sidebar-budget.ts removed; a config pointing at one, or
  naming a preset that does not exist, gets a `!` row rather than a blank column.
- Status draws cockpitNotices (unreadable file, unknown top-level name, unknown sidebar entry)
  and its own, wrapped by warnRows to the measured sidebar width.
- /statusline is /status-setup; /statusline stays one release as a twin that toasts the new
  name and still briefs the agent. The brief speaks of the `status` section.

Co-Authored-By: Claude Opus 5.5 
---
 packages/status/README.md                     | 129 ++++++--
 packages/status/examples/README.md            |  23 +-
 packages/status/examples/bottom.ts            |   2 +-
 packages/status/examples/sidebar-budget.ts    | 255 ----------------
 packages/status/examples/sidebar-full.ts      | 160 ----------
 packages/status/examples/sidebar.ts           |  82 -----
 .../status/skills/statusline-design/SKILL.md  |  20 +-
 packages/status/src/cli/preview.ts            |  58 +++-
 packages/status/src/core/budget.ts            |  72 +++++
 packages/status/src/core/builtins/index.ts    |   3 +-
 packages/status/src/core/builtins/model.ts    |  27 ++
 packages/status/src/core/builtins/table.ts    | 145 +++++++++
 packages/status/src/core/config.ts            | 280 +++++++++++-------
 packages/status/src/core/context.ts           |   8 +
 packages/status/src/core/custom.ts            |  20 +-
 packages/status/src/core/diff.ts              |  20 ++
 packages/status/src/core/fixtures.ts          |   3 +
 packages/status/src/core/instructions.ts      |  34 ++-
 packages/status/src/core/notices.ts           |  51 ++--
 packages/status/src/core/render.ts            |   6 +-
 packages/status/src/core/report.ts            |  29 +-
 packages/status/src/core/segments.ts          |  16 +
 packages/status/src/core/types.ts             |   4 +
 .../status/src/tui/components/statusline.tsx  |  66 +++--
 packages/status/src/tui/index.tsx             | 200 ++++++++-----
 packages/status/src/tui/state/snapshot.ts     |   5 +
 packages/status/src/tui/state/store.ts        |  51 +++-
 packages/status/test/config.test.ts           | 277 +++++++++++------
 packages/status/test/custom.test.ts           |  15 +-
 packages/status/test/examples.test.ts         | 127 +-------
 packages/status/test/instructions.test.ts     |  34 ++-
 packages/status/test/notices.test.ts          |  52 ++--
 packages/status/test/report.test.ts           |  14 +-
 packages/status/test/store.test.ts            |  65 ++++
 packages/status/test/table.test.ts            | 243 +++++++++++++++
 35 files changed, 1553 insertions(+), 1043 deletions(-)
 delete mode 100644 packages/status/examples/sidebar-budget.ts
 delete mode 100644 packages/status/examples/sidebar-full.ts
 delete mode 100644 packages/status/examples/sidebar.ts
 create mode 100644 packages/status/src/core/budget.ts
 create mode 100644 packages/status/src/core/builtins/table.ts
 create mode 100644 packages/status/test/table.test.ts

diff --git a/packages/status/README.md b/packages/status/README.md
index e257d2f5..c8f0e1d6 100644
--- a/packages/status/README.md
+++ b/packages/status/README.md
@@ -17,15 +17,49 @@ get it with every other bay through the `opencode-cockpit` bundle.
 { "plugin": ["@opencode-cockpit/status"] }
 ```
 
-That's enough. Without any configuration you get a line under the conversation carrying what
-OpenCode does not already tell you.
+That's enough. Without any configuration you get a table at the top of the sidebar carrying what
+OpenCode's own Context block says, better. `/status-setup` briefs the agent in your conversation to
+change it with you.
 
 ## What it shows by default, and why
 
-OpenCode's own furniture already carries a lot: its footer has the path, the branch and the token
-count; its sidebar has the context percentage and the spend; its prompt has the agent and the model.
+```
+Context
+████████████████
+working 1m02s
+tokens 85.2k · 43%
+in     265 · 0%
+out    60 · 0%
+cache  84.9k · 100%
+──────────────
+spend  $26.24
+avail  $173.76 · 87% left
+──────────────
+git    5f +312 -48 vs main
+```
+
+The `sidebar` preset, and the default since 0.9: how full the window is as one solid bar, the tokens
+broken into named rows with their share of it, a proxy's budget, and the branch's whole diff against
+where it forked — what a reviewer will read. Every number gets a word, in a fixed column so the
+figures line up; colour is a level (calm, then the warning, then the error), never a label.
+
+A row with nothing to say is not drawn: `write` with no cache writes, `spend` and `avail` with no
+proxy writing a budget (see [Proxies](#proxies-litellm-and-friends)), `working` while nothing runs,
+and a hairline with nothing on one side of it. The rows are built-ins — `title`, `context` with
+`"style": "solid"`, `tokens` with `"style": "row"`, `in`, `out`, `cache`, `write`, `sep`, `spend`,
+`avail`, `git` — so nothing is installed beside it.
 
-The default line repeats one of those on purpose — the token count and the percentage — because it
+It sits beside OpenCode's own Context block; to keep only one, see
+[Replacing OpenCode's own sidebar blocks](#replacing-opencodes-own-sidebar-blocks).
+
+### At the bottom instead
+
+`{ "status": { "sidebar": false } }` — or `"surface": "bottom"` — draws the `default` line under
+the conversation. OpenCode's own furniture already carries a lot: its footer has the path, the
+branch and the token count; its sidebar has the context percentage and the spend; its prompt has the
+agent and the model.
+
+The bottom line repeats one of those on purpose — the token count and the percentage — because it
 says them better: a bar you read without looking, with the total's parts beside it, is a different
 instrument from `78.5K (39%)` in a corner. What stays out are the facts a second copy adds nothing
 to: the path, the branch, the model, the spend.
@@ -53,21 +87,36 @@ Composing fourteen segments is a design exercise; most people want a good line.
 built-ins only — nothing to install, nothing to write:
 
 ```jsonc
-{ "statusline": { "preset": "default" } }
+{ "status": { "preset": "default" } }
 ```
 
-`minimal` · `default` · `detailed` · `sidebar`. Anything you write beside one wins, so it is a
-starting point and not a mode. See [`examples/`](./examples) for the modules to reach for when a
-preset is not enough.
+| Preset | Surface | What you get |
+| --- | --- | --- |
+| `sidebar` | sidebar | the table above — the default |
+| `minimal` | bottom | how full the context is, and what changed |
+| `default` | bottom | the bar, where the tokens went, what changed, how long |
+| `detailed` | bottom | everything the built-ins know, for a wide window |
+
+Anything you write beside one wins, so it is a starting point and not a mode. A name that is not a
+preset draws the surface's own line, with a `!` row above it naming the presets there are. See
+[`examples/`](./examples) for the modules to reach for when a preset is not enough.
+
+## `/status-setup`
+
+Type it in a conversation and the agent is briefed to set the line up with you: which config file
+this project reads, what is drawing now, the settings to fix first, the presets and built-ins, how to
+preview a change, and where the design rules are. It asks what you want before it edits anything.
+`/statusline`, its name until 0.9, still works for one release and says the new name.
 
 ## Configuration
 
-`~/.config/opencode-cockpit/config.json` for every project, `/.cockpit.json` for one, and
-the plugin entry itself beats both.
+The `status` section of `~/.config/opencode-cockpit/config.json` for every project, of
+`/.cockpit.json` for one, and the plugin entry itself beats both. Comments and trailing
+commas are fine.
 
 ```jsonc
 {
-  "statusline": {
+  "status": {
     "surface": "bottom",
     "separator": " │ ",
     "segments": [
@@ -83,20 +132,32 @@ the plugin entry itself beats both.
 A segment is a built-in's name, or that name with settings. Unknown names are skipped rather than
 fatal, so a config written against a newer version costs you a segment and not the line.
 
+The keys every bay shares work here too: `enabled`, `sidebar` (`false` draws at the bottom),
+`sidebarRows` (rows the column draws before the lowest-priority ones give way; the table's own is
+14, any other column's 8). Where the block sits among the others is the top-level `sidebar` list —
+`["status", "subagents", "shell", "trail", "trust"]` — and nowhere else.
+
+**What is not read says so.** `"statusline"` (the section's name before 0.9), Status's keys at the
+file's root, a bay-level `maxRows` (now `sidebarRows`) and `sidebarOrder` are no longer read; each is
+a `!` row at the top of the column, `! settings: "statusline" is no longer read — run
+/cockpit-setup`, until the file is fixed. So are a file that is not valid JSON, a top-level name
+nothing reads, an entry in the `sidebar` list that is not a bay, a value of the wrong kind, and a
+module that would not load.
+
 ### Surfaces
 
 Two, each with a job.
 
 | `surface` | Where | Good for |
 | --- | --- | --- |
+| `sidebar` | the sidebar, stacked vertically — the default | a table: every figure with its word |
 | `bottom` | full-width line under the conversation | everything, when no sidebar is open |
-| `sidebar` | the sidebar, stacked vertically by default | the time dimension: trends, composition |
 
 Use `lines` for more than one at once:
 
 ```jsonc
 {
-  "statusline": {
+  "status": {
     "lines": [
       { "surface": "bottom", "segments": ["git.diff", "todo", "session.time"] },
       { "surface": "sidebar", "segments": ["context", "cost"] }
@@ -113,7 +174,8 @@ surface up with OpenCode's own content.
 
 Segments carry a priority, and a line too wide for its surface drops the lowest-priority ones until
 it fits. How full the context is survives a 60-column window; the version string does not. Set
-`priority` on any segment to change what goes first. A vertical line drops by `maxRows` instead.
+`priority` on any segment to change what goes first. A column drops by `sidebarRows` (a line's own
+`maxRows`) instead, and says how many with a `↳ N more` row.
 
 ## Built-in segments
 
@@ -123,8 +185,13 @@ it fits. How full the context is survives a 60-column window; the version string
 | `git.branch` | current branch, dimmed on the default branch | |
 | `git.diff` | `+150 / -30` — what is uncommitted: `git diff --shortstat HEAD` | |
 | `model` | `claude-opus-5` | `full` |
-| `context` | how full the window is | `style`: `percent` \| `bar` \| `gradient` \| `split`, `width`, `warnAt`, `dangerAt` |
-| `tokens` | `78.5k tok` | |
+| `context` | how full the window is | `style`: `percent` \| `bar` \| `solid` \| `gradient` \| `split`, `width`, `warnAt`, `dangerAt` |
+| `tokens` | `78.5k tok`; `tokens 85.2k · 43%` as a table row | `format`, `style`: `parts` \| `row` |
+| `title` | `Context`, bold: a column's heading | `text` |
+| `in` · `out` · `cache` · `write` | `cache  84.9k · 100%` — one part of the window and its share; nothing when zero | |
+| `sep` | a hairline between groups, drawn only with a row on either side | `width` |
+| `spend` · `avail` | `spend  $26.24`, `avail  $173.76 · 87% left` — a proxy's budget; nothing without one | `file` |
+| `git` | `git    5f +312 -48 vs main` — the branch against where it forked, commits and uncommitted | |
 | `cost` | session spend | `currency`, `showZero` |
 | `todo` | `3/7 todo` | `showComplete` |
 | `session.status` | `working 1m02s` since the prompt, or a retry and its countdown | |
@@ -179,8 +246,8 @@ Each block of OpenCode's sidebar is an internal plugin, and `tui.json` can switc
 }
 ```
 
-That removes the host's own `Context / tokens / % used / spent` block, leaving the space to a
-`sidebar` line of your own — the honest way to avoid reading the same figure twice. The same works
+That removes the host's own `Context / tokens / % used / spent` block, leaving the space to the
+table — the honest way to avoid reading the same figure twice. The same works
 for `internal:sidebar-files`, `-todo`, `-lsp`, `-mcp`, `-footer`, and the home screen's
 `internal:home-footer` and `internal:home-tips`.
 
@@ -224,7 +291,7 @@ export default {
 
 ```jsonc
 {
-  "statusline": {
+  "status": {
     "modules": ["~/.config/opencode-cockpit/statusline.ts"],
     "segments": ["burn", "git.diff"]
   }
@@ -244,13 +311,12 @@ custom segment exactly as testable as a built-in. It is loaded once and its segm
 every repaint, so it can keep history — which is how a sparkline or a rate is possible at all.
 
 Returning `undefined` hides the segment. A segment that throws loses only its own place on the line.
-A module that will not load raises a toast naming the file, rather than silently dropping segments.
+A module that will not load raises a toast naming the file and keeps a `!` row above the line,
+rather than silently dropping segments.
 
 **Worked examples** live in [`examples/`](./examples): `bottom.ts` is a complete line for a window
-with no sidebar; `sidebar.ts` is a quiet column beside OpenCode's own Context block;
-`sidebar-full.ts` replaces that block; `sidebar-budget.ts` is that column drawn as a table, with a
-budget from a proxy and the branch's diff; `gallery.ts` draws every technique at once. All of them
-are loaded and asserted by the test suite, so none of them can rot.
+with no sidebar; `gallery.ts` draws every technique at once. Both are loaded and asserted by the
+test suite, so neither can rot. The sidebar examples became the `sidebar` preset in 0.9.
 
 ## Your Claude Code statusline
 
@@ -258,7 +324,7 @@ A shell command, fed the same JSON on stdin that Claude Code's `statusLine` hook
 
 ```jsonc
 {
-  "statusline": {
+  "status": {
     "commands": { "mine": { "run": "~/.claude/statusline.sh", "intervalMs": 2000 } },
     "segments": [{ "type": "command", "name": "mine" }]
   }
@@ -303,8 +369,15 @@ config:
 ```
 
 Without them the `cost` and `context` segments stay silent instead of reporting `$0.00` and `0%`.
-If your proxy knows the real spend — LiteLLM's `/spend` endpoints do — a `command` segment can read
-it, which is better than any locally multiplied estimate.
+If your proxy knows the real spend, that is better than any locally multiplied estimate. The table's
+`spend` and `avail` rows read it from a file: LiteLLM's IAP plugin writes `{ "baseline", "delta",
+"cap" }` to `~/.cache/opencode-litellm-iap/spend.json`, and anything that writes the same shape will
+do (`{ "type": "spend", "file": "~/elsewhere.json" }` points them at it). With no file they draw
+nothing. A `command` segment can read anything else — LiteLLM's `/spend` endpoints, say.
+
+```sh
+bunx @opencode-cockpit/status preview --proxy ~/.cache/opencode-litellm-iap/spend.json
+```
 
 ## Troubleshooting
 
diff --git a/packages/status/examples/README.md b/packages/status/examples/README.md
index ab231e7f..0dc161ef 100644
--- a/packages/status/examples/README.md
+++ b/packages/status/examples/README.md
@@ -4,20 +4,20 @@ Start with a **preset** — a whole line by name, built-ins only, nothing to ins
 
 ```jsonc
 // ~/.config/opencode-cockpit/config.json
-{ "statusline": { "preset": "default" } }
+{ "status": { "preset": "default" } }
 ```
 
 | Preset | Surface | What you get |
 | --- | --- | --- |
+| `sidebar` | sidebar | a table: the window, where the tokens went, a proxy's budget, the branch's diff — the default |
 | `minimal` | bottom | how full the context is, and what changed |
 | `default` | bottom | the bar, where the tokens went, what changed, how long |
 | `detailed` | bottom | everything the built-ins know, for a wide window |
-| `sidebar` | sidebar | a quiet column beside OpenCode's own blocks |
 
 Anything you write beside a preset wins, so it is a starting point rather than a mode:
 
 ```jsonc
-{ "statusline": { "preset": "default", "separator": "  " } }
+{ "status": { "preset": "default", "separator": "  " } }
 ```
 
 ## When a preset is not enough
@@ -28,14 +28,11 @@ name. Every one is loaded and asserted by the test suite, so none of them can ro
 | File | For | Segments |
 | --- | --- | --- |
 | [`bottom.ts`](./bottom.ts) | the whole statusline on one line, no sidebar open | `bar` `filling` `rate` `cached` `tokens` |
-| [`sidebar.ts`](./sidebar.ts) | a small column **beside** OpenCode's Context block | `bar` `split` `changes` |
-| [`sidebar-full.ts`](./sidebar-full.ts) | a column that **replaces** that block — turn it off with `plugin_enabled` | `bar` `window` `cached` `spend` `elapsed` `changes` `todo` |
-| [`sidebar-budget.ts`](./sidebar-budget.ts) | a column as a **table**: fixed label gutter, one bar, a proxy's budget, the branch diff | `title` `bar` `tokens` `in` `out` `cache` `write` `sep` `spend` `avail` `git` |
 | [`gallery.ts`](./gallery.ts) | not a statusline — every technique the renderer can draw, labelled | all of them |
 
 ```jsonc
 {
-  "statusline": {
+  "status": {
     "modules": ["~/.config/opencode-cockpit/modules/bottom.ts"],
     "surface": "bottom",
     "segments": ["bar", "filling", "rate", "cached", "session.diff", "session.time"]
@@ -43,18 +40,24 @@ name. Every one is loaded and asserted by the test suite, so none of them can ro
 }
 ```
 
+The sidebar examples (`sidebar.ts`, `sidebar-full.ts`, `sidebar-budget.ts`) became the `sidebar`
+preset in 0.9; its rows — `title`, `in`, `out`, `cache`, `write`, `sep`, `spend`, `avail`, `git` — are
+built-ins. A config still pointing at one in this folder gets a `!` row saying so. A copy you made
+elsewhere keeps working.
+
 ## Look at it before you ship it
 
 ```sh
 bunx @opencode-cockpit/status preview --watch            # redraws on every save
 bunx @opencode-cockpit/status preview --state fresh      # before the first reply
 bunx @opencode-cockpit/status preview --debug            # mark segments that drew nothing
+bunx @opencode-cockpit/status preview --proxy none       # as someone without a proxy sees it
 bunx @opencode-cockpit/status preview --module examples/gallery.ts
 ```
 
-The six sample sessions are the states a design gets wrong: `fresh` (no model, no tokens — most
-designs render a wall of zeroes), `working`, `full`, `unpriced` (behind a proxy, nothing declared),
-`retrying`, and `empty`.
+The sample sessions are the states a design gets wrong: `fresh` (no model, no tokens — most designs
+render a wall of zeroes), `working`, `busy`, `uncached`, `full`, `unpriced` (behind a proxy, nothing
+declared), `retrying`, and `empty`.
 
 The taste rules this bay learned the expensive way live in
 [`../skills/statusline-design/`](../skills/statusline-design/SKILL.md).
diff --git a/packages/status/examples/bottom.ts b/packages/status/examples/bottom.ts
index 27fa3053..a4d30133 100644
--- a/packages/status/examples/bottom.ts
+++ b/packages/status/examples/bottom.ts
@@ -12,7 +12,7 @@
  * densest of the examples on purpose -- the bottom line is the only surface with real width.
  *
  *   {
- *     "statusline": {
+ *     "status": {
  *       "modules": [""],
  *       "lines": [
  *         { "surface": "bottom", "separator": " │ ",
diff --git a/packages/status/examples/sidebar-budget.ts b/packages/status/examples/sidebar-budget.ts
deleted file mode 100644
index 4f373039..00000000
--- a/packages/status/examples/sidebar-budget.ts
+++ /dev/null
@@ -1,255 +0,0 @@
-/**
- * A sidebar built as a table: a context header, the window as one solid bar, the tokens broken
- * into named rows, a budget read from a proxy, and the branch's whole diff.
- *
- * This is the layout a user arrived at after five rejected iterations, kept here because the
- * reasons are worth more than the rows: every number gets a word, the labels are a fixed column
- * so the values line up, the bar is solid rather than dashed, and the groups are separated by
- * hairlines rather than headings — a heading cannot know whether the rows under it will draw.
- *
- * And, from seeing it on a real session: the word is the label, so there is no coloured square
- * beside it saying the same thing in a colour nobody can decode; a row whose figure is zero is not
- * drawn (`write 0 · 0%` said nothing, at length); and colour is a level — the bar, the percentage
- * and the budget are calm until a threshold, then the warning, then the error — never a category.
- *
- *   {
- *     "statusline": {
- *       "modules": [""],
- *       "lines": [{
- *         "surface": "sidebar",
- *         "maxRows": 13,
- *         "segments": ["title", "bar", "tokens", "in", "out", "cache", "write",
- *                      "sep", "spend", "avail", "sep", "git"]
- *       }]
- *     }
- *   }
- *
- * It replaces OpenCode's own Context block, so turn that off:
- *   { "plugin_enabled": { "internal:sidebar-context": false } }
- *
- * The budget rows read a file a proxy writes. Without one they stay silent, which is right in a
- * statusline and unhelpful while you are designing — `"demo": true` on either segment fills in
- * sample figures so the layout can be looked at.
- */
-
-import { execFile } from "node:child_process"
-import { readFileSync } from "node:fs"
-import { homedir } from "node:os"
-import { join } from "node:path"
-import type { CustomModule, Piece, Run, SegmentConfig, StatusContext } from "@opencode-cockpit/status/segment"
-import { compact, contextRatio, contextUsed, GAUGE, gaugeTone } from "@opencode-cockpit/status/segment"
-
-/**
- * The label column, padded so every value starts in the same place. The word is the label. Seven:
- * the longest label is `tokens`, and at six it ran into its own figure (`tokens167.8k`).
- */
-const LABEL = 7
-function row(label: string, value: Run[]): { runs: Run[] } {
-  return { runs: [{ text: label.padEnd(LABEL), tone: "muted" }, ...value] }
-}
-
-const BAR = 16
-
-/**
- * A level's tone: nothing until it is worth a colour, then the gauge rule's warning or error. Calm
- * is left to the bar, so a column of figures is not a column of greens.
- */
-const level = (ratio: number): Run["tone"] => (ratio >= GAUGE.warnAt ? gaugeTone(ratio) : "text")
-
-/** What each token row needs: its own figure, and its share of the window. Zero is not a row. */
-function share(ctx: StatusContext, value: number) {
-  const total = contextUsed(ctx.session?.tokens)
-  if (total === 0 || value === 0) return undefined
-  return [
-    { text: compact(value), tone: "text" as const },
-    { text: ` · ${Math.round((value / total) * 100)}%`, tone: "muted" as const },
-  ]
-}
-
-/**
- * The spend a proxy reports. LiteLLM's IAP plugin writes this; anything that writes
- * `{ baseline, delta, cap }` will do. Re-read at most every five seconds — the file is tiny, but
- * a statusline redraws every second and this is not worth a syscall each time.
- */
-interface Spend {
-  baseline?: number
-  delta?: number
-  cap?: number
-}
-let cached: { at: number; spend: Spend | undefined } | undefined
-function spendState(config: SegmentConfig): { total: number; cap: number } | undefined {
-  if (config.demo === true) return { total: 26.24, cap: 200 }
-  const now = Date.now()
-  if (!cached || now - cached.at > 5000) {
-    const file =
-      typeof config.file === "string"
-        ? config.file
-        : join(homedir(), ".cache", "opencode-litellm-iap", "spend.json")
-    try {
-      cached = { at: now, spend: JSON.parse(readFileSync(file, "utf8")) as Spend }
-    } catch {
-      cached = { at: now, spend: undefined }
-    }
-  }
-  const { baseline, delta, cap } = cached.spend ?? {}
-  if (typeof baseline !== "number" || typeof cap !== "number" || cap <= 0) return undefined
-  return { total: baseline + (typeof delta === "number" ? delta : 0), cap }
-}
-
-/**
- * The branch's whole diff against where it forked: every commit on the branch plus uncommitted
- * edits — what a reviewer would read, rather than what this session happened to touch. Git runs
- * away from the draw path and the row shows whatever the last finished reading produced.
- */
-interface BranchDiff {
-  files: number
-  added: number
-  removed: number
-}
-let branch: BranchDiff | undefined
-let asking = false
-let askedAt = 0
-function refreshBranch(ctx: StatusContext): void {
-  if (asking || Date.now() - askedAt < 10_000) return
-  asking = true
-  const base = ctx.defaultBranch ?? "main"
-  const run = execFile as unknown as (
-    cmd: string,
-    args: string[],
-    opts: { timeout: number; cwd: string },
-    cb: (err: Error | null, out: string) => void,
-  ) => void
-  const opts = { timeout: 3000, cwd: ctx.worktree }
-  run("git", ["merge-base", base, "HEAD"], opts, (err, fork) => {
-    if (err || !fork.trim()) {
-      asking = false
-      askedAt = Date.now()
-      return
-    }
-    run("git", ["diff", "--shortstat", fork.trim()], opts, (err2, out) => {
-      asking = false
-      askedAt = Date.now()
-      if (err2) return
-      const found = /(\d+) files? changed(?:, (\d+) insertions?)?(?:, (\d+) deletions?)?/.exec(out)
-      if (!found) return
-      branch = {
-        files: Number(found[1]),
-        added: Number(found[2] ?? 0),
-        removed: Number(found[3] ?? 0),
-      }
-    })
-  })
-}
-
-export default {
-  segments: {
-    /** The column's subject, so the table below it has one. */
-    title() {
-      return { runs: [{ text: "Context", tone: "text", bold: true }] }
-    },
-
-    /**
-     * One solid bar: filled cells in the gauge rule's tone, empty cells a solid dark track. Not `â–‘`,
-     * which reads as floating gaps, and not `─`, which reads as a row of dashes. One tone for the
-     * whole fill, not a gradient: green at 13% and olive at 12% was a colour nobody could read.
-     *
-     * No end caps. `â–•` and `â–�` are eighth-blocks whose ink sits against one edge of the cell, so an
-     * opening cap indents the row by most of a column and the bar stops lining up with the labels
-     * above and below it. The dark track already shows how far the bar could go.
-     *
-     * No figure beside it either: the `tokens` row directly below already reads the percentage out,
-     * and a number printed twice in a column of ten rows is the thing the eye catches on.
-     */
-    bar(ctx: StatusContext) {
-      const ratio = contextRatio(ctx.session)
-      if (ratio === undefined) return undefined
-      const filled = Math.round(ratio * BAR)
-      return {
-        runs: [
-          { text: "â–ˆ".repeat(filled), tone: gaugeTone(ratio) },
-          { text: "â–ˆ".repeat(BAR - filled), tone: "border" },
-        ],
-      }
-    },
-
-    /** The whole window, and how full it is. */
-    tokens(ctx: StatusContext) {
-      const total = contextUsed(ctx.session?.tokens)
-      if (total === 0) return undefined
-      const ratio = contextRatio(ctx.session)
-      return row("tokens", [
-        { text: compact(total), tone: "text" },
-        ...(ratio === undefined ? [] : [{ text: ` · ${Math.round(ratio * 100)}%`, tone: level(ratio) }]),
-      ])
-    },
-
-    /** Fresh prompt tokens: neither cached nor generated. */
-    in(ctx: StatusContext) {
-      const value = share(ctx, ctx.session?.tokens?.input ?? 0)
-      return value && row("in", value)
-    },
-
-    /** What the model wrote, reasoning included. */
-    out(ctx: StatusContext) {
-      const tokens = ctx.session?.tokens
-      const value = share(ctx, (tokens?.output ?? 0) + (tokens?.reasoning ?? 0))
-      return value && row("out", value)
-    },
-
-    /** Served from the prompt cache — cheap, and usually most of the window. */
-    cache(ctx: StatusContext) {
-      const value = share(ctx, ctx.session?.tokens?.cache.read ?? 0)
-      return value && row("cache", value)
-    },
-
-    /** Written into the cache this session — a one-time premium each. Not the same as "out". */
-    write(ctx: StatusContext) {
-      const value = share(ctx, ctx.session?.tokens?.cache.write ?? 0)
-      return value && row("write", value)
-    },
-
-    /** A hairline, to group without a heading that might strand itself. */
-    sep() {
-      return { runs: [{ text: "─".repeat(14), tone: "border", dim: true }] }
-    },
-
-    /** Spent so far against the cap: a figure, coloured only once the budget is a level to watch. */
-    spend(_ctx: StatusContext, config: SegmentConfig) {
-      const state = spendState(config)
-      if (!state) return undefined
-      return row("spend", [{ text: `$${state.total.toFixed(2)}`, tone: level(state.total / state.cap) }])
-    },
-
-    /** What is left, and the share of the cap that decides whether it earns a colour. */
-    avail(_ctx: StatusContext, config: SegmentConfig) {
-      const state = spendState(config)
-      if (!state) return undefined
-      const left = Math.max(0, state.cap - state.total)
-      const tone = level(state.total / state.cap)
-      return row("avail", [
-        { text: `$${left.toFixed(2)}`, tone },
-        { text: ` · ${Math.round((left / state.cap) * 100)}% left`, tone: tone === "text" ? "muted" : tone },
-      ])
-    },
-
-    /** The branch as a reviewer will see it, not as this session left it. */
-    git(ctx: StatusContext, config: SegmentConfig): Piece | undefined {
-      if (config.demo === true) {
-        return row("git", [
-          { text: "3f", tone: "muted" },
-          { text: " +42", tone: "success" },
-          { text: " -7", tone: "error" },
-          { text: ` vs ${ctx.defaultBranch ?? "main"}`, tone: "muted", dim: true },
-        ])
-      }
-      refreshBranch(ctx)
-      if (!branch || branch.files === 0) return undefined
-      return row("git", [
-        { text: `${branch.files}f`, tone: "muted" },
-        { text: ` +${compact(branch.added)}`, tone: "success" },
-        { text: ` -${compact(branch.removed)}`, tone: "error" },
-        { text: ` vs ${ctx.defaultBranch ?? "main"}`, tone: "muted", dim: true },
-      ])
-    },
-  },
-} satisfies CustomModule
diff --git a/packages/status/examples/sidebar-full.ts b/packages/status/examples/sidebar-full.ts
deleted file mode 100644
index a4056f68..00000000
--- a/packages/status/examples/sidebar-full.ts
+++ /dev/null
@@ -1,160 +0,0 @@
-/**
- * The sidebar as the whole instrument panel, for people who would rather not read a line under
- * the prompt at all.
- *
- * This one is meant to *replace* OpenCode's own Context block rather than sit beside it, so it
- * carries the figures that block carried — tokens, percentage, spend — and then the ones it never
- * did. Turn the host's block off and give the column to this:
- *
- *   // ~/.config/opencode/tui.json
- *   { "plugin": ["opencode-cockpit"], "plugin_enabled": { "internal:sidebar-context": false } }
- *
- *   // ~/.config/opencode-cockpit/config.json
- *   {
- *     "statusline": {
- *       "modules": [""],
- *       "surface": "sidebar",
- *       "segments": [
- *         { "type": "text", "value": "CONTEXT", "color": "border" },
- *         "gauge", "window", "cache",
- *         { "type": "text", "value": "SPEND", "color": "border" },
- *         "spend",
- *         { "type": "text", "value": "SESSION", "color": "border" },
- *         "elapsed", "work", "tasks"
- *       ],
- *       "maxRows": 14
- *     }
- *   }
- *
- * Every row is self-labelled and built to read at about thirty characters, and there are
- * deliberately no section headings: a heading cannot know whether the rows under it will draw
- * anything, so on an unpriced model a "SPEND" title strands itself above nothing.
- *
- * `tasks` is here but left out of the config above, because OpenCode's own Todo list carries the
- * task names and this is only a count. Add it if you switch that list off too
- * (`internal:sidebar-todo`).
- */
-
-import type { CustomModule, Run, StatusContext } from "@opencode-cockpit/status/segment"
-import {
-  compact,
-  contextRatio,
-  contextUsed,
-  gradient,
-  money,
-  preciseDuration,
-} from "@opencode-cockpit/status/segment"
-
-/** Spend samples, so a rate can be shown beside the total. */
-const samples: { at: number; cost: number }[] = []
-
-/** A row of label and value, so a column of them lines up as a table would. */
-function row(label: string, value: Run[]): { runs: Run[] } {
-  return { runs: [{ text: `${label} `, tone: "muted", dim: true }, ...value] }
-}
-
-export default {
-  segments: {
-    /** The headline figure, and the one the host's block led with. */
-    bar(ctx: StatusContext, config) {
-      const ratio = contextRatio(ctx.session)
-      if (ratio === undefined) return undefined
-      const width = typeof config.width === "number" ? config.width : 16
-      const filled = Math.round(ratio * width)
-      const runs: Run[] = []
-      for (let cell = 0; cell < width; cell++) {
-        runs.push(
-          cell < filled
-            ? { text: "â–ˆ", color: gradient((cell + 1) / width) }
-            : { text: "â–‘", tone: "border" as const },
-        )
-      }
-      runs.push({
-        text: ` ${Math.round(ratio * 100)}%`,
-        color: gradient(ratio),
-        bold: ratio >= 0.85,
-      })
-      return { runs }
-    },
-
-    /** What the percentage is a percentage of — the denominator the bar hides. */
-    window(ctx: StatusContext) {
-      const used = contextUsed(ctx.session?.tokens)
-      const limit = ctx.session?.model?.contextLimit
-      if (used === 0) return undefined
-      return limit
-        ? row("of", [
-            { text: compact(used), tone: "text" },
-            { text: ` / ${compact(limit)}`, tone: "muted" },
-          ])
-        : row("used", [{ text: `${compact(used)} tok`, tone: "text" }])
-    },
-
-    /**
-     * What was replayed from cache against what had to be sent fresh — the two figures side by
-     * side rather than a share of them. A share reads as "100%" for most of a cached session,
-     * which looks like a bug even when it is arithmetic.
-     */
-    cached(ctx: StatusContext) {
-      const tokens = ctx.session?.tokens
-      if (!tokens || contextUsed(tokens) === 0) return undefined
-      const fresh = tokens.input + tokens.output + tokens.reasoning
-      return row("cache", [
-        { text: compact(tokens.cache.read), tone: "success" },
-        { text: " · fresh ", tone: "muted", dim: true },
-        { text: compact(fresh), tone: "info" },
-      ])
-    },
-
-    /** What the session has cost, and what it is costing. Silent where nobody declared prices. */
-    spend(ctx: StatusContext) {
-      const session = ctx.session
-      if (!session?.priced) return undefined
-      const last = samples[samples.length - 1]
-      if (!last || ctx.now - last.at >= 1000) samples.push({ at: ctx.now, cost: session.cost })
-      if (samples.length > 60) samples.shift()
-
-      const first = samples[0]
-      const latest = samples[samples.length - 1]
-      const runs: Run[] = [{ text: money(session.cost), tone: "warning" }]
-      if (first && latest && latest.at > first.at) {
-        const perMinute = ((latest.cost - first.cost) / (latest.at - first.at)) * 60_000
-        if (perMinute >= 0.005) {
-          runs.push({ text: ` · $${perMinute.toFixed(2)}/min`, tone: "muted", dim: true })
-        }
-      }
-      return { runs }
-    },
-
-    /** How long this has been going, in a unit a person reads without converting. */
-    elapsed(ctx: StatusContext) {
-      const started = ctx.session?.startedAt
-      if (started === undefined) return undefined
-      return row("for", [{ text: preciseDuration(ctx.now - started), tone: "muted" }])
-    },
-
-    /** What the session has done to the tree. */
-    changes(ctx: StatusContext) {
-      const diff = ctx.session?.diff
-      if (!diff || diff.files === 0) return undefined
-      return row("diff", [
-        { text: `+${compact(diff.additions)}`, tone: "success" },
-        { text: ` -${compact(diff.deletions)}`, tone: "error" },
-        { text: ` · ${diff.files}f`, tone: "muted", dim: true },
-      ])
-    },
-
-    /**
-     * Work outstanding, for a sidebar where OpenCode's own Todo list is switched off. With that
-     * list on, this is a worse copy of it — the list carries the task names.
-     */
-    todo(ctx: StatusContext) {
-      const todo = ctx.session?.todo
-      if (!todo || todo.total === 0 || todo.completed === todo.total) return undefined
-      return row("todo", [
-        { text: `${todo.completed}/${todo.total}`, tone: "text" },
-        { text: ` · ${todo.total - todo.completed} left`, tone: "muted", dim: true },
-      ])
-    },
-  },
-} satisfies CustomModule
diff --git a/packages/status/examples/sidebar.ts b/packages/status/examples/sidebar.ts
deleted file mode 100644
index 7b554507..00000000
--- a/packages/status/examples/sidebar.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-/**
- * A small, quiet sidebar: a colored context bar and the two figures behind it.
- *
- * The sidebar sits beside OpenCode's own Context block, which already gives you the token count,
- * the percentage and the spend. So this one does not repeat them -- it draws the bar those numbers
- * describe, and adds the two things the host leaves out: how the window is being used, and what
- * the session has changed.
- *
- *   {
- *     "statusline": {
- *       "modules": [""],
- *       "surface": "sidebar",
- *       "segments": ["bar", "split", "changes"]
- *     }
- *   }
- *
- * `stack` defaults to vertical here, so it does not need to be written.
- */
-
-import type { CustomModule, Run, StatusContext } from "@opencode-cockpit/status/segment"
-import { compact, contextRatio, contextUsed, gradient } from "@opencode-cockpit/status/segment"
-
-/** A row with a quiet label, so a column of them lines up as a table would. */
-function row(label: string, value: Run[]): { runs: Run[] } {
-  return { runs: [{ text: `${label} `, tone: "muted", dim: true }, ...value] }
-}
-
-export default {
-  segments: {
-    /**
-     * The context window, coloured cell by cell. The figure beside it is the host's own, so this
-     * is the one place the bay repeats something -- a bar with no number is hard to read at a
-     * glance, and the percentage is two characters.
-     */
-    bar(ctx: StatusContext, config) {
-      const ratio = contextRatio(ctx.session)
-      if (ratio === undefined) return undefined
-      const width = typeof config.width === "number" ? config.width : 14
-      const filled = Math.round(ratio * width)
-      const runs: Run[] = []
-      for (let cell = 0; cell < width; cell++) {
-        runs.push(
-          cell < filled
-            ? { text: "â–ˆ", color: gradient((cell + 1) / width) }
-            : { text: "â–‘", tone: "border" as const },
-        )
-      }
-      runs.push({ text: ` ${Math.round(ratio * 100)}%`, color: gradient(ratio) })
-      return { runs }
-    },
-
-    /**
-     * What the window is made of: cache, fresh input, output. A coloured rule per part rather
-     * than a filled chip, so a share of nothing is a mark rather than an empty box.
-     */
-    split(ctx: StatusContext) {
-      const tokens = ctx.session?.tokens
-      const total = contextUsed(tokens)
-      if (!tokens || total === 0) return undefined
-      const share = (n: number) => `${Math.round((n / total) * 100)}%`
-      return row("split", [
-        { text: "▌", tone: "success" },
-        { text: share(tokens.cache.read + tokens.cache.write), tone: "muted" },
-        { text: " ▌", tone: "info" },
-        { text: share(tokens.input), tone: "muted" },
-        { text: " ▌", tone: "accent" },
-        { text: share(tokens.output + tokens.reasoning), tone: "muted" },
-      ])
-    },
-
-    /** What the session has done to the working tree, which the host never mentions. */
-    changes(ctx: StatusContext) {
-      const diff = ctx.session?.diff
-      if (!diff || diff.files === 0) return undefined
-      return row("diff", [
-        { text: `${diff.files}f`, tone: "muted" },
-        { text: ` +${compact(diff.additions)}`, tone: "success" },
-        { text: ` -${compact(diff.deletions)}`, tone: "error" },
-      ])
-    },
-  },
-} satisfies CustomModule
diff --git a/packages/status/skills/statusline-design/SKILL.md b/packages/status/skills/statusline-design/SKILL.md
index 10985c7a..c66d0fbd 100644
--- a/packages/status/skills/statusline-design/SKILL.md
+++ b/packages/status/skills/statusline-design/SKILL.md
@@ -1,6 +1,6 @@
 ---
 name: statusline-design
-description: Designing or editing an opencode-cockpit statusline — a bottom line or a sidebar column, its config, or a TypeScript segment module. Use when a request mentions the statusline, a segment, .cockpit.json's statusline section, or a module importing @opencode-cockpit/status/segment.
+description: Designing or editing an opencode-cockpit statusline (the Status bay) — the sidebar table or a bottom line, its config, or a TypeScript segment module. Use when a request mentions the statusline, the status bay, /status-setup, a segment, the "status" section of config.json or .cockpit.json, or a module importing @opencode-cockpit/status/segment.
 ---
 
 # Designing a statusline
@@ -75,8 +75,11 @@ state you happen to be looking at.
 - A run takes `tone`, `color`, `bg`, `bgTone`, `bold`, `dim`, `italic`, `underline`.
 - **A track drawn in `panel` tone is invisible** on most themes — it is the panel's own colour.
   Use `border`.
-- A **column** keeps at most `maxRows` rows (default 8) — **count your rows and raise it**, or the
-  extras vanish. The preview prints `↳ N dropped` when this happens.
+- A **column** keeps at most `sidebarRows` rows (a line in `lines` says `maxRows`; 14 for the
+  `sidebar` preset, 8 for any other column) — **count your rows and raise it**, or the extras
+  vanish. The preview prints `↳ N dropped` when this happens.
+- A `sep` hairline draws only with a row on either side of it, so a group that says nothing does
+  not strand one.
 - A **line** drops the lowest-priority segments until it fits the width.
 - A segment that throws loses only its own row. A module that fails to load raises a toast naming
   the file.
@@ -99,8 +102,9 @@ truncated; a track *was* being drawn and `panel` was the panel's own colour.
 
 | What | Where |
 | --- | --- |
-| Settings, every project | `~/.config/opencode-cockpit/config.json` |
-| Settings, one project | `/.cockpit.json` |
+| Settings, every project | the `"status"` section of `~/.config/opencode-cockpit/config.json` |
+| Settings, one project | the `"status"` section of `/.cockpit.json` |
+| Old names | `"statusline"`, Status keys at the file's root, a bay-level `maxRows`, `sidebarOrder`: not read; each is a `!` row. Write `"status"`, `sidebarRows`, and the top-level `"sidebar"` list |
 | Modules | anywhere — `~/.config/opencode-cockpit/modules/` needs no `node_modules` beside it |
 | Which plugins load | `~/.config/opencode/tui.json` |
 
@@ -120,7 +124,9 @@ The footer under the prompt is core UI: it cannot be hidden. Design around it.
 
 ## Start simple
 
-Most people want a good line, not a composition exercise. Begin with the built-ins and a `format`
-string; reach for a module only when the answer needs the session read, decided on, or remembered
+Most people want a good line, not a composition exercise. The default is the `sidebar` preset — the
+table this skill's rules were learned on: `title`, a `solid` context bar, a `tokens` row, `in` `out`
+`cache` `write`, `sep`, `spend` `avail` (a proxy's budget file; silent without one), `sep`, `git`.
+Begin with the presets and the built-ins and a `format` string; reach for a module only when the answer needs the session read, decided on, or remembered
 across ticks — a rate, a trend, a budget from a file. Reach for a shell `command` for anything a CLI
 already prints; do not reimplement the shell as a segment.
diff --git a/packages/status/src/cli/preview.ts b/packages/status/src/cli/preview.ts
index 921a278f..febf125c 100644
--- a/packages/status/src/cli/preview.ts
+++ b/packages/status/src/cli/preview.ts
@@ -5,6 +5,7 @@
  *   bunx @opencode-cockpit/status preview
  *   bunx @opencode-cockpit/status preview --config ~/.config/opencode-cockpit/config.json
  *   bunx @opencode-cockpit/status preview --state full --width 60
+ *   bunx @opencode-cockpit/status preview --proxy ~/.cache/opencode-litellm-iap/spend.json
  *
  * Why this exists: a statusline is a visual thing, and editing TypeScript, restarting OpenCode and
  * squinting is a loop measured in minutes. One sidebar took about twenty restarts to design, and
@@ -12,10 +13,13 @@
  * sentence. Nothing here can tell you a design is good; it can tell you what it looks like.
  */
 
-import { watch } from "node:fs"
-import { asSegmentConfig, loadStatusConfig, type ResolvedLine, resolveLines } from "../core/config.ts"
+import { readFileSync, watch } from "node:fs"
+import { settingsPaths } from "@opencode-cockpit/client/settings"
+import { budgetFile, readBudget } from "../core/budget.ts"
+import { asSegmentConfig, loadStatus, type ResolvedLine, resolveLines } from "../core/config.ts"
 import { loadCustomSegments, resolveModulePath } from "../core/custom.ts"
 import { FIXTURES, type FixtureName } from "../core/fixtures.ts"
+import { moduleNoticeText, noticeRows } from "../core/notices.ts"
 import { fit, fitColumn } from "../core/render.ts"
 import { buildSegments, type SegmentDef, segmentWidth } from "../core/segments.ts"
 import { paintRuns as paintColour } from "./ansi.ts"
@@ -32,6 +36,7 @@ if (has("help")) {
   preview — draw your statusline here, against sample sessions
 
     --config    a config file (default: your global + project config)
+    --proxy     the budget file a proxy writes, for spend and avail (none: draw without one)
     --module    draw this module's segments, on their own
     --with-config     ...and the config's modules and segments as well
     --state     ${Object.keys(FIXTURES).join(" | ")} (default: every one)
@@ -44,9 +49,27 @@ if (has("help")) {
 
 const directory = process.cwd()
 const configPath = flag("config")
-const config = configPath
-  ? ((await Bun.file(configPath).json()).statusline ?? {})
-  : loadStatusConfig(directory)
+/**
+ * Through the loader the bay itself uses, so the preview reads a file exactly as OpenCode will — its
+ * `status` section, comments and all — and draws the same `!` rows for what it will not read. A
+ * `--config` file stands in for the global one, with no project file beside it.
+ */
+const global = settingsPaths({ directory }).global
+const loadedStatus = loadStatus({
+  where: configPath
+    ? {
+        read: (path) => {
+          if (path !== global) return undefined
+          try {
+            return readFileSync(configPath, "utf8")
+          } catch {
+            return undefined
+          }
+        },
+      }
+    : { directory },
+})
+const config = loadedStatus.config
 
 /**
  * `--module` draws that module and nothing else.
@@ -60,11 +83,15 @@ const only = flag("module")
 const isolate = only !== undefined && !has("with-config")
 const modules = [...(isolate ? [] : (config.modules ?? [])), ...(only ? [only] : [])]
 let custom: ReadonlyMap = new Map()
+const moduleErrors: string[] = []
 if (modules.length > 0) {
   const loaded = await loadCustomSegments(modules, directory)
   custom = loaded.segments
+  moduleErrors.push(...loaded.errors)
   for (const error of loaded.errors) console.error(`  module failed: ${error}`)
 }
+/** The `!` rows the bay would draw above its first line. */
+const troubles = [...loadedStatus.notices, ...moduleErrors.map(moduleNoticeText)]
 
 /**
  * On its own, a module draws every segment it declares, in the order it declares them, with room
@@ -81,10 +108,22 @@ const lines = resolveLines(
         paddingLeft: config.paddingLeft,
         paddingRight: config.paddingRight,
         segments: [...custom.keys()],
-        maxRows: Math.max(config.maxRows ?? 0, custom.size),
+        sidebarRows: Math.max(config.sidebarRows ?? 0, custom.size),
       }
     : config,
 )
+/** Where the notices go: the first sidebar line, else the first line — as in the TUI. */
+const noticeLine = lines.find((line) => line.surface === "sidebar") ?? lines[0]
+
+/**
+ * A proxy's budget, as the bay reads it: the file the lines name, or `--proxy`. `--proxy none` draws
+ * the line as someone without a proxy sees it.
+ */
+const proxy = flag("proxy")
+const budgetAt =
+  proxy === "none" ? undefined : proxy ? resolveModulePath(proxy, directory) : budgetFile(lines)
+const budget = budgetAt ? readBudget(budgetAt) : undefined
+
 const states = flag("state") ? [flag("state") as FixtureName] : (Object.keys(FIXTURES) as FixtureName[])
 const debug = has("debug") || config.debug === true
 
@@ -112,7 +151,10 @@ async function draw(): Promise {
 
     for (const line of lines) {
       const room = width || roomFor(line, process.stdout.columns || 120)
-      const ctx = { ...fixture.ctx, width: room }
+      const ctx = { ...fixture.ctx, width: room, ...(budget ? { budget } : {}) }
+      if (line === noticeLine) {
+        for (const row of noticeRows(troubles, room)) console.log(`  ${paintRuns(row.runs).trimEnd()}`)
+      }
       const built = buildSegments(ctx, line.segments.map(asSegmentConfig), {
         custom,
         icons: line.icons,
@@ -134,7 +176,7 @@ async function draw(): Promise {
       }
       /** Rows a real sidebar would have dropped in silence. */
       if (fitted.dropped > 0) {
-        const over = line.stack === "vertical" ? `maxRows is ${line.maxRows}` : `${room} columns`
+        const over = line.stack === "vertical" ? `sidebarRows is ${line.maxRows}` : `${room} columns`
         console.log(`  ${dim(`↳ ${fitted.dropped} dropped — ${over}`)}`)
       }
       const widest = Math.max(0, ...fitted.segments.map(segmentWidth))
diff --git a/packages/status/src/core/budget.ts b/packages/status/src/core/budget.ts
new file mode 100644
index 00000000..63b44680
--- /dev/null
+++ b/packages/status/src/core/budget.ts
@@ -0,0 +1,72 @@
+/**
+ * A budget a proxy keeps: what has been spent against a cap.
+ *
+ * OpenCode knows what a model costs only when a provider declares prices, and behind a proxy nobody
+ * does — the proxy is the one that knows. LiteLLM's IAP plugin writes `{ baseline, delta, cap }` to a
+ * small file; anything that writes the same shape will do. No file, no budget, and the rows that draw
+ * it say nothing: a made-up figure would be worse than none.
+ */
+
+import { readFileSync } from "node:fs"
+import { homedir } from "node:os"
+import { join, resolve } from "node:path"
+
+export interface Budget {
+  /** Spent so far. */
+  spent: number
+  /** The most that may be spent; always above zero. */
+  cap: number
+}
+
+/** Where LiteLLM's IAP plugin writes it. A `file` on the `spend` or `avail` segment points elsewhere. */
+export const budgetPath = (home = homedir()): string =>
+  join(home, ".cache", "opencode-litellm-iap", "spend.json")
+
+/** How often the file is read again: it is tiny, but the line repaints every second. */
+export const BUDGET_EVERY_MS = 5_000
+
+/** The file's text as a budget, or undefined when it is not one. */
+export function parseBudget(text: string | undefined): Budget | undefined {
+  if (text === undefined) return undefined
+  let raw: unknown
+  try {
+    raw = JSON.parse(text)
+  } catch {
+    return undefined
+  }
+  const { baseline, delta, cap } = (raw ?? {}) as { baseline?: unknown; delta?: unknown; cap?: unknown }
+  if (typeof baseline !== "number" || typeof cap !== "number" || !(cap > 0)) return undefined
+  return { spent: baseline + (typeof delta === "number" ? delta : 0), cap }
+}
+
+export function readBudget(path: string): Budget | undefined {
+  try {
+    return parseBudget(readFileSync(path, "utf8"))
+  } catch {
+    return undefined
+  }
+}
+
+const BUDGET_SEGMENTS = new Set(["spend", "avail"])
+
+/**
+ * The file the lines want read, or undefined when no line draws a budget — nothing should touch the
+ * disk for a figure nobody is going to draw. The first `file` written on a budget segment wins.
+ */
+export function budgetFile(
+  lines: ReadonlyArray<{ segments: ReadonlyArray }>,
+  home = homedir(),
+): string | undefined {
+  let wanted = false
+  for (const line of lines) {
+    for (const segment of line.segments) {
+      const name = typeof segment === "string" ? segment : segment.type
+      if (!name || !BUDGET_SEGMENTS.has(name)) continue
+      if (typeof segment !== "string" && typeof segment.file === "string") {
+        return segment.file.startsWith("~/") ? resolve(home, segment.file.slice(2)) : segment.file
+      }
+      wanted = true
+    }
+  }
+  return wanted ? budgetPath(home) : undefined
+}
diff --git a/packages/status/src/core/builtins/index.ts b/packages/status/src/core/builtins/index.ts
index dbf040aa..927be3ff 100644
--- a/packages/status/src/core/builtins/index.ts
+++ b/packages/status/src/core/builtins/index.ts
@@ -3,9 +3,10 @@ import { SEGMENTS as model } from "./model.ts"
 import { SEGMENTS as place } from "./place.ts"
 import { SEGMENTS as session } from "./session.ts"
 import { SEGMENTS as system } from "./system.ts"
+import { SEGMENTS as table } from "./table.ts"
 
 /**
  * Every built-in, grouped by what it talks about rather than listed in one file: adding a segment
  * should mean opening the twenty lines it belongs with, not four hundred.
  */
-export const BUILTINS: SegmentDef[] = [...place, ...model, ...session, ...system]
+export const BUILTINS: SegmentDef[] = [...place, ...model, ...session, ...system, ...table]
diff --git a/packages/status/src/core/builtins/model.ts b/packages/status/src/core/builtins/model.ts
index 20b718db..a8a65dbc 100644
--- a/packages/status/src/core/builtins/model.ts
+++ b/packages/status/src/core/builtins/model.ts
@@ -5,6 +5,7 @@ import { contextRatio, contextUsed } from "../context.ts"
 import { bar, compact, gradient, money, percent, shortModel } from "../format.ts"
 import type { Run, SegmentDef, Tone } from "../types.ts"
 import { formatted, num, str } from "./settings.ts"
+import { labelled, level } from "./table.ts"
 
 export const SEGMENTS: SegmentDef[] = [
   {
@@ -74,6 +75,23 @@ export const SEGMENTS: SegmentDef[] = [
         return { runs }
       }
 
+      if (style === "solid") {
+        /**
+         * The sidebar table's bar: filled cells in the gauge rule's tone, the rest a solid dark track.
+         * Not `░`, which reads as floating gaps, and not `─`, a row of dashes. No end caps — `▕` and
+         * `â–�` are eighth-blocks whose ink sits against one edge, so a cap indents the bar off the
+         * label column — and no figure: the `tokens` row under it reads the percentage out, and a
+         * number printed twice in a column of ten rows is what the eye catches on.
+         */
+        const filled = Math.round(ratio * width)
+        return {
+          runs: [
+            { text: "â–ˆ".repeat(filled), tone: gaugeTone(ratio, warnAt, dangerAt) },
+            { text: "â–ˆ".repeat(width - filled), tone: "border" },
+          ],
+        }
+      }
+
       if (style === "bar") {
         const filled = bar(ratio, width)
         /**
@@ -128,6 +146,15 @@ export const SEGMENTS: SegmentDef[] = [
       })
       if (shaped) return shaped
 
+      /** A row of the sidebar table: the whole window, and how full it is, coloured only as a level. */
+      if (str(config, "style") === "row") {
+        const ratio = contextRatio(ctx.session)
+        return labelled("tokens", [
+          { text: compact(used), tone: "text" },
+          ...(ratio === undefined ? [] : [{ text: ` · ${percent(ratio)}`, tone: level(ratio) }]),
+        ])
+      }
+
       /**
        * The parts, coloured by what they are rather than labelled in a row of equal-weight text:
        * cache green, fresh input blue, output accent — the same three colours the split bar uses,
diff --git a/packages/status/src/core/builtins/table.ts b/packages/status/src/core/builtins/table.ts
new file mode 100644
index 00000000..040b9039
--- /dev/null
+++ b/packages/status/src/core/builtins/table.ts
@@ -0,0 +1,145 @@
+/**
+ * The rows of the sidebar's table: a heading, the tokens by where they went, a proxy's budget, the
+ * branch's diff, and the hairlines between the groups.
+ *
+ * They were the `sidebar-budget` example's, a layout a user arrived at after five rejected
+ * iterations, and became built-ins when it became the `sidebar` preset — so the default needs no
+ * module. Three rules from it hold every row here: the word is the label, in a fixed column so the
+ * figures line up; a row whose figure is zero is not drawn (`write 0 · 0%` said nothing, at length);
+ * and colour is a level — calm until a threshold, then the warning, then the error — never a category.
+ */
+
+import { GAUGE, gaugeTone } from "@opencode-cockpit/client/design"
+import type { StatusContext } from "../context.ts"
+import { contextUsed } from "../context.ts"
+import { compact } from "../format.ts"
+import type { Run, SegmentDef, Tone } from "../types.ts"
+import { num, str } from "./settings.ts"
+
+/**
+ * The label column, padded so every figure starts in the same place. Seven: the longest label is
+ * `tokens`, and at six it ran into its own figure (`tokens167.8k`).
+ */
+export const LABEL = 7
+
+export function labelled(label: string, value: Run[]): { runs: Run[] } {
+  return { runs: [{ text: label.padEnd(LABEL), tone: "muted" }, ...value] }
+}
+
+/** A level's tone: the text colour until it is worth a colour, so a column of figures is not a column of greens. */
+export const level = (ratio: number): Tone => (ratio >= GAUGE.warnAt ? gaugeTone(ratio) : "text")
+
+const widthOf = (runs: readonly Run[]) => runs.reduce((sum, run) => sum + run.text.length, 0)
+
+/** `extra` when the row still fits the room with it, so a narrow column loses a word rather than a figure. */
+function ifRoom(ctx: StatusContext, runs: Run[], extra: Run): Run[] {
+  return widthOf(runs) + LABEL + extra.text.length <= ctx.width ? [...runs, extra] : runs
+}
+
+/** One token row: its own figure, and its share of the window. Zero is not a row. */
+function share(ctx: StatusContext, label: string, value: number): { runs: Run[] } | undefined {
+  const total = contextUsed(ctx.session?.tokens)
+  if (total === 0 || value === 0) return undefined
+  return labelled(label, [
+    { text: compact(value), tone: "text" },
+    { text: ` · ${Math.round((value / total) * 100)}%`, tone: "muted" },
+  ])
+}
+
+export const SEGMENTS: SegmentDef[] = [
+  {
+    /** The column's subject, so the table under it has one; `text` names it something else. */
+    name: "title",
+    priority: 90,
+    render(_ctx, config) {
+      return { runs: [{ text: str(config, "text") ?? "Context", tone: "text", bold: true }] }
+    },
+  },
+  {
+    /** Fresh prompt tokens: neither cached nor generated. */
+    name: "in",
+    priority: 35,
+    render: (ctx) => share(ctx, "in", ctx.session?.tokens?.input ?? 0),
+  },
+  {
+    /** What the model wrote, reasoning included. */
+    name: "out",
+    priority: 35,
+    render(ctx) {
+      const tokens = ctx.session?.tokens
+      return share(ctx, "out", (tokens?.output ?? 0) + (tokens?.reasoning ?? 0))
+    },
+  },
+  {
+    /** Served from the prompt cache — cheap, and usually most of the window. */
+    name: "cache",
+    priority: 35,
+    render: (ctx) => share(ctx, "cache", ctx.session?.tokens?.cache.read ?? 0),
+  },
+  {
+    /** Written into the cache this session — a one-time premium each. Not the same as `out`. */
+    name: "write",
+    priority: 30,
+    render: (ctx) => share(ctx, "write", ctx.session?.tokens?.cache.write ?? 0),
+  },
+  {
+    /**
+     * A hairline, to group without a heading that might strand itself. Drawn only between two rows:
+     * one with nothing under it, or under another, is left out.
+     */
+    name: "sep",
+    priority: 5,
+    divider: true,
+    render(_ctx, config) {
+      return {
+        runs: [{ text: "─".repeat(Math.max(1, num(config, "width", 14))), tone: "border", dim: true }],
+      }
+    },
+  },
+  {
+    /** Spent so far against the cap: a figure, coloured only once the budget is a level to watch. */
+    name: "spend",
+    priority: 60,
+    render(ctx) {
+      const budget = ctx.budget
+      if (!budget) return undefined
+      return labelled("spend", [
+        { text: `$${budget.spent.toFixed(2)}`, tone: level(budget.spent / budget.cap) },
+      ])
+    },
+  },
+  {
+    /** What is left, and the share of the cap that decides whether it earns a colour. */
+    name: "avail",
+    priority: 60,
+    render(ctx) {
+      const budget = ctx.budget
+      if (!budget) return undefined
+      const left = Math.max(0, budget.cap - budget.spent)
+      const tone = level(budget.spent / budget.cap)
+      const runs: Run[] = [
+        { text: `$${left.toFixed(2)}`, tone },
+        { text: ` · ${Math.round((left / budget.cap) * 100)}%`, tone: tone === "text" ? "muted" : tone },
+      ]
+      return labelled("avail", ifRoom(ctx, runs, { text: " left", tone: tone === "text" ? "muted" : tone }))
+    },
+  },
+  {
+    /** The branch as a reviewer will see it, against where it forked — not as this session left it. */
+    name: "git",
+    priority: 50,
+    render(ctx) {
+      const diff = ctx.branchDiff
+      if (!diff || diff.files === 0) return undefined
+      const runs: Run[] = [
+        { text: `${diff.files}f`, tone: "muted" },
+        { text: ` +${compact(diff.additions)}`, tone: "success" },
+        { text: ` -${compact(diff.deletions)}`, tone: "error" },
+      ]
+      return labelled(
+        "git",
+        ifRoom(ctx, runs, { text: ` vs ${ctx.defaultBranch ?? "main"}`, tone: "muted", dim: true }),
+      )
+    },
+  },
+]
diff --git a/packages/status/src/core/config.ts b/packages/status/src/core/config.ts
index 20364ad3..5ca0722b 100644
--- a/packages/status/src/core/config.ts
+++ b/packages/status/src/core/config.ts
@@ -1,19 +1,22 @@
-import { existsSync, readFileSync } from "node:fs"
-import { homedir } from "node:os"
-import { join } from "node:path"
+import {
+  baySettings,
+  noticeText,
+  type Settings,
+  type SettingsNotice,
+  type SettingsWhere,
+} from "@opencode-cockpit/client/settings"
 
 /**
- * Statusline settings, read from the same two files every cockpit bay uses:
+ * Status's settings: the `status` section of the files every cockpit bay reads, through the one
+ * loader in `@opencode-cockpit/client/settings`:
  *
  *   ~/.config/opencode-cockpit/config.json  →  /.cockpit.json  →  plugin-entry options
  *
- * Only the `statusline` section is read here. An unreadable or invalid file is ignored rather than
- * fatal — a typo in a config should never cost you the interface.
+ * Only `status` is read. `statusline` (the section's name until 0.9) and keys at the file's root are
+ * old names: the loader recognises them and the bay draws a `!` row for each, but their values are
+ * not read. A file that cannot be parsed is a notice too, never the end of the interface.
  */
 
-export const CONFIG_FILE = "config.json"
-export const PROJECT_FILE = ".cockpit.json"
-
 /**
  * Where a line is drawn.
  *
@@ -23,6 +26,9 @@ export const PROJECT_FILE = ".cockpit.json"
  */
 export type Surface = "bottom" | "sidebar"
 
+/** Where Status draws when nothing says otherwise. The sidebar since 0.9. */
+export const DEFAULT_SURFACE: Surface = "sidebar"
+
 /**
  * A segment is either a built-in named by string ("cwd"), or that name with settings. `when` and
  * `priority` are what make a line survive a narrow terminal instead of wrapping into noise.
@@ -74,7 +80,7 @@ export interface LineConfig {
   icons?: boolean
   /** Draw a placeholder where a segment said nothing, so a typo and missing data look different. */
   debug?: boolean
-  /** Vertical only: rows to draw at most. Lowest priority goes first. Defaults to 8. */
+  /** Vertical only: rows to draw at most. Lowest priority goes first. The bay's `sidebarRows` by default. */
   maxRows?: number
   /**
    * Columns of space either side. The defaults line each surface up with OpenCode's own
@@ -87,8 +93,14 @@ export interface LineConfig {
   paddingBottom?: number
 }
 
+/** The `status` section. */
 export interface StatusConfig {
   enabled?: boolean
+  /**
+   * Draw in the sidebar: the key every bay shares. `false` is read as `surface: "bottom"`, so the
+   * one switch works here as it does everywhere; `surface` says the same thing in Status's words.
+   */
+  sidebar?: boolean
   /**
    * A whole line by name: `minimal`, `default`, `detailed`, `sidebar`. Anything you write
    * alongside it wins, so a preset is a starting point rather than a mode.
@@ -96,14 +108,6 @@ export interface StatusConfig {
   preset?: string
   /** One line, for the common case. Use `lines` for more than one surface. */
   surface?: Surface
-  /**
-   * Where this bay's block sits among the others in a shared surface. Lower draws first.
-   *
-   * The sidebar holds whatever bays you have installed, in the order they registered — which until
-   * now was a constant nobody could reach: shells above the statusline, whatever you would rather
-   * see. Defaults to 200, and Shell's is 150.
-   */
-  sidebarOrder?: number
   segments?: (string | SegmentConfig)[]
   separator?: string
   stack?: Stack
@@ -112,11 +116,10 @@ export interface StatusConfig {
   /** Draw a placeholder where a segment said nothing, so a typo and missing data look different. */
   debug?: boolean
   /**
-   * Vertical lines: rows to draw at most. Settable here as well as per line, because writing it
-   * here is the natural guess and having it quietly ignored costs exactly the rows it was meant
-   * to keep.
+   * Rows a column draws at most: the name every bay's sidebar block uses. Inside `lines`, a line's
+   * own cap is still `maxRows`.
    */
-  maxRows?: number
+  sidebarRows?: number
   paddingLeft?: number
   paddingRight?: number
   paddingTop?: number
@@ -131,80 +134,119 @@ export interface StatusConfig {
   modules?: string[]
 }
 
-export interface CockpitStatusConfig {
-  statusline?: StatusConfig
+/**
+ * The kind of value each key takes, as the loader checks it: a value of another kind is dropped
+ * with a `!` row naming the key, where it used to reach the renderer and fail there — or nowhere.
+ */
+const KINDS = {
+  preset: "",
+  surface: "",
+  segments: [] as unknown[],
+  separator: "",
+  stack: "",
+  icons: true,
+  debug: false,
+  paddingLeft: 0,
+  paddingRight: 0,
+  paddingTop: 0,
+  paddingBottom: 0,
+  lines: [] as unknown[],
+  commands: {} as Record,
+  modules: [] as unknown[],
 }
 
-export function globalConfigPath(env: Record = process.env): string {
-  const base = env.XDG_CONFIG_HOME ?? join(env.HOME ?? homedir(), ".config")
-  return join(base, "opencode-cockpit", CONFIG_FILE)
+const SURFACES: readonly string[] = ["bottom", "sidebar"]
+
+export interface LoadedStatus {
+  /** Every source merged, as written: the gaps are `resolveLines`'s to fill. */
+  config: StatusConfig
+  /** The block's place in the sidebar, from the top-level `sidebar` list. */
+  order: number
+  /**
+   * What to fix, one `!` row each: Status's own settings, and — because Status is the one bay every
+   * install draws — the notices that belong to no bay (a file that would not parse, a top-level
+   * name nothing reads, an entry in the `sidebar` list that is not a bay).
+   */
+  notices: string[]
+  settings: Settings
 }
 
-/** Reads and merges every source. `options` is the plugin entry's own options object. */
-export function loadStatusConfig(
-  directory: string,
-  options?: unknown,
-  env: Record = process.env,
-): StatusConfig {
-  return mergeStatus(
-    mergeStatus(readStatusFile(globalConfigPath(env)), readStatusFile(join(directory, PROJECT_FILE))),
-    asStatusConfig(options),
-  )
+export interface StatusInput {
+  /** The plugin entry's options: the section's own keys, or a whole config with a `status` section. */
+  options?: unknown
+  where?: SettingsWhere
 }
 
-export function readStatusFile(path: string): StatusConfig {
-  if (!existsSync(path)) return {}
-  try {
-    return asStatusConfig(JSON.parse(readFileSync(path, "utf8")))
-  } catch {
-    return {}
+/** Reads and merges every source. Never throws. */
+export function loadStatus(input: StatusInput = {}): LoadedStatus {
+  const loaded = baySettings("status", KINDS, { options: input.options, 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 = [...cockpitNoticesOf(loaded.settings), ...loaded.notices].map(noticeText)
+  return {
+    config,
+    order: loaded.order,
+    notices: [...own, ...configNotices(config)],
+    settings: loaded.settings,
   }
 }
 
-/**
- * Section-wise merge. `segments` is replaced rather than concatenated: a project that lists its
- * own segments means "this line", not "these as well as the global ones".
- */
-export function mergeStatus(base: StatusConfig, over: StatusConfig): StatusConfig {
-  const merged: StatusConfig = { ...base, ...over }
-  if (base.commands || over.commands) merged.commands = { ...base.commands, ...over.commands }
-  // Modules add up: a project can bring its own segments without losing the ones you use everywhere.
-  if (base.modules || over.modules) merged.modules = [...(base.modules ?? []), ...(over.modules ?? [])]
-  return merged
+/** Every source merged, as written. */
+export function loadStatusConfig(directory: string, options?: unknown, env = process.env): StatusConfig {
+  return loadStatus({ options, where: { directory, env } }).config
+}
+
+const cockpitNoticesOf = (settings: Settings): SettingsNotice[] =>
+  settings.notices.filter((notice) => notice.bay === "cockpit")
+
+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
 }
 
 /**
- * Accepts either a whole cockpit config (`{ statusline: {...} }`) or the statusline section on its
- * own, because plugin-entry options are written straight onto the `tui.json` entry.
+ * 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 asStatusConfig(input: unknown): StatusConfig {
-  if (!input || typeof input !== "object") return {}
-  const raw = input as Record
-  const section = raw.statusline ?? (raw.status as unknown)
-  if (section && typeof section === "object") return section as StatusConfig
-  const own: StatusConfig = {}
-  for (const key of [
-    "enabled",
-    "preset",
-    "surface",
-    "segments",
-    "separator",
-    "stack",
-    "icons",
-    "debug",
-    "maxRows",
-    "paddingLeft",
-    "paddingRight",
-    "paddingTop",
-    "paddingBottom",
-    "lines",
-    "commands",
-    "modules",
-    "sidebarOrder",
-  ] as const) {
-    if (raw[key] !== undefined) Object.assign(own, { [key]: raw[key] })
+export function configNotices(config: StatusConfig): string[] {
+  const out: string[] = []
+  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}]`
+    /** The name first: in a 24-column sidebar it is what survives the wrap. */
+    if (typeof line.preset === "string" && !PRESETS[line.preset]) {
+      out.push(`settings: no preset "${line.preset}" (${names})`)
+    }
+    if (line.surface !== undefined && !SURFACES.includes(line.surface)) {
+      out.push(`settings: "${where}.surface" is "sidebar" or "bottom"`)
+    }
   }
-  return own
+  return out
 }
 
 /**
@@ -238,6 +280,40 @@ export const DEFAULT_SEGMENTS: (string | SegmentConfig)[] = [
 
 export const DEFAULT_SEPARATOR = " │ "
 
+/**
+ * The sidebar's column: a table. A heading, the window as one solid bar, the tokens broken into named
+ * rows, a proxy's budget, and the branch's whole diff.
+ *
+ * It is the layout a user arrived at after five rejected iterations, and the reasons are worth more
+ * than the rows: every number gets a word, the labels are a fixed column so the values line up, the
+ * bar is solid rather than dashed, and the groups are separated by hairlines rather than headings — a
+ * heading cannot know whether the rows under it will draw. A row whose figure is zero is not drawn,
+ * the budget rows say nothing without a proxy, and a hairline with nothing on one side of it goes too.
+ *
+ * It sits beside OpenCode's own Context block and says it better; turn that one off with
+ * `{ "plugin_enabled": { "internal:sidebar-context": false } }` in OpenCode's `tui.json`.
+ */
+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: "" },
+  { type: "tokens", style: "row", icon: "" },
+  "in",
+  "out",
+  "cache",
+  "write",
+  "sep",
+  "spend",
+  "avail",
+  "sep",
+  "git",
+]
+
 /**
  * Whole lines, by the name of what you want.
  *
@@ -247,7 +323,7 @@ export const DEFAULT_SEPARATOR = " │ "
  */
 export const PRESETS: Record<
   string,
-  { about: string; surface: Surface; segments: (string | SegmentConfig)[] }
+  { about: string; surface: Surface; segments: (string | SegmentConfig)[]; maxRows?: number }
 > = {
   minimal: {
     about: "how full the context is, and what changed",
@@ -280,26 +356,17 @@ export const PRESETS: Record<
     ],
   },
   sidebar: {
-    about: "a quiet column beside OpenCode's own blocks",
+    about: "a table: the window, where the tokens went, a proxy's budget, the branch's diff",
     surface: "sidebar",
-    segments: [
-      { type: "context", style: "bar", width: 14, icon: "" },
-      /**
-       * Why it stalled — `retry 2 in 5s` — which OpenCode shows as a spinner and nothing more. It is
-       * the reason this bay exists, so it outranks everything but the bar when rows run out; under
-       * the bar rather than above it, so a row that comes and goes does not move the bar about.
-       */
-      { type: "session.status", priority: 95 },
-      { type: "tokens", format: "tk {total}", icon: "" },
-      { type: "tokens", format: "cache {cacheRead}", icon: "" },
-      { type: "git.diff", icon: "" },
-      { type: "session.time", of: "turn", icon: "" },
-      "todo",
-      "diagnostics",
-    ],
+    segments: SIDEBAR_SEGMENTS,
+    /** Every row it has, on a busy session with a budget: the table is the point of it. */
+    maxRows: 14,
   },
 }
 
+/** What a line draws when it names no preset and lists no segments: the surface's own. */
+const PRESET_FOR: Record = { sidebar: "sidebar", bottom: "default" }
+
 export interface ResolvedLine {
   surface: Surface
   segments: (string | SegmentConfig)[]
@@ -323,6 +390,9 @@ const PADDING: Record {
     // A preset fills in what was not written; it never overrides what was.
-    const preset = PRESETS[line.preset ?? config.preset ?? ""]
-    const surface = line.surface ?? config.surface ?? preset?.surface ?? "bottom"
+    const named = PRESETS[line.preset ?? config.preset ?? ""]
+    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 preset = named ?? PRESETS[PRESET_FOR[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 {
@@ -350,7 +424,7 @@ export function resolveLines(config: StatusConfig): ResolvedLine[] {
       segments: line.segments ?? config.segments ?? preset?.segments ?? DEFAULT_SEGMENTS,
       separator: line.separator ?? config.separator ?? (stack === "vertical" ? "" : DEFAULT_SEPARATOR),
       stack,
-      maxRows: line.maxRows ?? config.maxRows ?? 8,
+      maxRows: line.maxRows ?? config.sidebarRows ?? preset?.maxRows ?? MAX_ROWS,
       icons: line.icons ?? config.icons ?? true,
       debug: line.debug ?? config.debug ?? false,
       paddingLeft: line.paddingLeft ?? config.paddingLeft ?? PADDING[surface].left,
diff --git a/packages/status/src/core/context.ts b/packages/status/src/core/context.ts
index 2148c070..d436b1e0 100644
--- a/packages/status/src/core/context.ts
+++ b/packages/status/src/core/context.ts
@@ -4,6 +4,7 @@
  * OpenCode to draw it in.
  */
 
+import type { Budget } from "./budget.ts"
 import type { DiffCounts } from "./diff.ts"
 
 export interface TokenCounts {
@@ -103,6 +104,13 @@ export interface StatusContext {
    * the second renders zeros.
    */
   diff?: DiffCounts
+  /**
+   * The branch's whole diff against where it forked from the default branch: every commit on it plus
+   * what is uncommitted — what a reviewer will read. Absent until git answers, and outside a repo.
+   */
+  branchDiff?: DiffCounts
+  /** What a proxy reports spending against its cap. Absent when no proxy writes one. */
+  budget?: Budget
   session?: SessionSnapshot
   lsp: ServiceSnapshot[]
   mcp: ServiceSnapshot[]
diff --git a/packages/status/src/core/custom.ts b/packages/status/src/core/custom.ts
index b1353714..5a4ab14a 100644
--- a/packages/status/src/core/custom.ts
+++ b/packages/status/src/core/custom.ts
@@ -1,4 +1,4 @@
-import { rmSync } from "node:fs"
+import { existsSync, rmSync } from "node:fs"
 import { homedir } from "node:os"
 import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path"
 import { pathToFileURL } from "node:url"
@@ -159,8 +159,24 @@ export async function loadCustomSegments(
         })
       }
     } catch (err) {
-      errors.push(`${path}: ${err instanceof Error ? err.message : String(err)}`)
+      errors.push(
+        `${path}: ${existsSync(full) ? (err instanceof Error ? err.message : String(err)) : missing(full)}`,
+      )
     }
   }
   return { segments, errors }
 }
+
+/**
+ * The sidebar examples 0.9 removed when the `sidebar` preset became the table they built toward. A
+ * config that still points at one in the package gets a sentence that says what replaced it, rather
+ * than a resolver's stack of paths.
+ */
+const REMOVED_EXAMPLES = new Set(["sidebar.ts", "sidebar-full.ts", "sidebar-budget.ts"])
+
+/** What a config pointing at one of them is told — in the brief, the log and the `!` row. */
+export const REMOVED_EXAMPLE = `removed in 0.9: use "preset": "sidebar"`
+
+function missing(full: string): string {
+  return REMOVED_EXAMPLES.has(basename(full)) ? REMOVED_EXAMPLE : "no file there"
+}
diff --git a/packages/status/src/core/diff.ts b/packages/status/src/core/diff.ts
index ac430e24..804977a5 100644
--- a/packages/status/src/core/diff.ts
+++ b/packages/status/src/core/diff.ts
@@ -56,3 +56,23 @@ export function wantsDiff(
     }),
   )
 }
+
+/**
+ * The branch's whole diff: from where it forked off `base` to the working tree — every commit on the
+ * branch plus what is uncommitted, which is what a reviewer will read rather than what this session
+ * happened to touch. On the default branch itself the fork point is `HEAD`, so it is what is
+ * uncommitted. A base that does not resolve fails the command, and the row keeps saying nothing.
+ */
+export function branchDiffCommand(base: string): string {
+  const quoted = `'${base.replaceAll("'", "'\\''")}'`
+  return `git diff --shortstat "$(git merge-base ${quoted} HEAD)"`
+}
+
+/** Whether any line draws the branch's diff (`git`), so no git runs for a row nobody shows. */
+export function wantsBranchDiff(
+  lines: ReadonlyArray<{ segments: ReadonlyArray }>,
+): boolean {
+  return lines.some((line) =>
+    line.segments.some((segment) => (typeof segment === "string" ? segment : segment.type) === "git"),
+  )
+}
diff --git a/packages/status/src/core/fixtures.ts b/packages/status/src/core/fixtures.ts
index 88c1f96d..5a2bf7b7 100644
--- a/packages/status/src/core/fixtures.ts
+++ b/packages/status/src/core/fixtures.ts
@@ -46,6 +46,8 @@ const base = (over: Partial = {}): StatusContext => ({
   home: "/Users/you",
   branch: "feature/checkout",
   defaultBranch: "main",
+  /** The branch against where it forked: more than this session touched, and there with no session. */
+  branchDiff: { files: 5, additions: 312, deletions: 48 },
   version: "0.3.0",
   lsp: [{ name: "tsserver", status: "connected" }],
   mcp: [{ name: "github", status: "connected" }],
@@ -99,6 +101,7 @@ export const FIXTURES: Record 0
       ? ["- Failing to load:", ...report.modules.errors.map((error) => `  - ${error}`)]
       : []),
+    ...(report.notices.length > 0
+      ? [
+          "- Settings to fix first (each is a `!` row in the sidebar, and its value is not read):",
+          ...report.notices.map((notice) => `  - ${notice}`),
+        ]
+      : []),
     `- Version: ${report.version}`,
     "",
     "## What you can change",
     "",
-    'The config file holds `{ "statusline": { ... } }`. Two surfaces: `bottom` (a line under the',
-    "prompt) and `sidebar` (a column). A whole line by name with `preset`, then anything written",
-    "beside it wins:",
+    'The config file holds `{ "status": { ... } }` — that section only; `"statusline"` and keys at the',
+    "file's root are old names that are no longer read. Comments and trailing commas are fine.",
+    `Two surfaces: \`sidebar\` (a column, the default) and \`bottom\` (a line under the prompt);`,
+    '`"sidebar": false` means the bottom too. `"sidebarRows"` caps the rows a column draws. The',
+    'order of the sidebar\'s blocks is the top-level `"sidebar"` list, not a key in this section.',
+    "A whole line by name with `preset`, then anything written beside it wins:",
     "",
     ...Object.entries(PRESETS).map(
-      ([name, preset]) => `- \`"preset": "${name}"\` — ${preset.about} (${preset.surface})`,
+      ([name, preset]) =>
+        `- \`"preset": "${name}"\` — ${preset.about} (${preset.surface}${preset.surface === DEFAULT_SURFACE ? ", the default" : ""})`,
     ),
     "",
     `Built-in segment names: ${BUILTINS.map((segment) => segment.name).join(", ")}.`,
     'A segment can also be `{ "type": "...", ... }` with its own settings, or a shell command.',
+    "`spend` and `avail` read a budget a proxy writes; with no proxy they draw nothing.",
     "",
     "For anything the built-ins do not cover, write a TypeScript module and list it in `modules`:",
     "it exports `{ segments: { name(ctx, config) { return { runs: [...] } } } }` against",
diff --git a/packages/status/src/core/notices.ts b/packages/status/src/core/notices.ts
index 707dfa16..12983a74 100644
--- a/packages/status/src/core/notices.ts
+++ b/packages/status/src/core/notices.ts
@@ -2,22 +2,21 @@
  * Rows the line draws about itself.
  *
  * Everything else here follows the rule that a segment with nothing to say says nothing — which is
- * right for data and wrong for the line's own failures. A module that would not load and a column
- * that ran out of rows both end as *segments that are simply not there*, indistinguishable from a
- * segment that had nothing to report, and the only notice either one gets is a toast that is gone
- * in ten seconds. Both cost a debugging session to tell apart from a bug in the module itself.
+ * right for data and wrong for the line's own failures. A module that would not load, a setting that
+ * is no longer read and a column that ran out of rows all end as *segments that are simply not
+ * there*, indistinguishable from a segment that had nothing to report, and the only notice any of
+ * them got was a toast that is gone in ten seconds. Each cost a debugging session to tell apart from
+ * a bug in the module itself.
  *
  * So they get a row. It costs one line of the design and it says what happened where the person is
  * already looking.
  */
 
-import { GLYPH } from "@opencode-cockpit/client/design"
-import { truncate } from "./format.ts"
+import { warnRows } from "@opencode-cockpit/client/design"
+import { REMOVED_EXAMPLE } from "./custom.ts"
+import { basename } from "./format.ts"
 import type { Segment } from "./types.ts"
 
-/** Long enough to name the module, short enough for a sidebar column. */
-const NOTICE = 30
-
 /**
  * A column could not draw every row it was given. The preview has always said so; without this the
  * running TUI just left them out, and a row that never appears reads as a broken segment.
@@ -27,17 +26,35 @@ export function overflowNotice(dropped: number): Segment | undefined {
   return {
     id: "notice.rows",
     priority: 0,
-    runs: [{ text: `↳ ${dropped} more — raise maxRows`, tone: "muted", dim: true }],
+    runs: [{ text: `↳ ${dropped} more — raise sidebarRows`, tone: "muted", dim: true }],
   }
 }
 
 /**
- * A module did not load, so none of its segments exist. Named rather than counted where there is
- * only one, because the name is what turns "nothing drew" into something to go and fix.
+ * A module that did not load, as a notice: the file's name rather than the path written in the
+ * config, which is the part a sidebar has room for and the part you go and fix.
+ */
+export function moduleNoticeText(error: string): string {
+  const at = error.indexOf(": ")
+  if (at === -1) return `module ${error}`
+  const file = basename(error.slice(0, at))
+  const why = error.slice(at + 2)
+  /** One of the examples 0.9 folded into the `sidebar` preset: the fix, in as few words as fit. */
+  if (why === REMOVED_EXAMPLE) return `${file} was ${why}`
+  return `module ${file}: ${why}`
+}
+
+/**
+ * Every notice, as the rows a surface draws: `!` in the warning tone, each sentence wrapped to the
+ * room there is (`warnRows`), so a 24-column sidebar keeps the fix the sentence ends with. Above the
+ * line and outside its row cap — a notice never pushes out a row you asked for, nor gives way to one.
  */
-export function moduleNotice(errors: readonly string[], width = NOTICE): Segment | undefined {
-  if (errors.length === 0) return undefined
-  const text =
-    errors.length === 1 ? truncate(errors[0] as string, width) : `${errors.length} modules failed to load`
-  return { id: "notice.module", priority: 1000, runs: [{ text: `${GLYPH.warn} ${text}`, tone: "error" }] }
+export function noticeRows(notices: readonly string[], width: number): Segment[] {
+  return notices.flatMap((text, n) =>
+    warnRows(text, width).map((runs, row) => ({
+      id: `notice.${n}.${row}`,
+      priority: 1000,
+      runs: runs.map((run) => ({ text: run.text, ...(run.tone ? { tone: run.tone } : {}) })),
+    })),
+  )
 }
diff --git a/packages/status/src/core/render.ts b/packages/status/src/core/render.ts
index 0d0ab57a..ced59714 100644
--- a/packages/status/src/core/render.ts
+++ b/packages/status/src/core/render.ts
@@ -1,4 +1,4 @@
-import { cutSegment, type Segment, segmentWidth } from "./segments.ts"
+import { cutSegment, type Segment, segmentWidth, tidyDividers } from "./segments.ts"
 
 /**
  * Fitting the line to the terminal.
@@ -73,8 +73,10 @@ export function fitColumn(segments: readonly Segment[], width: number, maxRows:
       .slice(0, maxRows)
       .map(({ segment }) => segment.id),
   )
+  /** Rows dropped can strand a hairline; it goes too, and is not counted as a row you lost. */
+  const kept = tidyDividers(segments.filter((segment) => keep.has(segment.id)))
   return {
-    segments: segments.filter((segment) => keep.has(segment.id)).map((segment) => cutSegment(segment, width)),
+    segments: kept.map((segment) => cutSegment(segment, width)),
     dropped: segments.length - keep.size,
   }
 }
diff --git a/packages/status/src/core/report.ts b/packages/status/src/core/report.ts
index 4c48f3b0..06f031a1 100644
--- a/packages/status/src/core/report.ts
+++ b/packages/status/src/core/report.ts
@@ -4,14 +4,14 @@
  * The bay's own rule is that a segment with nothing to say says nothing, which is right on screen
  * and leaves exactly one question unanswerable from the screen itself: is this line quiet because
  * there is nothing to report, or because the config never arrived? This is the answer — which files
- * were read, which surfaces are drawing, how many segments each carries, and what a module did when
- * it failed to load. The `/statusline` command draws it; it lives here because it is a pure
- * function of the settings, and so can be tested without a terminal.
+ * were read, which surfaces are drawing, how many segments each carries, what a module did when it
+ * failed to load, and which settings are no longer read. `/status-setup` hands it to the agent; it
+ * lives here because it is a pure function of the settings, and so can be tested without a terminal.
  */
 
 import { existsSync } from "node:fs"
-import { join } from "node:path"
-import { globalConfigPath, PROJECT_FILE, type ResolvedLine } from "./config.ts"
+import { settingsPaths } from "@opencode-cockpit/client/settings"
+import type { ResolvedLine } from "./config.ts"
 
 export interface ReportLine {
   surface: string
@@ -34,24 +34,32 @@ export interface StatusReport {
     /** One per module that would not load, already phrased for a human. */
     errors: string[]
   }
+  /** Settings to fix, as the `!` rows say them: an old name, a key nothing reads, a file that would not parse. */
+  notices: string[]
 }
 
 export interface ReportInput {
   version: string
   directory: string
+  /** The files the loader looked for, global first. Looked up again when not given. */
+  files?: readonly { path: string; found: boolean }[]
   lines: readonly ResolvedLine[]
   modules?: readonly string[]
   registered: number
   errors: readonly string[]
+  notices?: readonly string[]
   env?: Record
 }
 
 export function buildReport(input: ReportInput): StatusReport {
-  const global = globalConfigPath(input.env ?? process.env)
-  const project = join(input.directory, PROJECT_FILE)
+  const env = input.env ?? process.env
+  const paths = settingsPaths({ directory: input.directory, env, ...(env.HOME ? { home: env.HOME } : {}) })
+  const sources =
+    input.files?.map(({ path, found }) => ({ path, found })) ??
+    [paths.global, paths.project ?? ""].map((path) => ({ path, found: existsSync(path) }))
   return {
     version: input.version,
-    sources: [global, project].map((path) => ({ path, found: existsSync(path) })),
+    sources,
     lines: input.lines.map((line) => ({
       surface: line.surface,
       stack: line.stack,
@@ -63,6 +71,7 @@ export function buildReport(input: ReportInput): StatusReport {
       registered: input.registered,
       errors: [...input.errors],
     },
+    notices: [...(input.notices ?? [])],
   }
 }
 
@@ -76,6 +85,10 @@ export function reportHeadline(report: StatusReport): string {
     return `${count} module${count === 1 ? "" : "s"} failed to load — segments from ${count === 1 ? "it" : "them"} are missing`
   }
   if (report.lines.length === 0) return "Nothing is drawing: no line is configured"
+  if (report.notices.length > 0) {
+    const count = report.notices.length
+    return `${count} setting${count === 1 ? "" : "s"} to fix — the line draws without ${count === 1 ? "it" : "them"}`
+  }
   if (report.sources.every((source) => !source.found)) {
     return "Drawing the defaults — neither config file exists yet"
   }
diff --git a/packages/status/src/core/segments.ts b/packages/status/src/core/segments.ts
index d3c5863a..29a9834b 100644
--- a/packages/status/src/core/segments.ts
+++ b/packages/status/src/core/segments.ts
@@ -156,11 +156,27 @@ export function buildSegments(
         id: count === 1 ? config.type : `${config.type}#${count}`,
         runs: styled,
         priority: typeof config.priority === "number" ? config.priority : def.priority,
+        ...(def.divider ? { divider: true } : {}),
       })
       drew = true
     }
     if (!drew && debug) out.push(marker(config.type, "border", config.type, seen))
   }
+  return tidyDividers(out)
+}
+
+/**
+ * Hairlines only between rows. A divider groups what is above it from what is below; with nothing on
+ * one side — the budget rows silent without a proxy, the first reply not in yet — it is a line of
+ * ink that says nothing, and two in a row read as a rendering fault.
+ */
+export function tidyDividers(segments: readonly Segment[]): Segment[] {
+  const out: Segment[] = []
+  for (const segment of segments) {
+    if (segment.divider && (out.length === 0 || out[out.length - 1]?.divider)) continue
+    out.push(segment)
+  }
+  while (out[out.length - 1]?.divider) out.pop()
   return out
 }
 
diff --git a/packages/status/src/core/types.ts b/packages/status/src/core/types.ts
index 809eaa7c..17d266b1 100644
--- a/packages/status/src/core/types.ts
+++ b/packages/status/src/core/types.ts
@@ -48,6 +48,8 @@ export interface Segment {
   runs: Run[]
   /** Higher survives when the line is too long for the terminal. */
   priority: number
+  /** A hairline between groups: drawn only with a row on either side of it (`tidyDividers`). */
+  divider?: boolean
 }
 
 /** What a segment may return: one styled string, or several runs. */
@@ -73,5 +75,7 @@ export interface SegmentDef {
   priority: number
   /** Shown before the text when icons are on. */
   icon?: string
+  /** It separates groups rather than saying anything; see `Segment.divider`. */
+  divider?: boolean
   render(ctx: StatusContext, config: SegmentConfig): Pieces | undefined
 }
diff --git a/packages/status/src/tui/components/statusline.tsx b/packages/status/src/tui/components/statusline.tsx
index a6bb7e5b..95a1b624 100644
--- a/packages/status/src/tui/components/statusline.tsx
+++ b/packages/status/src/tui/components/statusline.tsx
@@ -2,6 +2,7 @@
 
 import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
 import type { Host } from "@opencode-cockpit/client/host"
+import type { BoxRenderable } from "@opentui/core"
 import type { JSX } from "solid-js"
 import { For, Show } from "solid-js"
 import type { Run, Segment, Tone } from "../../core/segments.ts"
@@ -68,6 +69,10 @@ function decorate(run: Run): JSX.Element {
 export interface StatusLineProps {
   api: Host
   segments: () => Segment[]
+  /** Rows above the line about the line itself — a setting to fix, a module that would not load. */
+  notices?: () => Segment[]
+  /** The line's box, once laid out, so a column can measure the room the host gave it. */
+  onReady?: (box: BoxRenderable) => void
   separator: string
   /** Across the window, or down a column. */
   stack?: "horizontal" | "vertical"
@@ -77,43 +82,52 @@ export interface StatusLineProps {
   paddingBottom?: number
 }
 
+/** One segment's runs, as one row of styled text. */
+function Runs(props: { theme: TuiThemeCurrent; segment: Segment }) {
+  return (
+    
+      
+        {(run) => (
+          {decorate(run)}}>
+            
+              {run.text}
+            
+          
+        )}
+      
+    
+  )
+}
+
 export function StatusLine(props: StatusLineProps) {
   const theme = () => props.api.theme.current
   const down = () => props.stack === "vertical"
   return (
      props.onReady?.(box)}
+      flexDirection="column"
       flexShrink={0}
       paddingLeft={props.paddingLeft ?? 1}
       paddingRight={props.paddingRight ?? 1}
       paddingTop={props.paddingTop ?? 0}
       paddingBottom={props.paddingBottom ?? 0}
     >
-      
-        {(segment, index) => (
-          <>
-             0 && !down() && props.separator.length > 0}>
-              
-                {props.separator}
-              
-            
-            
-              
-                {(run) => (
-                  {decorate(run)}}
-                  >
-                    
-                      {run.text}
-                    
-                  
-                )}
-              
-            
-          
-        )}
-      
+      {/* Their own rows, whichever way the line reads: a sentence does not fit between two segments. */}
+      {(notice) => }
+      
+        
+          {(segment, index) => (
+            <>
+               0 && !down() && props.separator.length > 0}>
+                
+                  {props.separator}
+                
+              
+              
+            
+          )}
+        
+      
     
   )
 }
diff --git a/packages/status/src/tui/index.tsx b/packages/status/src/tui/index.tsx
index a48a71c5..bb08eed1 100644
--- a/packages/status/src/tui/index.tsx
+++ b/packages/status/src/tui/index.tsx
@@ -2,13 +2,13 @@
 
 import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client/feature"
 import { dualTui, type Host } from "@opencode-cockpit/client/host"
-import { sidebarOrder } from "@opencode-cockpit/client/sidebar"
+import type { BoxRenderable } from "@opentui/core"
 import { createMemo } from "solid-js"
 import pkg from "../../package.json" with { type: "json" }
-import { asSegmentConfig, loadStatusConfig, type ResolvedLine, resolveLines } from "../core/config.ts"
+import { asSegmentConfig, loadStatus, type ResolvedLine, resolveLines } from "../core/config.ts"
 import { loadCustomSegments } from "../core/custom.ts"
-import { statuslineBrief } from "../core/instructions.ts"
-import { moduleNotice, overflowNotice } from "../core/notices.ts"
+import { OLD_SLASH, SETUP_SLASH, statusSetupBrief } from "../core/instructions.ts"
+import { moduleNoticeText, noticeRows, overflowNotice } from "../core/notices.ts"
 import { fit, fitColumn } from "../core/render.ts"
 import { buildReport } from "../core/report.ts"
 import { buildSegments, type SegmentDef } from "../core/segments.ts"
@@ -27,7 +27,7 @@ export function createStatusTui({ source = STATUS_PACKAGE }: { source?: string }
       api.ui.toast({
         variant: "warning",
         title: "opencode-cockpit",
-        message: duplicateFeatureMessage("Statusline", claim.owner, source),
+        message: duplicateFeatureMessage("Status", claim.owner, source),
         duration: 10_000,
       })
       return
@@ -35,7 +35,8 @@ export function createStatusTui({ source = STATUS_PACKAGE }: { source?: string }
     api.lifecycle.onDispose(() => claim.release())
 
     const directory = api.state.path.directory
-    const config = loadStatusConfig(directory, rawOptions)
+    const { config, order, notices, settings } = loadStatus({ options: rawOptions, where: { directory } })
+    for (const notice of notices) api.log.warn("status: settings", { notice })
     if (config.enabled === false) return
 
     // Your own segments, loaded before the first draw so they are never missing from frame one.
@@ -54,12 +55,12 @@ export function createStatusTui({ source = STATUS_PACKAGE }: { source?: string }
       moduleErrors.push(...loaded.errors)
       for (const error of loaded.errors) {
         api.log.error("status: module failed to load", { error })
-        api.ui.toast({ variant: "error", title: "Statusline", message: error, duration: 10_000 })
+        api.ui.toast({ variant: "error", title: "Status", message: error, duration: 10_000 })
         void api.v1?.client.app
           .log({
             service: "opencode-cockpit.status",
             level: "error",
-            message: `statusline module failed to load: ${error}`,
+            message: `status module failed to load: ${error}`,
           })
           .catch(() => {})
       }
@@ -70,25 +71,49 @@ export function createStatusTui({ source = STATUS_PACKAGE }: { source?: string }
     api.lifecycle.onDispose(() => store.dispose())
 
     /**
-     * The line's own failures, drawn once rather than on every surface. A module that would not
-     * load has no segments to be missing from, so without a row of its own the only notice is a
-     * toast that is gone in ten seconds — and the log, which nobody reads while looking at a line
-     * that seems to have quietly done nothing.
+     * The line's own trouble — a setting no longer read, a file that would not parse, a module that
+     * would not load — drawn once, as `!` rows above the first line, and for the whole session: the
+     * toast is gone in ten seconds and the log is not where anyone looks at a line that seems to have
+     * quietly done nothing. Status draws the notices that belong to no bay, too, being the one bay
+     * every install has.
      */
-    const failure = moduleNotice(moduleErrors)
-    let noticeShown = false
+    const troubles = [...notices, ...moduleErrors.map(moduleNoticeText)]
 
-    /** A line, fitted to the room its surface actually has. */
-    const line = (spec: ResolvedLine, width: () => number) => {
-      const mine = failure && !noticeShown
-      if (mine) noticeShown = true
+    const on = (surface: string) => lines.filter((spec) => spec.surface === surface)
+    const bottom = on("bottom")
+    const sidebar = on("sidebar")
+    /** The sidebar's when there is one: it is where the default draws, and where a notice has room. */
+    const noticeLine = sidebar[0] ?? bottom[0]
+
+    /**
+     * A line, fitted to the room its surface actually has. A sidebar line measures it, as Subagents
+     * and Trust do: the container the host gave it, once laid out, and a guess before that. Guessed,
+     * a notice padded to a quarter of the window ran past the sidebar's edge and lost its last words.
+     */
+    const line = (spec: ResolvedLine, guess: () => number, measure = false) => {
+      let box: BoxRenderable | undefined
+      const width = () => {
+        if (!measure) return guess()
+        /**
+         * The parent, not the line's own box: the box is as wide as its widest row, so a row that
+         * gave up a word for want of room would keep the box — and itself — that narrow for good.
+         */
+        const parent = (box?.parent as { width?: number } | null | undefined)?.width ?? 0
+        return parent >= 12 ? parent : guess()
+      }
+      /** Read on the tick too, so a width the host settles after the first frame is picked up. */
+      const said = createMemo(() => {
+        store.context()
+        return spec === noticeLine ? noticeRows(troubles, width()) : []
+      })
       const segments = createMemo(() => {
-        const built = buildSegments(store.context(), spec.segments.map(asSegmentConfig), {
+        /** The room this line has, rather than the window's: a row with a word to spare uses it. */
+        const ctx = { ...store.context(), width: width() }
+        const built = buildSegments(ctx, spec.segments.map(asSegmentConfig), {
           custom,
           icons: spec.icons,
           debug: spec.debug,
         })
-        if (mine && failure) built.unshift(failure)
         if (spec.stack !== "vertical") return fit(built, width(), spec.separator).segments
         /**
          * Say how many rows did not fit. The preview has always printed `↳ N dropped`; the TUI
@@ -105,6 +130,10 @@ export function createStatusTui({ source = STATUS_PACKAGE }: { source?: string }
          {
+            box = ready
+          }}
           separator={spec.separator}
           stack={spec.stack}
           paddingLeft={spec.paddingLeft}
@@ -116,69 +145,93 @@ export function createStatusTui({ source = STATUS_PACKAGE }: { source?: string }
     }
 
     /**
-     * `/statusline` draws nothing. Customising a line is an editing job in a file the TUI never
+     * `/status-setup` draws nothing. Setting up the line is an editing job in a file the TUI never
      * names, so the useful thing is not a help panel the user then has to act on themselves — it is
      * a brief handed to the agent already in the session, carrying what it cannot look up: which
      * config file this project reads, what is in it now, and what would not load.
      */
+    const brief = () =>
+      statusSetupBrief(
+        buildReport({
+          version: pkg.version,
+          directory,
+          files: settings.files,
+          lines,
+          modules: config.modules,
+          registered: custom.size,
+          errors: moduleErrors,
+          notices,
+        }),
+      )
+    const send = () => {
+      const text = brief()
+      /**
+       * Sent, not left in the prompt. The brief is forty lines; parked in the input it is a wall of
+       * text the user has to scroll past to type their own sentence, and it ends by asking what they
+       * want anyway — so the agent is the right place for it to land.
+       *
+       * On the next tick, because running a slash command clears the prompt it was typed into:
+       * writing during the command itself is wiped a moment later, which looks exactly like a
+       * command that did nothing.
+       */
+      setTimeout(() => {
+        const failed = () => {
+          api.log.warn("status: could not reach the prompt")
+          api.ui.toast({ variant: "error", title: "Status", message: "could not reach the prompt" })
+        }
+        if (api.v1) {
+          const tui = api.v1.client.tui
+          void tui
+            .appendPrompt({ text })
+            .then(() => tui.submitPrompt())
+            .catch(failed)
+          return
+        }
+        /** OpenCode 2: straight to the conversation on screen, there being no prompt to fill. */
+        const session = currentSession(api)
+        if (!session) {
+          api.ui.toast({ title: "Status", message: "Open a conversation first." })
+          return
+        }
+        void api.promptSession(session, text).catch(failed)
+      }, 0)
+    }
+
     api.keymap.registerLayer({
       commands: [
+        {
+          name: "cockpit.status.setup",
+          title: "Ask the agent to set up the status bay",
+          category: "Cockpit · Status",
+          namespace: "palette",
+          slashName: SETUP_SLASH,
+          run: send,
+        },
+        /**
+         * The old name, for one release (removed in 0.10). A second command rather than an alias:
+         * neither OpenCode tells a command which of its names was typed, so only a command of its
+         * own can say it was renamed (docs/opencode/settings-and-commands.md, Part B). It says so,
+         * then does what it always did, so the habit keeps working while it learns the new name.
+         */
         {
           name: "cockpit.status.customise",
-          title: "Ask the agent to customise the statusline",
+          title: `Renamed: use /${SETUP_SLASH}`,
           category: "Cockpit · Status",
           namespace: "palette",
-          slashName: "statusline",
+          slashName: OLD_SLASH,
           run: () => {
-            const brief = statuslineBrief(
-              buildReport({
-                version: pkg.version,
-                directory,
-                lines,
-                modules: config.modules,
-                registered: custom.size,
-                errors: moduleErrors,
-              }),
-            )
-            /**
-             * Sent, not left in the prompt. The brief is forty lines; parked in the input it is a
-             * wall of text the user has to scroll past to type their own sentence, and it ends by
-             * asking what they want anyway — so the agent is the right place for it to land.
-             *
-             * On the next tick, because running a slash command clears the prompt it was typed
-             * into: writing during the command itself is wiped a moment later, which looks exactly
-             * like a command that did nothing.
-             */
-            setTimeout(() => {
-              const failed = () => {
-                api.log.warn("status: could not reach the prompt")
-                api.ui.toast({ variant: "error", title: "Statusline", message: "could not reach the prompt" })
-              }
-              if (api.v1) {
-                const tui = api.v1.client.tui
-                void tui
-                  .appendPrompt({ text: brief })
-                  .then(() => tui.submitPrompt())
-                  .catch(failed)
-                return
-              }
-              /** OpenCode 2: straight to the conversation on screen, there being no prompt to fill. */
-              const session = currentSession(api)
-              if (!session) {
-                api.ui.toast({ title: "Statusline", message: "Open a conversation first." })
-                return
-              }
-              void api.promptSession(session, brief).catch(failed)
-            }, 0)
+            api.ui.toast({
+              variant: "info",
+              title: "Status",
+              message: `/${OLD_SLASH} is now /${SETUP_SLASH} — briefing the agent all the same.`,
+              duration: 8_000,
+            })
+            send()
           },
         },
       ],
     })
 
-    const on = (surface: string) => lines.filter((spec) => spec.surface === surface)
-    const bottom = on("bottom")
-    const sidebar = on("sidebar")
-
     api.slots.register({
       /** After the shell dock (150), so the line sits at the very bottom of the window. */
       order: 200,
@@ -193,15 +246,16 @@ export function createStatusTui({ source = STATUS_PACKAGE }: { source?: string }
       },
     })
     api.slots.register({
-      /**
-       * First in the sidebar by default — statusline, subagents, shells. `"sidebar"` in Cockpit's
-       * config, or `sidebarOrder`, moves it; lower draws first.
-       */
-      order: sidebarOrder("status", 140, config.sidebarOrder, { directory: api.state.path.directory }),
+      /** First of Cockpit's blocks by default; the top-level `sidebar` list moves it. */
+      order,
       slots: {
         // sidebar_content, not sidebar_footer: the host does not draw plugin content in the footer.
         sidebar_content: () => (
-          <>{sidebar.map((spec) => line(spec, () => Math.max(10, Math.floor(api.renderer.width / 4))))}
+          <>
+            {sidebar.map((spec) =>
+              line(spec, () => Math.max(20, Math.min(40, Math.floor(api.renderer.width / 4) - 2)), true),
+            )}
+          
         ),
       },
     })
diff --git a/packages/status/src/tui/state/snapshot.ts b/packages/status/src/tui/state/snapshot.ts
index e5949e5d..5923e666 100644
--- a/packages/status/src/tui/state/snapshot.ts
+++ b/packages/status/src/tui/state/snapshot.ts
@@ -1,6 +1,7 @@
 import { homedir } from "node:os"
 import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
 import type { Host, V2Context } from "@opencode-cockpit/client/host"
+import type { Budget } from "../../core/budget.ts"
 import {
   lastTurn,
   type SessionSnapshot,
@@ -217,6 +218,8 @@ export function buildContext(
     version: string
     commands: Record
     diff?: DiffCounts
+    branchDiff?: DiffCounts
+    budget?: Budget
   },
 ): StatusContext {
   const sessionID = currentSession(api)
@@ -229,6 +232,8 @@ export function buildContext(
     ...(api.state.vcs?.default_branch ? { defaultBranch: api.state.vcs.default_branch } : {}),
     version: options.version,
     ...(options.diff ? { diff: options.diff } : {}),
+    ...(options.branchDiff ? { branchDiff: options.branchDiff } : {}),
+    ...(options.budget ? { budget: options.budget } : {}),
     ...(sessionID
       ? {
           session: api.v1
diff --git a/packages/status/src/tui/state/store.ts b/packages/status/src/tui/state/store.ts
index e2cef6b6..8111b607 100644
--- a/packages/status/src/tui/state/store.ts
+++ b/packages/status/src/tui/state/store.ts
@@ -1,9 +1,17 @@
 import type { Host } from "@opencode-cockpit/client/host"
 import { type Accessor, createMemo, createRoot, createSignal } from "solid-js"
+import { BUDGET_EVERY_MS, type Budget, budgetFile, readBudget } from "../../core/budget.ts"
 import { type CommandRunner, createRunner, execShell } from "../../core/command.ts"
 import { resolveLines, type StatusConfig } from "../../core/config.ts"
 import type { StatusContext } from "../../core/context.ts"
-import { type DiffCounts, parseShortstat, UNCOMMITTED, wantsDiff } from "../../core/diff.ts"
+import {
+  branchDiffCommand,
+  type DiffCounts,
+  parseShortstat,
+  UNCOMMITTED,
+  wantsBranchDiff,
+  wantsDiff,
+} from "../../core/diff.ts"
 
 /**
  * Keeps one snapshot of OpenCode's state for every line to read. One memo rather than one per
@@ -24,6 +32,8 @@ export interface StoreOptions {
   diffIntervalMs?: number
   /** Injected in tests, so no shell runs and no clock is needed. */
   exec?: (command: string, stdin: string, timeoutMs: number) => Promise
+  /** Injected in tests, so no file is read. */
+  readBudget?: (path: string) => Budget | undefined
   now?: () => number
   build: (
     api: Host,
@@ -33,6 +43,8 @@ export interface StoreOptions {
       version: string
       commands: Record
       diff: DiffCounts | undefined
+      branchDiff: DiffCounts | undefined
+      budget: Budget | undefined
     },
   ) => StatusContext
 }
@@ -65,7 +77,8 @@ export function createStatusStore(api: Host, config: StatusConfig, options: Stor
      * they were built for. `claudeCodeCompat` is off because nothing is being handed a session on
      * stdin — this is one command with one answer.
      */
-    const diffRunner = wantsDiff(resolveLines(config))
+    const lines = resolveLines(config)
+    const diffRunner = wantsDiff(lines)
       ? createRunner(
           {
             run: UNCOMMITTED,
@@ -77,20 +90,51 @@ export function createStatusStore(api: Host, config: StatusConfig, options: Stor
         )
       : undefined
 
+    /**
+     * The branch's whole diff, for `git`. The command names the default branch, which OpenCode may
+     * only learn after the first frame, so the runner is made once it is known — and made again if
+     * it changes. Until then `main`, the likeliest answer.
+     */
+    const branchWanted = wantsBranchDiff(lines)
+    let branch: { base: string; runner: CommandRunner } | undefined
+    const branchRunner = (base: string) => {
+      if (branch?.base === base) return branch.runner
+      branch?.runner.dispose()
+      const runner = createRunner(
+        { run: branchDiffCommand(base), intervalMs: 10_000, timeoutMs: 3000, claudeCodeCompat: false },
+        { exec: options.exec ?? execShell, now: clock, onValue: () => bump((n) => n + 1) },
+      )
+      branch = { base, runner }
+      return runner
+    }
+
+    /** A proxy's budget, for `spend` and `avail`: a tiny file, read again every few seconds. */
+    const budgetPath = budgetFile(lines)
+    let budget: Budget | undefined
+    let budgetAt = Number.NEGATIVE_INFINITY
+
     const context = createMemo(() => {
       commandTick()
+      const at = now()
       const commands: Record = {}
       for (const [name, runner] of runners) commands[name] = runner.value()
+      if (budgetPath && at - budgetAt >= BUDGET_EVERY_MS) {
+        budgetAt = at
+        budget = (options.readBudget ?? readBudget)(budgetPath)
+      }
       const ctx = options.build(api, {
-        now: now(),
+        now: at,
         width: api.renderer.width,
         version: options.version,
         commands,
         diff: diffRunner ? parseShortstat(diffRunner.value()) : undefined,
+        branchDiff: branch ? parseShortstat(branch.runner.value()) : undefined,
+        budget,
       })
       // Asking here keeps the schedule tied to what is actually drawn: a hidden line costs nothing.
       for (const runner of runners.values()) runner.maybeRun(ctx)
       diffRunner?.maybeRun(ctx)
+      if (branchWanted) branchRunner(ctx.defaultBranch ?? "main").maybeRun(ctx)
       return ctx
     })
 
@@ -99,6 +143,7 @@ export function createStatusStore(api: Host, config: StatusConfig, options: Stor
       dispose() {
         clearInterval(tick)
         for (const runner of runners.values()) runner.dispose()
+        branch?.runner.dispose()
         dispose()
       },
     }
diff --git a/packages/status/test/config.test.ts b/packages/status/test/config.test.ts
index 84479312..0b13bc64 100644
--- a/packages/status/test/config.test.ts
+++ b/packages/status/test/config.test.ts
@@ -1,18 +1,13 @@
-import { afterEach, describe, expect, test } from "bun:test"
-import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
-import { join } from "node:path"
+import { describe, expect, test } from "bun:test"
 import {
   asSegmentConfig,
-  asStatusConfig,
+  configNotices,
   DEFAULT_SEGMENTS,
   DEFAULT_SEPARATOR,
-  globalConfigPath,
-  loadStatusConfig,
-  mergeStatus,
+  loadStatus,
   PRESETS,
-  PROJECT_FILE,
-  readStatusFile,
   resolveLines,
+  SIDEBAR_SEGMENTS,
 } from "../src/core/config.ts"
 import { FIXTURES } from "../src/core/fixtures.ts"
 import { fitColumn } from "../src/core/render.ts"
@@ -20,88 +15,193 @@ import { buildSegments, findSegment } from "../src/core/segments.ts"
 
 const rowText = (runs: readonly { text: string }[]) => runs.map((run) => run.text).join("")
 
-const dirs: string[] = []
-const tmp = () => {
-  const dir = mkdtempSync("/tmp/ck-status-")
-  dirs.push(dir)
-  return dir
-}
-afterEach(() => {
-  for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
-})
+/** Both files in memory, through the loader every bay reads with. */
+const GLOBAL = "/cfg/opencode-cockpit/config.json"
+const PROJECT = "/w/app/.cockpit.json"
+const load = (files: Record, options?: unknown) =>
+  loadStatus({
+    options,
+    where: {
+      directory: "/w/app",
+      env: { XDG_CONFIG_HOME: "/cfg" },
+      read: (path) => {
+        const file = files[path]
+        return file === undefined ? undefined : typeof file === "string" ? file : JSON.stringify(file)
+      },
+    },
+  })
 
 describe("reading the config", () => {
-  test("a missing file is simply no settings", () => {
-    expect(readStatusFile("/nowhere/at/all.json")).toEqual({})
+  test("no file at all is no settings, and nothing to fix", () => {
+    const loaded = load({})
+    expect(loaded.config).toEqual({})
+    expect(loaded.notices).toEqual([])
   })
 
-  // A typo in a config should cost you your settings, never the interface.
-  test("a broken file is ignored rather than fatal", () => {
-    const dir = tmp()
-    const file = join(dir, "broken.json")
-    writeFileSync(file, "{ not json")
-    expect(readStatusFile(file)).toEqual({})
+  test("reads the `status` section, comments and trailing commas included", () => {
+    const loaded = load({ [GLOBAL]: '{\n  // mine\n  "status": { "separator": " | ", },\n}' })
+    expect(loaded.config.separator).toBe(" | ")
+    expect(loaded.notices).toEqual([])
   })
 
-  test("reads the statusline section out of a whole cockpit config", () => {
-    const dir = tmp()
-    const file = join(dir, PROJECT_FILE)
-    writeFileSync(file, JSON.stringify({ watch: { auto: true }, statusline: { separator: " | " } }))
-    expect(readStatusFile(file)).toEqual({ separator: " | " })
+  /** A typo in a config should cost you your settings, never the interface — and it says so. */
+  test("a broken file is ignored, with a `!` row that says the file could not be read", () => {
+    const loaded = load({ [GLOBAL]: "{ not json" })
+    expect(loaded.config).toEqual({})
+    expect(loaded.notices).toHaveLength(1)
+    expect(loaded.notices[0]).toMatch(/^settings: .*the whole file is ignored$/)
   })
 
-  test("plugin-entry options are accepted as the section itself", () => {
-    expect(asStatusConfig({ separator: " | ", segments: ["cwd"] })).toEqual({
+  test("plugin-entry options are accepted as the section itself, or as a whole config", () => {
+    expect(load({}, { separator: " | ", segments: ["cwd"] }).config).toMatchObject({
       separator: " | ",
       segments: ["cwd"],
     })
+    expect(load({}, { status: { separator: " / " } }).config.separator).toBe(" / ")
   })
 
-  test("anything that is not an object is no settings", () => {
-    expect(asStatusConfig(undefined)).toEqual({})
-    expect(asStatusConfig("nonsense")).toEqual({})
-    expect(asStatusConfig(null)).toEqual({})
+  test("a value of the wrong kind is dropped, with a row naming the key", () => {
+    const loaded = load({ [GLOBAL]: { status: { icons: "no", separator: " | " } } })
+    expect(loaded.config).toEqual({ separator: " | " })
+    expect(loaded.notices).toEqual(['settings: "status.icons" should be a boolean; the default is used'])
   })
 
-  test("the global path follows XDG when it is set", () => {
-    expect(globalConfigPath({ XDG_CONFIG_HOME: "/cfg" })).toBe("/cfg/opencode-cockpit/config.json")
-    expect(globalConfigPath({ HOME: "/home/u" })).toBe("/home/u/.config/opencode-cockpit/config.json")
+  test("off by `enabled`, or by the bundle's `features.status`", () => {
+    expect(load({ [GLOBAL]: { status: { enabled: false } } }).config.enabled).toBe(false)
+    expect(load({ [GLOBAL]: { features: { status: false } } }).config.enabled).toBe(false)
   })
 })
 
-describe("precedence", () => {
-  test("the project file beats the global one, and plugin options beat both", () => {
-    const config = tmp()
-    const project = tmp()
-    const env = { XDG_CONFIG_HOME: config }
-    mkdirSync(join(config, "opencode-cockpit"), { recursive: true })
-    writeFileSync(
-      globalConfigPath(env),
-      JSON.stringify({ statusline: { separator: " ~ ", segments: ["version"] } }),
+/**
+ * 0.9 renamed the section and stopped reading the file's root as Status's. Neither is read, and
+ * neither is silent: each is a `!` row, which is how a 0.8 config learns what changed.
+ */
+describe("old names", () => {
+  test("`statusline` is not read: it is a notice that names the fix", () => {
+    const loaded = load({ [GLOBAL]: { statusline: { separator: " | " } } })
+    expect(loaded.config.separator).toBeUndefined()
+    expect(loaded.notices).toEqual(['settings: "statusline" is no longer read — run /cockpit-setup'])
+  })
+
+  test("keys at the file's root are not Status's, and say where they belong", () => {
+    const loaded = load({ [GLOBAL]: { enabled: false, debug: true } })
+    expect(loaded.config.enabled).toBeUndefined()
+    expect(loaded.notices).toContain(
+      'settings: "enabled" at the top level is not read: it belongs in "status"',
     )
+  })
 
-    // Global alone.
-    expect(loadStatusConfig(project, undefined, env).separator).toBe(" ~ ")
+  test("a bay-level `maxRows` is `sidebarRows` now; inside `lines` it is still `maxRows`", () => {
+    const loaded = load({ [GLOBAL]: { status: { maxRows: 4, lines: [{ surface: "sidebar", maxRows: 3 }] } } })
+    expect(loaded.notices).toEqual(['settings: "status.maxRows" is no longer read — run /cockpit-setup'])
+    expect(resolveLines(loaded.config)[0]?.maxRows).toBe(3)
+  })
 
-    // The project overrides what it names and inherits what it does not.
-    writeFileSync(join(project, PROJECT_FILE), JSON.stringify({ statusline: { separator: " | " } }))
-    const merged = loadStatusConfig(project, undefined, env)
-    expect(merged.separator).toBe(" | ")
-    expect(merged.segments).toEqual(["version"])
+  test("`sidebarOrder` is the top-level `sidebar` list now", () => {
+    const loaded = load({ [GLOBAL]: { status: { sidebarOrder: 120 } } })
+    expect(loaded.notices).toEqual(['settings: "status.sidebarOrder" is no longer read — run /cockpit-setup'])
+  })
+})
+
+/** Status is the one bay every install draws, so the notices that belong to no bay are its to draw. */
+describe("notices that belong to no bay", () => {
+  test("a top-level name nothing reads is drawn here", () => {
+    expect(load({ [GLOBAL]: { wobble: 1 } }).notices).toEqual(['settings: "wobble" is not a setting'])
+  })
+
+  test("as is a `sidebar` entry that is not a bay", () => {
+    const loaded = load({ [GLOBAL]: { sidebar: ["status", "panels"] } })
+    expect(loaded.notices).toHaveLength(1)
+    expect(loaded.notices[0]).toContain('"panels" in "sidebar" is not a bay')
+  })
 
+  test("another bay's own notice is that bay's to draw, not this one's", () => {
+    expect(load({ [GLOBAL]: { trust: { sidebarOrder: 1 } } }).notices).toEqual([])
+  })
+})
+
+describe("precedence", () => {
+  test("the project file beats the global one, and plugin options beat both", () => {
+    const files = {
+      [GLOBAL]: { status: { separator: " ~ ", segments: ["version"] } },
+      [PROJECT]: { status: { separator: " | " } },
+    }
+    expect(load({ [GLOBAL]: files[GLOBAL] }).config.separator).toBe(" ~ ")
+    // The project overrides what it names and inherits what it does not.
+    expect(load(files).config).toMatchObject({ separator: " | ", segments: ["version"] })
     // The plugin entry wins over both.
-    expect(loadStatusConfig(project, { separator: " / " }, env).separator).toBe(" / ")
+    expect(load(files, { separator: " / " }).config.separator).toBe(" / ")
   })
 
   // Listing segments in a project means "this line", not "these as well as the global ones".
   test("segments are replaced, not concatenated", () => {
-    const merged = mergeStatus({ segments: ["cwd", "cost"] }, { segments: ["model"] })
-    expect(merged.segments).toEqual(["model"])
+    const loaded = load({
+      [GLOBAL]: { status: { segments: ["cwd", "cost"] } },
+      [PROJECT]: { status: { segments: ["model"] } },
+    })
+    expect(loaded.config.segments).toEqual(["model"])
   })
 
   test("commands from both sources are kept", () => {
-    const merged = mergeStatus({ commands: { budget: { run: "a" } } }, { commands: { pods: { run: "b" } } })
-    expect(Object.keys(merged.commands ?? {}).sort()).toEqual(["budget", "pods"])
+    const loaded = load({
+      [GLOBAL]: { status: { commands: { budget: { run: "a" } } } },
+      [PROJECT]: { status: { commands: { pods: { run: "b" } } } },
+    })
+    expect(Object.keys(loaded.config.commands ?? {}).sort()).toEqual(["budget", "pods"])
+  })
+
+  test("modules from every source add up rather than replacing each other", () => {
+    const loaded = load(
+      { [GLOBAL]: { status: { modules: ["~/a.ts"] } }, [PROJECT]: { status: { modules: ["./b.ts"] } } },
+      { modules: ["./c.ts"] },
+    )
+    expect(loaded.config.modules).toEqual(["~/a.ts", "./b.ts", "./c.ts"])
+  })
+})
+
+describe("where it draws", () => {
+  test("the sidebar, when nothing says otherwise", () => {
+    expect(resolveLines(load({}).config)[0]?.surface).toBe("sidebar")
+  })
+
+  /** The switch every bay shares, in Status's words: off the sidebar means at the bottom. */
+  test("`sidebar: false` means the bottom, with the bottom's own line", () => {
+    const [line] = resolveLines(load({ [GLOBAL]: { status: { sidebar: false } } }).config)
+    expect(line?.surface).toBe("bottom")
+    expect(line?.segments).toEqual(DEFAULT_SEGMENTS)
+  })
+
+  test("`surface` says it in Status's words, and wins", () => {
+    expect(load({ [GLOBAL]: { status: { sidebar: false, surface: "sidebar" } } }).config.surface).toBe(
+      "sidebar",
+    )
+  })
+
+  test("`sidebarRows` caps the column", () => {
+    const loaded = load({ [GLOBAL]: { status: { sidebarRows: 5 } } })
+    expect(resolveLines(loaded.config)[0]?.maxRows).toBe(5)
+  })
+
+  test("the block's place comes from the top-level `sidebar` list", () => {
+    expect(load({}).order).toBeLessThan(load({ [GLOBAL]: { sidebar: ["trust", "shell", "status"] } }).order)
+  })
+})
+
+/** What only Status can tell is wrong: its own vocabulary. A preset nothing answers to used to fall back in silence. */
+describe("settings only Status can check", () => {
+  test("a preset nothing answers to is a row, and the line still draws", () => {
+    const loaded = load({ [GLOBAL]: { status: { preset: "sidebar-budget" } } })
+    expect(loaded.notices).toEqual([
+      'settings: no preset "sidebar-budget" (minimal, default, detailed, sidebar)',
+    ])
+    expect(resolveLines(loaded.config)[0]?.segments).toEqual(SIDEBAR_SEGMENTS)
+  })
+
+  test("so is one inside `lines`, and a surface that does not exist", () => {
+    expect(configNotices({ lines: [{ preset: "nope" }, { surface: "top" as never }] })).toEqual([
+      'settings: no preset "nope" (minimal, default, detailed, sidebar)',
+      'settings: "status.lines[1].surface" is "sidebar" or "bottom"',
+    ])
   })
 })
 
@@ -119,9 +219,10 @@ describe("presets", () => {
   test("a preset brings its own surface", () => {
     expect(resolveLines({ preset: "sidebar" })[0]?.surface).toBe("sidebar")
     expect(resolveLines({ preset: "sidebar" })[0]?.stack).toBe("vertical")
+    expect(resolveLines({ preset: "default" })[0]?.surface).toBe("bottom")
   })
 
-  /** Why a turn stalled is what OpenCode does not show; the sidebar preset dropped it entirely. */
+  /** Why a turn stalled is what OpenCode does not show; the sidebar keeps it when rows run out. */
   test("every preset says why a turn stalled, and the sidebar keeps it when rows run out", () => {
     for (const [name, preset] of Object.entries(PRESETS))
       expect(
@@ -141,9 +242,9 @@ describe("presets", () => {
     expect(line?.segments).toEqual(["cwd"])
   })
 
-  test("a name nothing answers to falls back rather than drawing nothing", () => {
-    const [line] = resolveLines({ preset: "nope" })
-    expect(line?.segments).toEqual(DEFAULT_SEGMENTS)
+  test("a name nothing answers to falls back to the surface's own line rather than drawing nothing", () => {
+    expect(resolveLines({ preset: "nope" })[0]?.segments).toEqual(SIDEBAR_SEGMENTS)
+    expect(resolveLines({ preset: "nope", surface: "bottom" })[0]?.segments).toEqual(DEFAULT_SEGMENTS)
   })
 
   test("every preset uses only built-ins, so none of them needs a module", () => {
@@ -157,18 +258,19 @@ describe("presets", () => {
 })
 
 describe("resolving lines", () => {
-  test("writing nothing gives the default line at the bottom", () => {
+  test("writing nothing gives the sidebar's table", () => {
     const [line] = resolveLines({})
-    expect(line?.surface).toBe("bottom")
+    expect(line?.surface).toBe("sidebar")
+    expect(line?.segments).toEqual(SIDEBAR_SEGMENTS)
+    expect(line?.separator).toBe("")
+  })
+
+  test("the bottom with nothing else written gives the default line", () => {
+    const [line] = resolveLines({ surface: "bottom" })
     expect(line?.segments).toEqual(DEFAULT_SEGMENTS)
     expect(line?.separator).toBe(DEFAULT_SEPARATOR)
   })
 
-  /**
-   * OpenCode's own footer, sidebar and prompt already carry the path, branch, tokens, context
-   * percentage, spend and model. A default that repeated them would draw the same figure several
-   * times on one screen, which is exactly what it looked like before this rule.
-   */
   /**
    * The rule is not to avoid every fact OpenCode mentions -- it is to avoid saying one no better
    * than the host does. A bar with the breakdown beside it is a different instrument from
@@ -225,11 +327,6 @@ describe("resolving lines", () => {
     expect(side?.separator).toBe(DEFAULT_SEPARATOR)
   })
 
-  test("modules from both sources add up rather than replacing each other", () => {
-    const merged = mergeStatus({ modules: ["~/a.ts"] }, { modules: ["./b.ts"] })
-    expect(merged.modules).toEqual(["~/a.ts", "./b.ts"])
-  })
-
   // Promised in the docs, so it has to actually reach the builder.
   test("icons are on by default and can be switched off globally or per line", () => {
     expect(resolveLines({})[0]?.icons).toBe(true)
@@ -253,34 +350,24 @@ describe("resolving lines", () => {
    * Writing these at the top level is the natural guess, and having them quietly ignored costs
    * exactly the rows they were meant to keep.
    */
-  test("maxRows and padding can be set once for every line", () => {
-    const [line] = resolveLines({ surface: "sidebar", maxRows: 20, paddingLeft: 4 })
+  test("the row cap and padding can be set once for every line", () => {
+    const [line] = resolveLines({ surface: "sidebar", sidebarRows: 20, paddingLeft: 4 })
     expect(line?.maxRows).toBe(20)
     expect(line?.paddingLeft).toBe(4)
   })
 
   test("a line still overrides what the top level set", () => {
-    const [line] = resolveLines({ maxRows: 20, lines: [{ surface: "sidebar", maxRows: 3 }] })
+    const [line] = resolveLines({ sidebarRows: 20, lines: [{ surface: "sidebar", maxRows: 3 }] })
     expect(line?.maxRows).toBe(3)
   })
 
+  test("the table's preset brings room for every row it has; another column keeps eight", () => {
+    expect(resolveLines({})[0]?.maxRows).toBe(PRESETS.sidebar?.maxRows)
+    expect(resolveLines({ surface: "sidebar", segments: ["cwd"], preset: "minimal" })[0]?.maxRows).toBe(8)
+  })
+
   test("a bare string is that built-in with no settings", () => {
     expect(asSegmentConfig("cwd")).toEqual({ type: "cwd" })
     expect(asSegmentConfig({ type: "cwd", priority: 5 })).toEqual({ type: "cwd", priority: 5 })
   })
 })
-
-/**
- * Two bays share the sidebar and draw in registration order, which was a constant nobody could
- * reach. A key the loader drops is a setting that silently does nothing.
- */
-describe("where the line sits among other bays", () => {
-  test("sidebarOrder survives the loader, from a file and from plugin options", () => {
-    expect(asStatusConfig({ statusline: { sidebarOrder: 120 } }).sidebarOrder).toBe(120)
-    expect(asStatusConfig({ sidebarOrder: 120 }).sidebarOrder).toBe(120)
-  })
-
-  test("and is absent when nobody set it, so the default stands", () => {
-    expect(asStatusConfig({ surface: "sidebar" }).sidebarOrder).toBeUndefined()
-  })
-})
diff --git a/packages/status/test/custom.test.ts b/packages/status/test/custom.test.ts
index 1fcf703b..fae52534 100644
--- a/packages/status/test/custom.test.ts
+++ b/packages/status/test/custom.test.ts
@@ -125,7 +125,20 @@ describe("loading your own segments", () => {
     })
     expect(segments.size).toBe(0)
     expect(errors[0]).toContain("./nope.ts")
-    expect(errors[0]).toContain("Cannot find module")
+    // Nothing at that path: said in words, rather than as the resolver's stack of paths.
+    expect(errors[0]).toContain("no file there")
+  })
+
+  /** 0.9 folded the sidebar examples into the `sidebar` preset; a config naming one is told so. */
+  test("a sidebar example 0.9 removed says what replaced it", async () => {
+    const { errors } = await loadCustomSegments(
+      ["~/node_modules/@opencode-cockpit/status/examples/sidebar-budget.ts"],
+      "/w/app",
+      async () => {
+        throw new Error("Cannot find module")
+      },
+    )
+    expect(errors[0]).toContain('use "preset": "sidebar"')
   })
 
   test("an export that is not a function is reported, and its siblings still load", async () => {
diff --git a/packages/status/test/examples.test.ts b/packages/status/test/examples.test.ts
index 31d40425..20cebb83 100644
--- a/packages/status/test/examples.test.ts
+++ b/packages/status/test/examples.test.ts
@@ -9,7 +9,8 @@ import { buildSegments, type Segment, segmentText, segmentWidth } from "../src/c
  * run is worse than no example, and this is the only thing that catches one.
  */
 
-const EXAMPLES = ["bottom", "sidebar", "sidebar-full", "sidebar-budget"] as const
+/** The sidebar ones became the `sidebar` preset in 0.9; its rows are built-ins, tested in table.test.ts. */
+const EXAMPLES = ["bottom"] as const
 
 const session = (over: Partial = {}): SessionSnapshot => ({
   id: "ses_1",
@@ -126,127 +127,3 @@ describe("the bottom example", () => {
     expect(segmentText(drawn as Segment)).toBe("▌50% cached")
   })
 })
-
-/**
- * This one replaces OpenCode's own Context block rather than sitting beside it, so it has to carry
- * what that block carried — the percentage, the token total and the spend — or turning the host's
- * block off leaves the user worse off than before.
- */
-describe("the full sidebar example", () => {
-  const full = (over: Partial> = {}) => ({ ...working(0.4), ...over })
-
-  test("carries everything the host's Context block did", () => {
-    const ctxWith = ctx({ session: full() })
-    expect(segmentText(draw("sidebar-full", "bar", ctxWith) as Segment)).toContain("40%")
-    expect(segmentText(draw("sidebar-full", "window", ctxWith) as Segment)).toContain("/")
-    expect(segmentText(draw("sidebar-full", "spend", ctxWith) as Segment)).toContain("$")
-  })
-
-  test("every row fits a sidebar column", () => {
-    const ctxWith = ctx({ session: full() })
-    for (const type of loaded["sidebar-full"].segments.keys()) {
-      const drawn = draw("sidebar-full", type, ctxWith)
-      if (drawn) expect(segmentWidth(drawn)).toBeLessThanOrEqual(32)
-    }
-  })
-
-  test("without a declared window it reports the total rather than a share of nothing", () => {
-    const unmeasured = ctx({
-      session: {
-        ...working(0.4),
-        model: { providerID: "p", modelID: "m" },
-      },
-    })
-    expect(draw("sidebar-full", "bar", unmeasured)).toBeUndefined()
-    expect(segmentText(draw("sidebar-full", "window", unmeasured) as Segment)).toContain("tok")
-  })
-
-  test("spend stays silent on an unpriced model", () => {
-    const unpriced = ctx({ session: { ...working(0.4), priced: false } })
-    expect(draw("sidebar-full", "spend", unpriced)).toBeUndefined()
-  })
-
-  test("tasks go quiet once the list is finished", () => {
-    const done = ctx({ session: { ...working(0.4), todo: { total: 5, completed: 5 } } })
-    expect(draw("sidebar-full", "todo", done)).toBeUndefined()
-  })
-})
-
-describe("the sidebar example", () => {
-  test("rows carry a dim label so a column of them reads as a table", () => {
-    const drawn = draw("sidebar", "changes", ctx({ session: working() }))
-    expect(drawn?.runs[0]).toMatchObject({ text: "diff ", dim: true })
-    expect(segmentText(drawn as Segment)).toBe("diff 3f +120 -18")
-  })
-
-  test("the bar is exactly the width asked for, with its figure beside it", () => {
-    const drawn = buildSegments(ctx({ session: working(0.5) }), [{ type: "bar", width: 10 }], {
-      custom: loaded.sidebar.segments,
-      icons: false,
-    })[0]
-    expect(segmentText(drawn as Segment)).toMatch(/^[█░]{10} 50%$/)
-  })
-
-  test("the split parts always add up to the whole window", () => {
-    const drawn = draw("sidebar", "split", ctx({ session: working() }))
-    const shares = [...segmentText(drawn as Segment).matchAll(/(\d+)%/g)].map((m) => Number(m[1]))
-    expect(shares.reduce((a, b) => a + b, 0)).toBe(100)
-  })
-})
-
-/**
- * The table sidebar. Its whole point is that a column of rows lines up, so the label gutter is
- * worth asserting: a row that pads to a different width reads as a typo from across the room.
- */
-describe("the budget sidebar example", () => {
-  const withSession = () => ctx({ session: working(0.4) })
-
-  test("every labelled row starts with the same seven-column gutter, a space past the longest label", () => {
-    for (const type of ["tokens", "in", "out", "cache"]) {
-      const drawn = draw("sidebar-budget", type, withSession())
-      expect(drawn?.runs[0]?.text).toHaveLength(7)
-    }
-  })
-
-  test("the token rows are shares of the window, and they add up to it", () => {
-    const shares = ["in", "out", "cache", "write"].map((type) => {
-      const drawn = draw("sidebar-budget", type, withSession())
-      return drawn ? Number(/(\d+)%$/.exec(segmentText(drawn))?.[1] ?? 0) : 0
-    })
-    expect(shares.reduce((a, b) => a + b, 0)).toBe(100)
-  })
-
-  /** The figure lives on the tokens row below; printing it twice is what the bar is spared. */
-  test("the bar is the full width of solid cells and nothing else", () => {
-    const text = segmentText(draw("sidebar-budget", "bar", withSession()) as Segment)
-    expect(text).toBe("â–ˆ".repeat(16))
-  })
-
-  /** Without a proxy writing the file there is no budget, and a made-up one would be worse. */
-  test("the budget rows stay silent when nothing reports a spend", () => {
-    expect(draw("sidebar-budget", "spend", withSession())).toBeUndefined()
-    expect(draw("sidebar-budget", "avail", withSession())).toBeUndefined()
-  })
-
-  test("demo fills them in, so the layout can be looked at before a proxy exists", () => {
-    const drawn = buildSegments(withSession(), [{ type: "avail", demo: true }], {
-      custom: loaded["sidebar-budget"].segments,
-      icons: false,
-    })[0]
-    expect(segmentText(drawn as Segment)).toBe("avail  $173.76 · 87% left")
-  })
-
-  /** `write 0 · 0%` on a session with no cache writes said nothing, in a row of its own. */
-  test("a row whose figure is zero is not drawn", () => {
-    expect(draw("sidebar-budget", "write", withSession())).toBeUndefined()
-  })
-
-  /** The word is the label: no coloured square beside it, and the figures are not categories. */
-  test("colour is a level, never a label", () => {
-    for (const type of ["tokens", "in", "out", "cache"]) {
-      const drawn = draw("sidebar-budget", type, withSession())
-      expect(segmentText(drawn as Segment)).not.toContain("â–ª")
-      for (const run of drawn?.runs ?? []) expect(["text", "muted"]).toContain(run.tone)
-    }
-  })
-})
diff --git a/packages/status/test/instructions.test.ts b/packages/status/test/instructions.test.ts
index 636e592e..3e931088 100644
--- a/packages/status/test/instructions.test.ts
+++ b/packages/status/test/instructions.test.ts
@@ -1,10 +1,10 @@
 import { describe, expect, test } from "bun:test"
 import { resolveLines } from "../src/core/config.ts"
-import { statuslineBrief, targetConfig } from "../src/core/instructions.ts"
+import { statusSetupBrief, targetConfig } from "../src/core/instructions.ts"
 import { buildReport } from "../src/core/report.ts"
 
 /**
- * `/statusline` sends this to the agent instead of drawing a panel. It is worth testing as a string
+ * `/status-setup` sends this to the agent instead of drawing a panel. It is worth testing as a string
  * because the whole value of it is the part an agent cannot look up: which file to edit, and what
  * is in it right now.
  */
@@ -28,33 +28,51 @@ describe("which file to edit", () => {
   })
 
   test("otherwise the global one, and the brief says it has to be created", () => {
-    expect(statuslineBrief(base())).toContain("does not exist yet")
+    expect(statusSetupBrief(base())).toContain("does not exist yet")
   })
 })
 
 describe("what it tells the agent", () => {
   test("names the surfaces drawing now, so it does not have to guess", () => {
-    expect(statuslineBrief(base())).toMatch(/Drawing now: bottom \(horizontal\), \d+ segments/)
+    expect(statusSetupBrief(base())).toMatch(/Drawing now: bottom \(horizontal\), \d+ segments/)
   })
 
   test("carries the built-in names and the presets, which are otherwise only on a website", () => {
-    const brief = statuslineBrief(base())
+    const brief = statusSetupBrief(base())
     expect(brief).toContain("git.branch")
     expect(brief).toContain('"preset": "minimal"')
   })
 
   test("a module that would not load is stated, not left to be rediscovered", () => {
-    const brief = statuslineBrief(base({ modules: ["./x.ts"], errors: ["./x.ts: no such file"] }))
+    const brief = statusSetupBrief(base({ modules: ["./x.ts"], errors: ["./x.ts: no such file"] }))
     expect(brief).toContain("./x.ts: no such file")
   })
 
   test("points at the skill and insists the result is looked at before it is called done", () => {
-    const brief = statuslineBrief(base())
+    const brief = statusSetupBrief(base())
     expect(brief).toContain("skills/statusline-design/SKILL.md")
     expect(brief).toContain("preview --watch")
   })
 
+  /** The section is `status` since 0.9; an agent told `statusline` would write a key nothing reads. */
+  test("names the `status` section, and says the old one is not read", () => {
+    const brief = statusSetupBrief(base())
+    expect(brief).toContain('{ "status": { ... } }')
+    expect(brief).toContain('`"statusline"` and keys at the')
+    expect(brief).toContain('"sidebarRows"')
+  })
+
+  test("says the sidebar is the default surface", () => {
+    expect(statusSetupBrief(base())).toMatch(/"preset": "sidebar"`.*\(sidebar, the default\)/)
+  })
+
+  test("settings to fix are listed first, so the agent fixes them before designing", () => {
+    const brief = statusSetupBrief(base({ notices: ['settings: "statusline" is no longer read'] }))
+    expect(brief).toContain("- Settings to fix first")
+    expect(brief).toContain('  - settings: "statusline" is no longer read')
+  })
+
   test("ends by asking what I want, so it does not redesign the line unprompted", () => {
-    expect(statuslineBrief(base()).trimEnd().endsWith("before you edit anything.")).toBe(true)
+    expect(statusSetupBrief(base()).trimEnd().endsWith("before you edit anything.")).toBe(true)
   })
 })
diff --git a/packages/status/test/notices.test.ts b/packages/status/test/notices.test.ts
index 07aabf1f..a54790c2 100644
--- a/packages/status/test/notices.test.ts
+++ b/packages/status/test/notices.test.ts
@@ -1,16 +1,17 @@
 import { describe, expect, test } from "bun:test"
-import { moduleNotice, overflowNotice } from "../src/core/notices.ts"
+import { REMOVED_EXAMPLE } from "../src/core/custom.ts"
+import { moduleNoticeText, noticeRows, overflowNotice } from "../src/core/notices.ts"
 import { type Segment, segmentText } from "../src/core/segments.ts"
 
 /**
- * Both of these exist because a failure that draws nothing is indistinguishable from a segment that
- * had nothing to say — the one place the bay's own silence rule is wrong.
+ * These exist because a failure that draws nothing is indistinguishable from a segment that had
+ * nothing to say — the one place the bay's own silence rule is wrong.
  */
 
 describe("rows that did not fit", () => {
-  test("says how many, and what to raise", () => {
+  test("says how many, and what to raise — by the name every bay's block uses", () => {
     const notice = overflowNotice(6)
-    expect(segmentText(notice as Segment)).toBe("↳ 6 more — raise maxRows")
+    expect(segmentText(notice as Segment)).toBe("↳ 6 more — raise sidebarRows")
     expect(notice?.runs[0]).toMatchObject({ tone: "muted", dim: true })
   })
 
@@ -26,26 +27,41 @@ describe("rows that did not fit", () => {
 })
 
 describe("a module that would not load", () => {
-  test("names the one that failed, because the name is what you go and fix", () => {
-    const notice = moduleNotice(["./mine.ts: Cannot find module 'foo'"])
-    expect(segmentText(notice as Segment)).toContain("./mine.ts")
-    expect(notice?.runs[0]?.tone).toBe("error")
+  test("names the file, not the path: the part a sidebar has room for and the part you fix", () => {
+    expect(moduleNoticeText("~/.config/opencode-cockpit/modules/mine.ts: Cannot find module 'foo'")).toBe(
+      "module mine.ts: Cannot find module 'foo'",
+    )
   })
 
-  test("a long message is cut to fit a sidebar column", () => {
-    const notice = moduleNotice([`./x.ts: ${"very ".repeat(40)}long`], 30)
-    expect(segmentText(notice as Segment).length).toBeLessThanOrEqual(32)
+  test("a sidebar example 0.9 removed says what replaced it, in a sentence that fits three rows", () => {
+    const text = moduleNoticeText(`~/x/examples/sidebar-budget.ts: ${REMOVED_EXAMPLE}`)
+    expect(text).toBe('sidebar-budget.ts was removed in 0.9: use "preset": "sidebar"')
+    expect(noticeRows([text], 24)).toHaveLength(3)
+    expect(noticeRows([text], 24).map((row) => segmentText(row).trimEnd())[2]).toBe('  "preset": "sidebar"')
+  })
+})
+
+describe("the rows a notice draws", () => {
+  test("`!` in the warning tone, wrapped to the column, every row its full width", () => {
+    const rows = noticeRows(['settings: "statusline" is no longer read — run /cockpit-setup'], 24)
+    expect(rows.map((row) => segmentText(row).trimEnd())).toEqual([
+      '! settings: "statusline"',
+      "  is no longer read —",
+      "  run /cockpit-setup",
+    ])
+    expect(rows[0]?.runs[0]).toMatchObject({ text: "! ", tone: "warning" })
+    for (const row of rows) expect(segmentText(row)).toHaveLength(24)
   })
 
-  test("several are counted rather than listed, which would fill the line", () => {
-    expect(segmentText(moduleNotice(["a", "b", "c"]) as Segment)).toBe("! 3 modules failed to load")
+  test("a wide surface keeps a notice to one row", () => {
+    expect(noticeRows(['settings: "statusline" is no longer read — run /cockpit-setup'], 120)).toHaveLength(1)
   })
 
-  test("it outranks every segment, so a broken line still reports why", () => {
-    expect(moduleNotice(["a"])?.priority).toBe(1000)
+  test("they outrank every segment, so a broken line still reports why", () => {
+    for (const row of noticeRows(["a", "b"], 30)) expect(row.priority).toBe(1000)
   })
 
-  test("nothing failed, nothing drawn", () => {
-    expect(moduleNotice([])).toBeUndefined()
+  test("nothing to say, nothing drawn", () => {
+    expect(noticeRows([], 30)).toEqual([])
   })
 })
diff --git a/packages/status/test/report.test.ts b/packages/status/test/report.test.ts
index df8a62bb..af1b3be8 100644
--- a/packages/status/test/report.test.ts
+++ b/packages/status/test/report.test.ts
@@ -5,7 +5,7 @@ import { resolveLines } from "../src/core/config.ts"
 import { buildReport, reportHeadline } from "../src/core/report.ts"
 
 /**
- * `/statusline` exists to answer the one question the screen cannot: a quiet line looks the same
+ * `/status-setup` exists to answer the one question the screen cannot: a quiet line looks the same
  * whether there was nothing to report or the config never arrived. So the report has to be right
  * about the empty cases, not only the working one.
  */
@@ -56,6 +56,18 @@ describe("the headline", () => {
     expect(reportHeadline(report())).toContain("neither config file exists")
   })
 
+  test("settings to fix outrank the defaults message: the line is drawing without them", () => {
+    expect(reportHeadline(report({ notices: ["settings: x"] }))).toContain("1 setting to fix")
+  })
+
+  test("the loader's files are reported as it found them", () => {
+    const files = [
+      { path: "/g/config.json", found: true },
+      { path: "/w/.cockpit.json", found: false },
+    ]
+    expect(report({ files }).sources).toEqual(files)
+  })
+
   test("nothing configured says exactly that, rather than counting zero segments", () => {
     expect(reportHeadline(report({ lines: [] }))).toContain("Nothing is drawing")
   })
diff --git a/packages/status/test/store.test.ts b/packages/status/test/store.test.ts
index 2d7a8bef..b7af465e 100644
--- a/packages/status/test/store.test.ts
+++ b/packages/status/test/store.test.ts
@@ -138,6 +138,71 @@ describe("commands run off the draw path", () => {
   })
 })
 
+/** The sidebar table's two outside readings: the branch's diff from git, and a proxy's budget file. */
+describe("what the table reads", () => {
+  const table = (readBudget: (path: string) => { spent: number; cap: number } | undefined) => {
+    const runs: string[] = []
+    const reads: string[] = []
+    const store = createStatusStore(
+      api,
+      {},
+      {
+        version: "9.9.9",
+        now: () => 0,
+        exec: async (command) => {
+          runs.push(command)
+          return " 2 files changed, 9 insertions(+), 1 deletion(-)"
+        },
+        readBudget: (path) => {
+          reads.push(path)
+          return readBudget(path)
+        },
+        build: (_api, input) => ({
+          ...base,
+          defaultBranch: "dev",
+          ...(input.branchDiff ? { branchDiff: input.branchDiff } : {}),
+          ...(input.budget ? { budget: input.budget } : {}),
+        }),
+      },
+    )
+    return { store, runs, reads }
+  }
+
+  test("the default line asks git for the branch against the default branch, and draws its answer", async () => {
+    const { store, runs } = table(() => undefined)
+    store.context()
+    await new Promise((done) => setTimeout(done, 0))
+    expect(runs).toContain(`git diff --shortstat "$(git merge-base 'dev' HEAD)"`)
+    store.dispose()
+  })
+
+  test("reads the proxy's file, and carries the budget it found", () => {
+    const { store, reads } = table(() => ({ spent: 5, cap: 50 }))
+    expect(store.context().budget).toEqual({ spent: 5, cap: 50 })
+    expect(reads[0]).toEndWith("opencode-litellm-iap/spend.json")
+    store.dispose()
+  })
+
+  test("a line with no budget row reads no file", () => {
+    const reads: string[] = []
+    const store = createStatusStore(
+      api,
+      { segments: ["context"] },
+      {
+        version: "9.9.9",
+        readBudget: (path) => {
+          reads.push(path)
+          return undefined
+        },
+        build: () => base,
+      },
+    )
+    store.context()
+    expect(reads).toEqual([])
+    store.dispose()
+  })
+})
+
 describe("disposal", () => {
   test("stops the clock and the commands", async () => {
     live = harness({ commands: { probe: { run: "x", intervalMs: 250 } } })
diff --git a/packages/status/test/table.test.ts b/packages/status/test/table.test.ts
new file mode 100644
index 00000000..7a990a1b
--- /dev/null
+++ b/packages/status/test/table.test.ts
@@ -0,0 +1,243 @@
+import { describe, expect, test } from "bun:test"
+import { budgetFile, parseBudget } from "../src/core/budget.ts"
+import { asSegmentConfig, PRESETS, resolveLines, SIDEBAR_SEGMENTS } from "../src/core/config.ts"
+import type { SessionSnapshot, StatusContext } from "../src/core/context.ts"
+import { branchDiffCommand, wantsBranchDiff } from "../src/core/diff.ts"
+import { FIXTURES } from "../src/core/fixtures.ts"
+import { fitColumn } from "../src/core/render.ts"
+import { buildSegments, findSegment, type Segment, segmentText, segmentWidth } from "../src/core/segments.ts"
+
+/**
+ * The sidebar's table — the `sidebar` preset, and the default since 0.9. Its rows were the
+ * `sidebar-budget` example's, promoted to built-ins so the default needs no module. Its whole point
+ * is that a column of rows lines up and says nothing it has no figure for, so both are asserted.
+ */
+
+const session = (over: Partial = {}): SessionSnapshot => ({
+  id: "ses_1",
+  status: "idle",
+  cost: 0,
+  priced: true,
+  messages: 2,
+  startedAt: 0,
+  model: { providerID: "p", modelID: "m", contextLimit: 200_000 },
+  tokens: { input: 24_000, output: 16_000, reasoning: 0, cache: { read: 40_000, write: 0 } },
+  diff: { files: 0, additions: 0, deletions: 0 },
+  todo: { total: 0, completed: 0 },
+  ...over,
+})
+
+const ctx = (over: Partial = {}): StatusContext => ({
+  now: 60_000,
+  directory: "/w/app",
+  worktree: "/w/app",
+  home: "/home/u",
+  defaultBranch: "main",
+  version: "0.9.0",
+  lsp: [],
+  mcp: [],
+  commands: {},
+  width: 34,
+  session: session(),
+  ...over,
+})
+
+const draw = (type: string, context: StatusContext, config: Record = {}) =>
+  buildSegments(context, [{ type, ...config }], { icons: false })[0]
+const text = (segment: Segment | undefined) => (segment ? segmentText(segment) : undefined)
+
+/** The whole preset, as the sidebar draws it. */
+const table = (context: StatusContext) =>
+  fitColumn(
+    buildSegments(context, SIDEBAR_SEGMENTS.map(asSegmentConfig)),
+    context.width,
+    PRESETS.sidebar?.maxRows ?? 8,
+  ).segments.map((segment) => segmentText(segment))
+
+describe("the sidebar preset", () => {
+  test("is the default: writing nothing draws the table in the sidebar", () => {
+    const [line] = resolveLines({})
+    expect(line?.surface).toBe("sidebar")
+    expect(line?.segments).toEqual(SIDEBAR_SEGMENTS)
+  })
+
+  test("every name in it is a built-in, so it needs no module", () => {
+    for (const entry of SIDEBAR_SEGMENTS) expect(findSegment(asSegmentConfig(entry).type)).toBeDefined()
+  })
+
+  test("a working session, no proxy: heading, bar, tokens by where they went, one hairline, the branch", () => {
+    expect(table(ctx({ branchDiff: { files: 3, additions: 42, deletions: 7 } }))).toEqual([
+      "Context",
+      "â–ˆ".repeat(5) + "â–ˆ".repeat(11),
+      "tokens 80k · 40%",
+      "in     24k · 30%",
+      "out    16k · 20%",
+      "cache  40k · 50%",
+      "─".repeat(14),
+      "git    3f +42 -7 vs main",
+    ])
+  })
+
+  test("with a proxy's budget, the budget is a group of its own", () => {
+    const rows = table(
+      ctx({ budget: { spent: 26.24, cap: 200 }, branchDiff: { files: 3, additions: 42, deletions: 7 } }),
+    )
+    expect(rows.slice(-5)).toEqual([
+      "─".repeat(14),
+      "spend  $26.24",
+      "avail  $173.76 · 87% left",
+      "─".repeat(14),
+      "git    3f +42 -7 vs main",
+    ])
+  })
+
+  test("a hairline left with nothing under it goes: a budget and no branch", () => {
+    const rows = table(ctx({ budget: { spent: 26.24, cap: 200 } }))
+    expect(rows.at(-1)).toBe("avail  $173.76 · 87% left")
+    expect(rows.filter((row) => row.startsWith("─"))).toHaveLength(1)
+  })
+
+  /** Every row of a busy session with a budget and a branch fits under the preset's own cap. */
+  test("the cap holds every row it has, so none is dropped on a full session", () => {
+    const full = ctx({
+      ...FIXTURES.full.ctx,
+      width: 34,
+      budget: { spent: 26.24, cap: 200 },
+      session: {
+        ...(FIXTURES.full.ctx.session as SessionSnapshot),
+        status: "retry",
+        retry: { attempt: 2, message: "", next: 6_000 },
+      },
+    })
+    const built = buildSegments(full, SIDEBAR_SEGMENTS.map(asSegmentConfig))
+    expect(fitColumn(built, 34, PRESETS.sidebar?.maxRows ?? 8).dropped).toBe(0)
+  })
+
+  test("before the first reply: the heading and the branch, and no hairline with nothing under it", () => {
+    expect(table(ctx({ ...FIXTURES.fresh.ctx, width: 34 }))).toEqual([
+      "Context",
+      "─".repeat(14),
+      "git    5f +312 -48 vs main",
+    ])
+  })
+
+  test("every row fits a 24-column sidebar, a word given up before a figure", () => {
+    const narrow = ctx({
+      width: 24,
+      budget: { spent: 26.24, cap: 200 },
+      branchDiff: { files: 28, additions: 1_840, deletions: 620 },
+    })
+    for (const row of table(narrow)) expect(row.length).toBeLessThanOrEqual(24)
+    expect(table(narrow)).toContain("avail  $173.76 · 87%")
+    expect(table(narrow)).toContain("git    28f +1.8k -620")
+  })
+})
+
+describe("the table's rows", () => {
+  test("every labelled row starts with the same seven-column gutter, a space past the longest label", () => {
+    for (const type of ["in", "out", "cache", "spend", "avail", "git"]) {
+      const drawn = draw(
+        type,
+        ctx({ budget: { spent: 1, cap: 10 }, branchDiff: { files: 1, additions: 1, deletions: 1 } }),
+      )
+      expect(drawn?.runs[0]?.text, type).toHaveLength(7)
+    }
+    expect(draw("tokens", ctx(), { style: "row" })?.runs[0]?.text).toBe("tokens ")
+  })
+
+  test("the token rows are shares of the window, and they add up to it", () => {
+    const shares = ["in", "out", "cache", "write"].map((type) => {
+      const drawn = draw(type, ctx())
+      return drawn ? Number(/(\d+)%$/.exec(segmentText(drawn))?.[1] ?? 0) : 0
+    })
+    expect(shares.reduce((a, b) => a + b, 0)).toBe(100)
+  })
+
+  /** `write 0 · 0%` on a session with no cache writes said nothing, in a row of its own. */
+  test("a row whose figure is zero is not drawn", () => {
+    expect(draw("write", ctx())).toBeUndefined()
+  })
+
+  /** The figure lives on the tokens row below; printing it twice is what the bar is spared. */
+  test("the bar is the full width of solid cells and nothing else", () => {
+    expect(text(draw("context", ctx(), { style: "solid", width: 16 }))).toBe("â–ˆ".repeat(16))
+  })
+
+  /** Without a proxy writing the file there is no budget, and a made-up one would be worse. */
+  test("the budget rows stay silent when nothing reports a spend", () => {
+    expect(draw("spend", ctx())).toBeUndefined()
+    expect(draw("avail", ctx())).toBeUndefined()
+  })
+
+  test("the budget is calm until it is a level to watch", () => {
+    expect(draw("spend", ctx({ budget: { spent: 26, cap: 200 } }))?.runs[1]?.tone).toBe("text")
+    expect(draw("spend", ctx({ budget: { spent: 190, cap: 200 } }))?.runs[1]?.tone).toBe("error")
+  })
+
+  /** The word is the label: no coloured square beside it, and the figures are not categories. */
+  test("colour is a level, never a label", () => {
+    for (const type of ["in", "out", "cache"]) {
+      const drawn = draw(type, ctx())
+      for (const run of drawn?.runs ?? []) expect(["text", "muted"]).toContain(run.tone)
+    }
+  })
+
+  test("git is the branch against where it forked, and silent until git says", () => {
+    expect(draw("git", ctx())).toBeUndefined()
+    expect(draw("git", ctx({ branchDiff: { files: 0, additions: 0, deletions: 0 } }))).toBeUndefined()
+    expect(
+      text(draw("git", ctx({ branchDiff: { files: 2, additions: 9, deletions: 1 }, defaultBranch: "dev" }))),
+    ).toBe("git    2f +9 -1 vs dev")
+  })
+
+  test("the heading says Context unless told otherwise", () => {
+    expect(draw("title", ctx())?.runs[0]).toMatchObject({ text: "Context", bold: true })
+    expect(text(draw("title", ctx(), { text: "Window" }))).toBe("Window")
+  })
+
+  test("a hairline is drawn only between two rows", () => {
+    const rows = (types: string[]) =>
+      buildSegments(
+        ctx(),
+        types.map((type) => ({ type })),
+        { icons: false },
+      ).map((segment) => segment.id)
+    expect(rows(["sep", "in", "sep", "sep", "out", "sep"])).toEqual(["in", "sep#2", "out"])
+  })
+
+  test("the widest row stays inside a sidebar", () => {
+    for (const entry of SIDEBAR_SEGMENTS) {
+      const drawn = buildSegments(ctx({ budget: { spent: 1, cap: 10 } }), [asSegmentConfig(entry)], {
+        icons: false,
+      })[0]
+      if (drawn) expect(segmentWidth(drawn)).toBeLessThanOrEqual(34)
+    }
+  })
+})
+
+describe("what the table reads", () => {
+  test("a proxy's file: { baseline, delta, cap }", () => {
+    expect(parseBudget('{"baseline": 20, "delta": 6.5, "cap": 200}')).toEqual({ spent: 26.5, cap: 200 })
+    expect(parseBudget('{"baseline": 20, "cap": 200}')).toEqual({ spent: 20, cap: 200 })
+    expect(parseBudget('{"baseline": 20, "cap": 0}')).toBeUndefined()
+    expect(parseBudget("not json")).toBeUndefined()
+    expect(parseBudget(undefined)).toBeUndefined()
+  })
+
+  test("the file is read only when a line draws a budget, and a segment's `file` points elsewhere", () => {
+    expect(budgetFile([{ segments: ["in", "git"] }])).toBeUndefined()
+    expect(budgetFile([{ segments: ["spend"] }], "/home/u")).toBe(
+      "/home/u/.cache/opencode-litellm-iap/spend.json",
+    )
+    expect(budgetFile([{ segments: [{ type: "avail", file: "~/b.json" }] }], "/home/u")).toBe(
+      "/home/u/b.json",
+    )
+  })
+
+  test("git runs only for a line that draws it, against the default branch, quoted", () => {
+    expect(wantsBranchDiff([{ segments: ["git.diff"] }])).toBe(false)
+    expect(wantsBranchDiff([{ segments: SIDEBAR_SEGMENTS }])).toBe(true)
+    expect(branchDiffCommand("main")).toBe(`git diff --shortstat "$(git merge-base 'main' HEAD)"`)
+    expect(branchDiffCommand("it's")).toContain(`'it'\\''s'`)
+  })
+})

From e3697e74d345a758a32c36c2ffc5d0770b33c975 Mon Sep 17 00:00:00 2001
From: Codestz 
Date: Sat, 3 Oct 2026 10:10:45 -0500
Subject: [PATCH 13/90] Trust: draw its settings notices as `!` rows, not only
 log them

Co-Authored-By: Claude Opus 5.5 
---
 packages/trust/src/tui/index.tsx | 11 ++++++++++-
 1 file changed, 10 insertions(+), 1 deletion(-)

diff --git a/packages/trust/src/tui/index.tsx b/packages/trust/src/tui/index.tsx
index 0e8139ad..192f5730 100644
--- a/packages/trust/src/tui/index.tsx
+++ b/packages/trust/src/tui/index.tsx
@@ -9,8 +9,10 @@
  * ledger file read and appended, and the two surfaces — the sidebar block and the ledger dialog.
  */
 
+import { warnRows } from "@opencode-cockpit/client/design"
 import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client/feature"
 import { bindingLookup, dualTui, type Host, type Layer } from "@opencode-cockpit/client/host"
+import { noticeText } from "@opencode-cockpit/client/settings"
 import type { BoxRenderable } from "@opentui/core"
 import { createSignal } from "solid-js"
 import { commandOf, type Seen } from "../core/adapt/seen.ts"
@@ -143,8 +145,14 @@ export function createTrustTui({ source = TRUST_PACKAGE }: { source?: string } =
     let inSidebar = settings.sidebar
     const paint = () => {
       drawnAt = sidebarWidth()
+      /**
+       * A setting in Trust's section that is not read — an old name, a value of the wrong kind — is
+       * a `!` row on top, for the session, until the file is fixed. Shown with the block hidden too:
+       * like trouble, a setting that silently does nothing is what nobody would find otherwise.
+       */
+      const warned: Row[] = notices.flatMap((notice) => warnRows(noticeText(notice), drawnAt))
       /** Hidden, the block says nothing — except trouble: a failure always speaks. */
-      const next =
+      const block =
         !inSidebar && !trouble
           ? []
           : sidebarRows({
@@ -158,6 +166,7 @@ export function createTrustTui({ source = TRUST_PACKAGE }: { source?: string } =
               ...(inSidebar ? { project: tally(engine.state, settings, Date.now()) } : {}),
               ...(trouble ? { trouble } : {}),
             })
+      const next = [...warned, ...block]
       /** Only when they changed: new rows rebuild every line of the block. */
       const text = JSON.stringify(next)
       if (text !== said) {

From 9a40855cc41945ce4a3dfef42f17d14f393bd453 Mon Sep 17 00:00:00 2001
From: Codestz 
Date: Sat, 3 Oct 2026 10:10:45 -0500
Subject: [PATCH 14/90] Updater: read updater.updateCheck through the client
 loader; ui.updateCheck is an old name

Co-Authored-By: Claude Opus 5.5 
---
 packages/updater/src/core/settings.ts  | 60 ++++++++++++++------------
 packages/updater/test/registry.test.ts | 26 ++++++++---
 2 files changed, 53 insertions(+), 33 deletions(-)

diff --git a/packages/updater/src/core/settings.ts b/packages/updater/src/core/settings.ts
index 91119d95..c967ff67 100644
--- a/packages/updater/src/core/settings.ts
+++ b/packages/updater/src/core/settings.ts
@@ -1,39 +1,43 @@
 /**
- * The Updater's one setting: whether to check once a day and say so.
+ * The Updater's one setting: whether to check once a day and say so — `updater.updateCheck`.
  *
- * Read through the same files as every bay — `~/.config/opencode-cockpit/config.json`, then the
- * project's `.cockpit.json`, then plugin-entry options, later wins. `ui.updateCheck` is honoured
- * too: it is the switch Shell's own notice had, and someone who turned that off meant it.
+ * Read through the loader every bay reads with (`@opencode-cockpit/client/settings`):
+ * `~/.config/opencode-cockpit/config.json`, then the project's `.cockpit.json`, then plugin-entry
+ * options, later wins. `ui.updateCheck`, the switch Shell's own notice had, is an old name: the
+ * loader recognises it and doctor names the fix, but its value is not read.
  */
 
-import { join } from "node:path"
+import { baySettings, type SettingsNotice } from "@opencode-cockpit/client/settings"
 import type { Disk } from "./disk.ts"
-import { parseJsonc } from "./jsonc.ts"
 
-type Section = { updateCheck?: unknown } | undefined
-
-function fromFile(disk: Disk, path: string): boolean | undefined {
-  const text = disk.read(path)
-  if (text === undefined) return undefined
-  const parsed = parseJsonc(text)
-  if (!parsed.ok) return undefined // a broken config must not take the notice down with it
-  const file = parsed.value as { updater?: Section; ui?: Section } | null
-  const value = file?.updater?.updateCheck ?? file?.ui?.updateCheck
-  return typeof value === "boolean" ? value : undefined
+export interface UpdateCheckWhere {
+  env: Readonly>
+  home: string
+  directory?: string
 }
 
-export function updateCheckEnabled(
+/** The setting, and what about it is worth fixing. A broken file is a notice, never the end of the check. */
+export function updateCheck(
   disk: Disk,
-  where: { env: Readonly>; home: string; directory?: string },
+  where: UpdateCheckWhere,
   options: unknown,
-): boolean {
-  const base = where.env.XDG_CONFIG_HOME || join(where.home, ".config")
-  const layers = [
-    fromFile(disk, join(base, "opencode-cockpit", "config.json")),
-    where.directory ? fromFile(disk, join(where.directory, ".cockpit.json")) : undefined,
-    (options as Section)?.updateCheck,
-  ]
-  let enabled = true
-  for (const layer of layers) if (typeof layer === "boolean") enabled = layer
-  return enabled
+): { enabled: boolean; notices: SettingsNotice[] } {
+  const loaded = baySettings(
+    "updater",
+    { updateCheck: true },
+    {
+      options,
+      where: {
+        env: where.env,
+        home: where.home,
+        ...(where.directory ? { directory: where.directory } : {}),
+        read: (path) => disk.read(path),
+      },
+    },
+  )
+  return { enabled: loaded.config.enabled && loaded.config.updateCheck, notices: loaded.notices }
+}
+
+export function updateCheckEnabled(disk: Disk, where: UpdateCheckWhere, options: unknown): boolean {
+  return updateCheck(disk, where, options).enabled
 }
diff --git a/packages/updater/test/registry.test.ts b/packages/updater/test/registry.test.ts
index 6697ac1f..13e66de1 100644
--- a/packages/updater/test/registry.test.ts
+++ b/packages/updater/test/registry.test.ts
@@ -8,7 +8,7 @@
 import { describe, expect, test } from "bun:test"
 import { memoryDisk } from "../src/core/disk.ts"
 import { fetchAllLatest, fetchLatest, registryFrom } from "../src/core/registry.ts"
-import { updateCheckEnabled } from "../src/core/settings.ts"
+import { updateCheck, updateCheckEnabled } from "../src/core/settings.ts"
 
 const stub = (fn: (url: string, init: RequestInit) => Promise | Response) =>
   ((url: string, init: RequestInit) => fn(String(url), init)) as unknown as typeof fetch
@@ -59,10 +59,26 @@ describe("the daily notice setting", () => {
     expect(updateCheckEnabled(memoryDisk({}), where, undefined)).toBe(true)
   })
 
-  test("Shell's old `ui.updateCheck: false` still silences it", () => {
-    expect(
-      updateCheckEnabled(memoryDisk({ [GLOBAL]: '{"ui":{"updateCheck":false}}' }), where, undefined),
-    ).toBe(false)
+  /** An old name is not read (0.9): it is a notice naming `updater.updateCheck`, and the check stays on. */
+  test("Shell's old `ui.updateCheck` is not read, and says what to write instead", () => {
+    const disk = memoryDisk({ [GLOBAL]: '{"ui":{"updateCheck":false}}' })
+    expect(updateCheckEnabled(disk, where, undefined)).toBe(true)
+    expect(updateCheck(disk, where, undefined).notices).toMatchObject([
+      { bay: "updater", kind: "old", old: "ui.updateCheck", new: "updater.updateCheck" },
+    ])
+  })
+
+  test("comments and trailing commas are fine, as in every bay", () => {
+    const disk = memoryDisk({ [GLOBAL]: '{\n  // quiet\n  "updater": { "updateCheck": false, },\n}' })
+    expect(updateCheckEnabled(disk, where, undefined)).toBe(false)
+  })
+
+  test("a value of the wrong kind is a notice, and the default stands", () => {
+    const disk = memoryDisk({ [GLOBAL]: '{"updater":{"updateCheck":"no"}}' })
+    expect(updateCheck(disk, where, undefined)).toMatchObject({
+      enabled: true,
+      notices: [{ kind: "invalid" }],
+    })
   })
 
   test("the project file beats the global one, and plugin options beat both", () => {

From cfd1bf256d19e18130faea1c0c98e1b33be132a6 Mon Sep 17 00:00:00 2001
From: Codestz 
Date: Sat, 3 Oct 2026 10:08:57 -0500
Subject: [PATCH 15/90] Shell: settings from the shell section, present when
 empty, and no show fewer with one shell

Shell reads through baySettings: the file's shell section and the plugin entry,
JSONC, the order from the top-level sidebar list. Root keys and ui.* are no
longer read; each is a ! row in the Shells block. ui.historyMinutes is
hideFinishedAfterMinutes. With no shells the block draws its heading and
none yet unless hideWhenEmpty. The fold toggle shows only while folding hides
something, and an expansion the list outgrew folds itself back (and is no
longer kept across restarts).

Co-Authored-By: Claude Opus 5.5 
---
 packages/shell/README.md                      |  57 +++---
 packages/shell/src/agent/plugin.ts            |   5 +
 packages/shell/src/agent/tools/shared.ts      |   6 +-
 packages/shell/src/agent/tools/watch-args.ts  |   4 +-
 packages/shell/src/cli/preview.ts             |  77 +++++++-
 packages/shell/src/core/config.ts             | 153 +++++++--------
 packages/shell/src/tui/components/dock.tsx    |  34 ++--
 packages/shell/src/tui/components/sidebar.tsx | 175 ++++++++++++------
 packages/shell/src/tui/index.tsx              |  30 +--
 packages/shell/src/tui/lib/sidebar.ts         |  44 +++++
 packages/shell/src/tui/state/store.ts         |  21 ++-
 packages/shell/test/config-effects.test.ts    |   4 +-
 packages/shell/test/config.test.ts            | 138 ++++++++++----
 packages/shell/test/tui-lib.test.ts           |  29 +++
 packages/shell/test/tui-store.test.ts         |  42 ++++-
 15 files changed, 575 insertions(+), 244 deletions(-)

diff --git a/packages/shell/README.md b/packages/shell/README.md
index 8452f921..ad724705 100644
--- a/packages/shell/README.md
+++ b/packages/shell/README.md
@@ -167,26 +167,32 @@ both halves of the plugin — the agent's tools and the interface:
 ~/.config/opencode-cockpit/config.json   →   /.cockpit.json   →   plugin-entry options
 ```
 
-Later sources win key by key, so a project can override one setting without restating the rest. An
-unreadable or invalid file is ignored rather than fatal: a typo should never stop shells from
-working. (`XDG_CONFIG_HOME` is honoured for the global path.)
+Shell's settings sit in the file's `shell` section. Later sources win key by key, so a project can
+override one setting without restating the rest. Comments and trailing commas are fine; an
+unreadable file is ignored rather than fatal — a typo should never stop shells from working.
+(`XDG_CONFIG_HOME` is honoured for the global path.)
 
-```json
+```jsonc
 {
-  "kinds": { "e2e": "playwright|cypress", "infra": "^(terraform|pulumi)\\b" },
-  "watch": {
-    "auto": false,
-    "presets": { "e2e": { "done": "\\d+ passed", "fail": "\\d+ failed", "ignoreCase": true } }
-  },
-  "defaults": { "logFile": true, "timeoutSeconds": 900 },
-  "notify": { "exit": true, "watch": true, "tailLines": 15 },
-  "guidance": true,
-  "listRunningShells": 15,
-  "ui": { "dockHeight": 16, "dockOpen": true, "historyMinutes": 60, "colors": true }
+  "shell": {
+    "kinds": { "e2e": "playwright|cypress", "infra": "^(terraform|pulumi)\\b" },
+    "watch": {
+      "auto": false,
+      "presets": { "e2e": { "done": "\\d+ passed", "fail": "\\d+ failed", "ignoreCase": true } }
+    },
+    "defaults": { "logFile": true, "timeoutSeconds": 900 },
+    "notify": { "exit": true, "watch": true, "tailLines": 15 },
+    "guidance": true,
+    "listRunningShells": 15,
+    "dockHeight": 16,
+    "dockOpen": true,
+    "hideFinishedAfterMinutes": 60,
+    "colors": true
+  }
 }
 ```
 
-| Section | Meaning |
+| Key | Meaning |
 |---|---|
 | `kinds` | Extra shell categories, or overrides, as name → regex matched against the command. Drives the badges in the sidebar, panel and `shell_list`, so you can group your own stack (`e2e`, `infra`, `worker`) instead of the built-ins. |
 | `watch.presets` | Your own watch rules, or replacements for built-ins, keyed by name. A rule is `{ done?, fail?, ok?, idleSeconds?, ignoreCase? }` — patterns are regular expressions matched against each output line. The agent can then ask for `watch: "e2e"`. |
@@ -196,27 +202,30 @@ working. (`XDG_CONFIG_HOME` is honoured for the global path.)
 | `notify` | What may interrupt the agent — `exit`, `watch`, and `tailLines` (output lines included in an exit message). |
 | `guidance` | The system-prompt paragraph that teaches the agent when to use shells. `false` saves ~120 tokens per request, at the cost of a model that uses shells less well. |
 | `listRunningShells` | How many running shells are listed in the system prompt each turn (~20 tokens each). `0` disables it; the agent can still call `shell_list`. |
-| `ui` | Interface only: `dockHeight`, `dockOpen` (set it and the panel always starts that way; leave it out and it starts as you last left it), `sidebarRows`, `historyMinutes`, `colors`, `defaultView` (`"screen"` or `"log"`), `keybinds`, `updateCheck`. |
+| Interface | `dockHeight` (14), `dockOpen` (set it and the panel always starts that way; leave it out and it starts as you last left it), `sidebarRows` (5), `hideWhenEmpty` (`false`: with no shells the block says `none yet` under its heading; `true` draws nothing), `hideFinishedAfterMinutes` (30: how long a finished shell stays in the folded views), `colors`, `defaultView` (`"screen"` or `"log"`), `keybinds`, `enabled`. |
+
+Where the block sits is the top-level `"sidebar"` list's to say (`["status", "subagents", "shell",
+"trail", "trust"]` by default). Before 0.9 these keys sat at the file's root, with the interface's
+under `ui`; those places are no longer read — the Shells block shows a `!` row naming the new key
+(`ui.historyMinutes` is now `hideFinishedAfterMinutes`, `ui.updateCheck` is `updater.updateCheck`),
+and `/cockpit-setup` fixes it.
 
-The same settings can go on the plugin entry instead, which is handy for one-offs and for machines
-where you'd rather keep everything in `tui.json`/`opencode.json`:
+The same keys can go on the plugin entry instead, which wins over both files — handy for one-offs:
 
 ```json
 {
   "plugin": [
     ["@opencode-cockpit/shell", {
-      "ui": { "dockHeight": 16 },
+      "dockHeight": 16,
       "keybinds": { "cockpit.shells.dock": "j", "cockpit.shells.console": "k" }
     }]
   ]
 }
 ```
 
-`ui` keys also work spelled flat at the top level (`{ "dockHeight": 16 }`), as they did before the
-config file existed. Using `opencode-cockpit` instead of the standalone package? Put the same
-object under `"shell"`: `["opencode-cockpit", { "shell": { "ui": { "dockHeight": 16 } } }]`.
-Interface settings must be reachable from `tui.json`, agent settings from `opencode.json` — which
-is exactly why the config file exists.
+Using `opencode-cockpit` instead of the standalone package? Put the same object under `"shell"`:
+`["opencode-cockpit", { "shell": { "dockHeight": 16 } }]`. The config file is still the better
+place: interface settings on an entry must be in `tui.json`, agent settings in `opencode.json`.
 
 | Environment variable | Default | Purpose |
 |---|---|---|
diff --git a/packages/shell/src/agent/plugin.ts b/packages/shell/src/agent/plugin.ts
index 8ad3f801..f160da8b 100644
--- a/packages/shell/src/agent/plugin.ts
+++ b/packages/shell/src/agent/plugin.ts
@@ -72,6 +72,11 @@ async function shellParts(host: ServerHost, options?: unknown): Promise
   /** Settings that shape defaults, kinds and watch presets. */
-  config?: CockpitConfig
+  config?: ShellConfig
   /** Human title of an OpenCode session, for telling agents which session started a shell. */
   sessionTitle?(sessionID: string): Promise
   /**
@@ -37,7 +37,7 @@ export interface ToolDeps {
 export interface ToolKit {
   deps: ToolDeps
   /** Settings from ~/.config/opencode-cockpit/config.json, .cockpit.json and plugin options. */
-  config: CockpitConfig
+  config: ShellConfig
   client: CockpitClient
   peek(info: ShellInfo, tail?: number): Promise
   sessionLabel(shell: ShellInfo, ctx: ToolContext): Promise
diff --git a/packages/shell/src/agent/tools/watch-args.ts b/packages/shell/src/agent/tools/watch-args.ts
index e3358a77..1249892f 100644
--- a/packages/shell/src/agent/tools/watch-args.ts
+++ b/packages/shell/src/agent/tools/watch-args.ts
@@ -1,5 +1,5 @@
 import type { WatchRule } from "@opencode-cockpit/protocol/shell"
-import type { CockpitConfig } from "../../core/config.ts"
+import type { ShellConfig } from "../../core/config.ts"
 
 export type WatchRequest = { preset?: string; rule?: WatchRule }
 
@@ -35,7 +35,7 @@ const RULE_KEYS = new Set(["done", "fail", "ok", "ignoreCase", "idleSeconds"])
  * parsed the way the schema says) that a string which looks like an object is parsed rather than
  * handed on as a preset name nobody has.
  */
-export function watchArgs(watch: unknown, config: CockpitConfig): WatchRequest {
+export function watchArgs(watch: unknown, config: ShellConfig): WatchRequest {
   const rule = asWatchRule(watch)
   if (rule) return { rule }
   const preset = watch === true || watch == null ? "auto" : String(watch)
diff --git a/packages/shell/src/cli/preview.ts b/packages/shell/src/cli/preview.ts
index d590cad9..303e4dea 100644
--- a/packages/shell/src/cli/preview.ts
+++ b/packages/shell/src/cli/preview.ts
@@ -12,10 +12,18 @@
  */
 
 import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
-import { HEADING_GAP, type State as Shared, summaryRuns } from "@opencode-cockpit/client/design"
+import {
+  emptyBlock,
+  FEWER_TEXT,
+  HEADING_GAP,
+  moreText,
+  type State as Shared,
+  summaryRuns,
+  warnRows,
+} from "@opencode-cockpit/client/design"
 import type { ScreenResult, ShellInfo } from "@opencode-cockpit/protocol/shell"
 import { type ConsoleInput, consoleRows, type Row, type Run } from "../tui/lib/console.ts"
-import { sidebarRow } from "../tui/lib/sidebar.ts"
+import { fold, sidebarRow } from "../tui/lib/sidebar.ts"
 import {
   badgeText,
   displayCommand,
@@ -94,7 +102,50 @@ function fitRow(row: Row, width: number): Row {
 
 // --- the sidebar ---------------------------------------------------------------------------------
 
-function sidebar(list: readonly ShellInfo[], width: number): Row[] {
+/** A settings notice as the block draws it (client/settings `noticeText`), for `--notice`. */
+const NOTICE = 'settings: "ui.historyMinutes" is no longer read — run /cockpit-setup'
+
+interface SidebarState {
+  /** Expanded by a click on `+ N more`. */
+  showAll?: boolean
+  hideWhenEmpty?: boolean
+  notices?: readonly string[]
+}
+
+function sidebar(list: readonly ShellInfo[], width: number, state: SidebarState = {}): Row[] {
+  /** As `components/sidebar.tsx` draws them: the design module's rows for an empty block and a notice. */
+  const warnings = (state.notices ?? []).flatMap((text) => warnRows(text, width, 5).map((row) => row as Row))
+  if (list.length === 0)
+    return [
+      ...emptyBlock("Shells", width, state.hideWhenEmpty === true && warnings.length === 0).map(
+        (row) => row as Row,
+      ),
+      ...warnings,
+    ]
+  const folding = fold({
+    all: list,
+    folded: list.filter((shell) => kindOf(shell) === "run" || kindOf(shell) === "fail"),
+    showAll: state.showAll === true,
+    rows: 5,
+    expandedRows: 12,
+  })
+  const toggle: Row[] = !folding.toggle
+    ? []
+    : [
+        fitRow(
+          [
+            {
+              text: `  ${folding.toggle === "fewer" ? FEWER_TEXT : moreText(folding.more)}`,
+              tone: "muted",
+            },
+          ],
+          width,
+        ),
+      ]
+  return [...blockRows(folding.shown, list, width), ...toggle, ...warnings]
+}
+
+function blockRows(shown: readonly ShellInfo[], list: readonly ShellInfo[], width: number): Row[] {
   /** As `components/sidebar.tsx` draws it: the name left, every count flush right, no row of air. */
   const tally: Partial> = {}
   for (const shell of list) tally[STATE[kindOf(shell)]] = (tally[STATE[kindOf(shell)]] ?? 0) + 1
@@ -108,7 +159,7 @@ function sidebar(list: readonly ShellInfo[], width: number): Row[] {
   return [
     fitRow(heading, width),
     ...Array.from({ length: HEADING_GAP }, () => fitRow([], width)),
-    ...list.map((shell): Row => {
+    ...shown.map((shell): Row => {
       const row = sidebarRow(shell, SAMPLE_NOW, 2, width)
       const kind = hex(kindColor(theme, kindOf(shell)))
       return [
@@ -258,6 +309,7 @@ if (args.includes("--help") || args.includes("-h")) {
       "",
       "  --part      one surface (default: all three)",
       "  --width     the sidebar's width (default 38, about what OpenCode gives it)",
+      "  --notice    the sidebar with a settings notice in it",
       "  --columns   the dock's and the console's width (default: this terminal, 60 to 140)",
       "  --state     the console in one state (default: every one):",
       ...Object.entries(STATES).map(([name, about]) => `                ${name.padEnd(9)}${about}`),
@@ -288,8 +340,21 @@ const section = (title: string, rows: Row[]) => {
   out.push("", title, "")
   for (const row of rows) out.push(paint(row))
 }
-if (!part || part === "sidebar")
-  section(`Sidebar — ${sidebarWidth} columns`, sidebar(SAMPLE_LIST, sidebarWidth))
+if (!part || part === "sidebar") {
+  const notices = args.includes("--notice") ? [NOTICE] : []
+  section(`Sidebar — ${sidebarWidth} columns`, sidebar(SAMPLE_LIST, sidebarWidth, { notices }))
+  section("Sidebar — expanded", sidebar(SAMPLE_LIST, sidebarWidth, { showAll: true, notices }))
+  /** Expanded over six, down to one: nothing to fold, so no `− fewer`. */
+  section(
+    "Sidebar — one shell, after it was expanded",
+    sidebar(SAMPLE_LIST.slice(0, 1), sidebarWidth, { showAll: true, notices }),
+  )
+  section("Sidebar — no shells yet", sidebar([], sidebarWidth, { notices }))
+  section(
+    "Sidebar — no shells yet, hideWhenEmpty",
+    sidebar([], sidebarWidth, { hideWhenEmpty: true, notices }),
+  )
+}
 if (!part || part === "dock")
   section(`Dock — ${columns} columns`, dock(SAMPLE_LIST, SHELLS.running, DEV_SCREEN, columns))
 if (!part || part === "console")
diff --git a/packages/shell/src/core/config.ts b/packages/shell/src/core/config.ts
index d0abd5ab..c39741df 100644
--- a/packages/shell/src/core/config.ts
+++ b/packages/shell/src/core/config.ts
@@ -1,18 +1,21 @@
-import { existsSync, readFileSync } from "node:fs"
-import { homedir } from "node:os"
-import { join } from "node:path"
+import { baySettings, type SettingsNotice } from "@opencode-cockpit/client/settings"
 import type { WatchRule } from "@opencode-cockpit/protocol/shell"
 
 /**
- * Settings, read from one file so they are written once instead of twice (OpenCode keeps agent and
- * TUI plugins in separate configs). Precedence, lowest first:
+ * Shell's settings, through the loader every bay shares (`@opencode-cockpit/client/settings`), read
+ * by both halves so they are written once instead of twice (OpenCode keeps agent and TUI plugins in
+ * separate configs). Precedence, lowest first:
  *
  *   ~/.config/opencode-cockpit/config.json  →  /.cockpit.json  →  plugin-entry options
  *
- * Everything is optional, and an unreadable or invalid file is ignored rather than fatal: a typo in
- * a config should never stop shells from working.
+ * Only the `shell` section of a file is read. Shell's keys used to sit at the file's root, with the
+ * interface's under `ui`; those are no longer read, and each one found is a notice naming the new
+ * place (drawn in the Shells block, and by doctor). Everything is optional, and an unreadable or
+ * invalid file is ignored rather than fatal: a typo in a config should never stop shells from working.
  */
-export interface CockpitConfig {
+export interface ShellConfig {
+  /** Off switch for this bay, both halves, wherever it is written. `features.shell: false` too. */
+  enabled?: boolean
   /** Health watching (see shell_watch). */
   watch?: {
     /** Attach a matching preset to every new shell without being asked. Off by default. */
@@ -57,96 +60,66 @@ export interface CockpitConfig {
   guidance?: boolean
   /** Running shells listed in the system prompt each turn; 0 disables (~20 tokens each). */
   listRunningShells?: number
-  /** Interface options; also settable on the tui.json plugin entry. */
-  ui?: {
-    dockHeight?: number
-    dockOpen?: boolean
-    sidebarRows?: number
-    /**
-     * Where this bay's block sits among the others in a shared surface. Lower draws first.
-     *
-     * Two bays in one sidebar draw in the order they registered, and until now that order was a
-     * constant nobody could reach: shells above the statusline, whatever you would rather see.
-     */
-    sidebarOrder?: number
-    historyMinutes?: number
-    colors?: boolean
-    defaultView?: "screen" | "log"
-    keybinds?: Record
-    updateCheck?: boolean
-  }
+  /** The panel at the foot of the window: its height in rows (at most 45% of the window). */
+  dockHeight?: number
+  /** Whether the panel starts open; unset, it starts as you last left it. */
+  dockOpen?: boolean
+  /** Shells in the sidebar before the rest fold into `+ N more`. */
+  sidebarRows?: number
+  /** Draw no Shells block at all while there are none. Default false: the heading and `none yet`. */
+  hideWhenEmpty?: boolean
+  /** Minutes a finished shell stays in the folded views after it ends. Was `ui.historyMinutes`. */
+  hideFinishedAfterMinutes?: number
+  /** Paint the colours programs print. */
+  colors?: boolean
+  /** What the console opens on: the program's screen, or its clean log. */
+  defaultView?: "screen" | "log"
+  keybinds?: Record
 }
 
-export const CONFIG_FILE = "config.json"
-export const PROJECT_FILE = ".cockpit.json"
+/** What every source left unset becomes. Their kinds are what a written value is checked against. */
+export const DEFAULTS = {
+  guidance: true,
+  listRunningShells: 15,
+  dockHeight: 14,
+  hideFinishedAfterMinutes: 30,
+  colors: true,
+  defaultView: "screen" as "screen" | "log",
+  watch: {} as NonNullable,
+  kinds: {} as Record,
+  defaults: {} as NonNullable,
+  lifecycle: {} as NonNullable,
+  notify: {} as NonNullable,
+}
 
-export function globalConfigPath(env: Record = process.env): string {
-  const base = env.XDG_CONFIG_HOME ?? join(env.HOME ?? homedir(), ".config")
-  return join(base, "opencode-cockpit", CONFIG_FILE)
+export interface LoadedShell {
+  /** Every source merged over the defaults. */
+  config: ShellConfig
+  /** The block's place, from the top-level `sidebar` list. */
+  order: number
+  /** Settings to fix, for a `!` row in the block. */
+  notices: SettingsNotice[]
 }
 
-/** Reads and merges every source. `options` is the plugin entry's own options object. */
-export function loadConfig(
+/** Reads and merges every source. `options` is the plugin entry's own options object. Never throws. */
+export function loadShell(
   directory: string,
   options?: unknown,
   env: Record = process.env,
-): CockpitConfig {
-  return mergeConfig(
-    mergeConfig(readConfigFile(globalConfigPath(env)), readConfigFile(join(directory, PROJECT_FILE))),
-    asConfig(options),
-  )
-}
-
-export function readConfigFile(path: string): CockpitConfig {
-  if (!existsSync(path)) return {}
-  try {
-    return asConfig(JSON.parse(readFileSync(path, "utf8")))
-  } catch {
-    return {} // a broken config must not take shells down with it
-  }
-}
-
-/** Section-wise merge: later sources win key by key, and never lose a whole section. */
-export function mergeConfig(base: CockpitConfig, over: CockpitConfig): CockpitConfig {
-  return {
-    ...base,
-    ...over,
-    watch: { ...base.watch, ...over.watch, presets: { ...base.watch?.presets, ...over.watch?.presets } },
-    kinds: { ...base.kinds, ...over.kinds },
-    defaults: { ...base.defaults, ...over.defaults },
-    lifecycle: { ...base.lifecycle, ...over.lifecycle },
-    notify: { ...base.notify, ...over.notify },
-    ui: { ...base.ui, ...over.ui, keybinds: { ...base.ui?.keybinds, ...over.ui?.keybinds } },
-  }
+): LoadedShell {
+  const loaded = baySettings("shell", DEFAULTS, { options, where: { directory, env } })
+  const config = loaded.config as ShellConfig & typeof loaded.config
+  /** A value outside the two the console knows is the default, not a third view. */
+  if (config.defaultView !== "screen" && config.defaultView !== "log") config.defaultView = "screen"
+  if (config.dockOpen !== undefined && typeof config.dockOpen !== "boolean") delete config.dockOpen
+  return { config, order: loaded.order, notices: loaded.notices }
 }
 
-/**
- * Plugin-entry options were flat before the config file existed (`{ dockHeight: 16 }`), so those
- * keys still work and are read as `ui`.
- */
-function asConfig(input: unknown): CockpitConfig {
-  if (!input || typeof input !== "object") return {}
-  const raw = input as Record
-  const config: CockpitConfig = {}
-  for (const key of ["watch", "kinds", "defaults", "lifecycle", "notify", "ui"] as const) {
-    const value = raw[key]
-    if (value && typeof value === "object") Object.assign(config, { [key]: value })
-  }
-  if (typeof raw.guidance === "boolean") config.guidance = raw.guidance
-  if (typeof raw.listRunningShells === "number") config.listRunningShells = raw.listRunningShells
-
-  const legacy: CockpitConfig["ui"] = {}
-  for (const key of [
-    "dockHeight",
-    "dockOpen",
-    "sidebarRows",
-    "sidebarOrder",
-    "historyMinutes",
-    "updateCheck",
-  ] as const) {
-    if (raw[key] !== undefined) Object.assign(legacy, { [key]: raw[key] })
-  }
-  if (raw.keybinds && typeof raw.keybinds === "object")
-    legacy.keybinds = raw.keybinds as Record
-  return Object.keys(legacy).length > 0 ? mergeConfig(config, { ui: legacy }) : config
+/** Every source merged over the defaults: what the agent's half reads. */
+export function loadConfig(
+  directory: string,
+  options?: unknown,
+  env: Record = process.env,
+): ShellConfig {
+  return loadShell(directory, options, env).config
 }
diff --git a/packages/shell/src/tui/components/dock.tsx b/packages/shell/src/tui/components/dock.tsx
index 216f8f60..43cd78ca 100644
--- a/packages/shell/src/tui/components/dock.tsx
+++ b/packages/shell/src/tui/components/dock.tsx
@@ -10,6 +10,7 @@ import {
 import type { Host } from "@opencode-cockpit/client/host"
 import { useTerminalDimensions } from "@opentui/solid"
 import { createMemo, For, Show } from "solid-js"
+import { fold } from "../lib/sidebar.ts"
 import {
   displayCommand,
   kindColor,
@@ -44,10 +45,17 @@ export function Dock(props: DockProps) {
   const bodyRows = () => Math.max(2, props.height - 3)
   // Tabs share one row: keep them to what fits, the rest lives behind the "N more" chip.
   const tabLimit = () => Math.max(1, Math.floor((dims().width - 26) / 30))
-  const tabs = createMemo(() =>
-    (props.store.showAll() ? props.store.shells() : props.store.visible()).slice(0, tabLimit()),
+  /** The sidebar's fold, at the dock's width: the toggle only while folding hides something. */
+  const folding = createMemo(() =>
+    fold({
+      all: props.store.shells(),
+      folded: props.store.folded(),
+      showAll: props.store.showAll(),
+      rows: tabLimit(),
+      expandedRows: tabLimit(),
+    }),
   )
-  const overflow = createMemo(() => props.store.shells().length - tabs().length)
+  const tabs = () => folding().shown
   const bodyCols = () => Math.max(10, dims().width - 4)
   const body = createMemo(() => tailLines(screen()?.text, bodyRows(), bodyCols()))
   const bodyRuns = createMemo(() =>
@@ -106,15 +114,17 @@ export function Dock(props: DockProps) {
               )
             }}
           
-           0 || props.store.showAll()}>
-             props.store.toggleAll()}
-            >
-              {props.store.showAll() ? FEWER_TEXT : moreText(overflow())}
-            
+          
+            {(toggle) => (
+               props.store.toggleAll()}
+              >
+                {toggle() === "more" ? moreText(folding().more) : FEWER_TEXT}
+              
+            )}
           
         
         
diff --git a/packages/shell/src/tui/components/sidebar.tsx b/packages/shell/src/tui/components/sidebar.tsx
index 75a35199..70e99c2e 100644
--- a/packages/shell/src/tui/components/sidebar.tsx
+++ b/packages/shell/src/tui/components/sidebar.tsx
@@ -1,9 +1,18 @@
 /** @jsxImportSource @opentui/solid */
-import { FEWER_TEXT, HEADING_GAP, moreText, type State, summaryRuns } from "@opencode-cockpit/client/design"
+import {
+  emptyBlock,
+  FEWER_TEXT,
+  HEADING_GAP,
+  moreText,
+  type State,
+  summaryRuns,
+  type ToneRun,
+  warnRows,
+} from "@opencode-cockpit/client/design"
 import type { Host } from "@opencode-cockpit/client/host"
 import type { BoxRenderable } from "@opentui/core"
-import { createMemo, createSignal, For, Show } from "solid-js"
-import { sidebarRow } from "../lib/sidebar.ts"
+import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
+import { fold, sidebarRow } from "../lib/sidebar.ts"
 import { kindColor, kindOf, STATE, toneColor, watchColor } from "../lib/view.ts"
 import type { ShellStore } from "../state/store.ts"
 
@@ -14,13 +23,19 @@ export interface SidebarProps {
   rows?: number
   /** Rows shown while expanded, so a hundred shells can never push the sidebar over. */
   expandedRows?: number
+  /** Draw nothing at all while there are no shells (`hideWhenEmpty`); else the heading and `none yet`. */
+  hideWhenEmpty?: boolean
+  /** Settings to fix, one `!` row each (client/settings `noticeText`); they show even when empty and hidden. */
+  notices?: readonly string[]
   onOpen: (id: string) => void
   consoleShortcut: () => string
 }
 
+/** Rows a settings notice may wrap to: its last words are the fix, and a narrow column needs four. */
+const NOTICE_ROWS = 5
+
 export function SidebarShells(props: SidebarProps) {
   const theme = () => props.api.theme.current
-  const limit = () => Math.max(1, props.store.showAll() ? (props.expandedRows ?? 12) : (props.rows ?? 5))
 
   /**
    * Every count, in the words and tones the Subagents heading uses beside it (client/design): it read
@@ -36,10 +51,20 @@ export function SidebarShells(props: SidebarProps) {
     return out
   })
 
-  // Running shells and recent failures first; everything else only when expanded.
-  const candidates = createMemo(() => (props.store.showAll() ? props.store.shells() : props.store.visible()))
-  const shown = createMemo(() => candidates().slice(0, limit()))
-  const overflow = createMemo(() => props.store.shells().length - shown().length)
+  /** Running shells and recent failures first; everything else only when expanded (`fold`). */
+  const folding = createMemo(() =>
+    fold({
+      all: props.store.shells(),
+      folded: props.store.folded(),
+      showAll: props.store.showAll(),
+      rows: props.rows ?? 5,
+      expandedRows: props.expandedRows ?? 12,
+    }),
+  )
+  /** Expanded for a list that fits folded now: fold back, so the next time it overflows it starts folded. */
+  createEffect(() => {
+    if (folding().stale) props.store.foldAll()
+  })
 
   /**
    * The width the sidebar gives the block, so each row can be exactly that wide and its facts flush
@@ -63,61 +88,103 @@ export function SidebarShells(props: SidebarProps) {
   })
 
   const counts = createMemo(() => summaryRuns(tally(), Math.max(8, width() - "Shells ".length)))
+  const warnings = createMemo(() =>
+    (props.notices ?? []).flatMap((text) => warnRows(text, width(), NOTICE_ROWS)),
+  )
+  const empty = () => props.store.shells().length === 0
+  /**
+   * With no shells the block still says it exists — the heading, and `none yet` in the row the first
+   * shell will take — unless asked for silence; a notice always speaks.
+   */
+  const emptyRows = createMemo(() =>
+    emptyBlock("Shells", width(), props.hideWhenEmpty === true && warnings().length === 0),
+  )
+
+  /** Rows the shared design module drew, coloured here: the empty block, and the notices. */
+  const toneRows = (rows: () => readonly ToneRun[][]) => (
+    
+      {(row) => (
+        
+          
+            {(run) => (
+              
+                {run.bold ? {run.text} : run.text}
+              
+            )}
+          
+        
+      )}
+    
+  )
 
   return (
-     0}>
+     0}>
        {
           block = el
         }}
         onSizeChange={() => setResized((n) => n + 1)}
       >
-        {/*
-         * The title on the left and what needs your eye flush right, as the Subagents heading draws
-         * it; then the row of air every block has under its heading.
-         */}
-        
-          
-            Shells
-          
-          
-          
-            
-              {(part) => {part.text}}
-            
-          
-        
-        
-          {(shell) => {
-            const row = () => sidebarRow(shell, props.store.now(), props.store.frame(), width())
-            const colour = () => kindColor(theme(), kindOf(shell))
-            return (
-              // Mouse-up, not mouse-down: the host dialog closes on the mouse-up that follows, so
-              // opening the console on press would need the button held down.
-               props.onOpen(shell.id)}>
-                {row().rule}
-                
-                  {row().label}
-                
-                {row().title}
-                {row().watch}
-                {row().detail}
+        
+              {toneRows(emptyRows)}
+              {toneRows(warnings)}
+            
+          }
+        >
+          {/*
+           * The title on the left and what needs your eye flush right, as the Subagents heading draws
+           * it; then the row of air every block has under its heading.
+           */}
+          
+            
+              Shells
+            
+            
+            
+              
+                {(part) => {part.text}}
+              
+            
+          
+          
+            {(shell) => {
+              const row = () => sidebarRow(shell, props.store.now(), props.store.frame(), width())
+              const colour = () => kindColor(theme(), kindOf(shell))
+              return (
+                // Mouse-up, not mouse-down: the host dialog closes on the mouse-up that follows, so
+                // opening the console on press would need the button held down.
+                 props.onOpen(shell.id)}>
+                  {row().rule}
+                  
+                    {row().label}
+                  
+                  {row().title}
+                  {row().watch}
+                  {row().detail}
+                
+              )
+            }}
+          
+          {/*
+           * Said as the Subagents block says it (`+ 3 more`), under the names rather than under the
+           * marks; a click still unfolds it here, and folds it back. Only while folding hides
+           * something: one shell has nothing to fold.
+           */}
+          
+            {(toggle) => (
+               props.store.toggleAll()}>
+                {toggle() === "more"
+                  ? `  ${moreText(folding().more)}`
+                  : toggle() === "both"
+                    ? `  ${moreText(folding().more)} · ${props.consoleShortcut()} console`
+                    : `  ${FEWER_TEXT}`}
               
-            )
-          }}
-        
-        {/*
-         * Said as the Subagents block says it (`+ 3 more`), under the names rather than under the
-         * marks; a click still unfolds it here, and folds it back.
-         */}
-         0 || props.store.showAll()}>
-           props.store.toggleAll()}>
-            {props.store.showAll()
-              ? overflow() > 0
-                ? `  ${moreText(overflow())} · ${props.consoleShortcut()} console`
-                : `  ${FEWER_TEXT}`
-              : `  ${moreText(overflow())}`}
-          
+            )}
+          
+          {toneRows(warnings)}
         
       
     
diff --git a/packages/shell/src/tui/index.tsx b/packages/shell/src/tui/index.tsx
index 7b26604b..323eaca5 100644
--- a/packages/shell/src/tui/index.tsx
+++ b/packages/shell/src/tui/index.tsx
@@ -2,11 +2,11 @@
 
 import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client"
 import { bindingLookup, dualTui, type Host, onPaste } from "@opencode-cockpit/client/host"
-import { sidebarOrder } from "@opencode-cockpit/client/sidebar"
+import { noticeText } from "@opencode-cockpit/client/settings"
 import type { BoxRenderable } from "@opentui/core"
 import { createSignal } from "solid-js"
 import { createClient } from "../connect.ts"
-import { type CockpitConfig, loadConfig } from "../core/config.ts"
+import { loadShell, type ShellConfig } from "../core/config.ts"
 import { Console } from "./components/console.tsx"
 import { Dock } from "./components/dock.tsx"
 import { SidebarShells } from "./components/sidebar.tsx"
@@ -27,8 +27,8 @@ const DEFAULT_KEYS = {
   "cockpit.shells.console": "i",
 }
 
-/** Interface settings; the `ui` section of the config file (see core/config.ts). */
-export type ShellTuiOptions = NonNullable
+/** Shell's settings: the `shell` section of the config files, then the plugin entry (core/config.ts). */
+export type ShellTuiOptions = ShellConfig
 
 const SHELL_PACKAGE = "@opencode-cockpit/shell"
 
@@ -52,15 +52,19 @@ export function createShellTui({ source = SHELL_PACKAGE }: { source?: string } =
 }
 
 const shellTui = async (api: Host, rawOptions?: unknown) => {
-  // Settings come from the shared config file; plugin-entry options still win, flat or under "ui".
+  // Settings come from the shared config files' `shell` section; plugin-entry options win.
   const log = api.log.child("shell")
-  const config = loadConfig(api.state.path.directory, rawOptions)
-  const options: ShellTuiOptions = config.ui ?? {}
+  const { config: options, order, notices } = loadShell(api.state.path.directory, rawOptions)
+  for (const notice of notices) log.warn("settings", { file: notice.file, notice: notice.text })
+  if (options.enabled === false) {
+    log.info("off in the settings")
+    return
+  }
   const client = createClient("opencode-cockpit/tui")
-  const store = createShellStore(api, client, { historyMinutes: options.historyMinutes })
+  const store = createShellStore(api, client, { historyMinutes: options.hideFinishedAfterMinutes })
   const keys = bindingLookup({ ...DEFAULT_KEYS, ...options.keybinds })
 
-  // An explicit `ui.dockOpen` says how the panel should start; without one, whatever you last left
+  // An explicit `dockOpen` says how the panel should start; without one, whatever you last left
   // it as. Remembered state that overrides a written setting is a setting that appears to do nothing.
   const [dockOpen, setDockOpen] = createSignal(
     options.dockOpen ?? api.kv.get("cockpit.dock.open", false),
@@ -423,10 +427,10 @@ const shellTui = async (api: Host, rawOptions?: unknown) => {
 
   api.slots.register({
     /**
-     * The sidebar on its own: its place there (statusline, subagents, then shells — `"sidebar"` in
-     * Cockpit's config moves it) is not the dock's place at the foot of the window.
+     * The sidebar on its own: its place there (status, subagents, then shells — the top-level
+     * `"sidebar"` list in Cockpit's config moves it) is not the dock's place at the foot of the window.
      */
-    order: sidebarOrder("shell", 170, options.sidebarOrder, { directory: api.state.path.directory }),
+    order,
     slots: {
       sidebar_content() {
         return (
@@ -434,6 +438,8 @@ const shellTui = async (api: Host, rawOptions?: unknown) => {
             api={api}
             store={store}
             rows={options.sidebarRows}
+            hideWhenEmpty={options.hideWhenEmpty === true}
+            notices={notices.map(noticeText)}
             onOpen={(id) => openConsole(id)}
             consoleShortcut={() => shortcut("cockpit.shells.console")}
           />
diff --git a/packages/shell/src/tui/lib/sidebar.ts b/packages/shell/src/tui/lib/sidebar.ts
index 7822d073..763c2096 100644
--- a/packages/shell/src/tui/lib/sidebar.ts
+++ b/packages/shell/src/tui/lib/sidebar.ts
@@ -49,6 +49,50 @@ export function sidebarCounts(list: readonly ShellInfo[]): string {
     .join(" · ")
 }
 
+export interface FoldInput {
+  /** Every shell the block is about, in order. */
+  all: readonly T[]
+  /** The ones the folded view keeps — running, recent failures, the selection — in order. */
+  folded: readonly T[]
+  /** Expanded by a click on `+ N more`. */
+  showAll: boolean
+  /** Rows folded, and rows expanded. */
+  rows: number
+  expandedRows: number
+}
+
+export interface Fold {
+  shown: T[]
+  /** Shells not shown: what `+ N more` counts. */
+  more: number
+  /**
+   * The row under the shells: `more` offers to unfold, `fewer` to fold back, `both` is expanded and
+   * still cut by the expanded limit. None when folding would hide nothing.
+   */
+  toggle?: "more" | "fewer" | "both"
+  /**
+   * Expanded, but the folded view would show every shell now: the expansion should reset itself. It
+   * outlived the shells it was for — expanded at six, down to one, the block still offered
+   * `− fewer` for a list nothing could fold.
+   */
+  stale: boolean
+}
+
+/**
+ * What the block shows, and whether it offers to fold or unfold. The toggle is there only while
+ * folding hides something; expanded with nothing left to hide, the expansion is stale.
+ */
+export function fold(input: FoldInput): Fold {
+  const rows = Math.max(1, input.rows)
+  const folded = input.folded.slice(0, rows)
+  const folds = input.all.length > folded.length
+  const expanded = input.showAll && folds
+  const shown = expanded ? input.all.slice(0, Math.max(rows, input.expandedRows)) : folded
+  const more = input.all.length - shown.length
+  const toggle = !folds ? undefined : !expanded ? "more" : more > 0 ? "both" : "fewer"
+  return { shown, more, ...(toggle ? { toggle } : {}), stale: input.showAll && !folds }
+}
+
 /** Every part's text, in the order the row draws them. */
 export const sidebarRowText = (row: SidebarRow): string =>
   `${row.rule}${row.label}${row.title}${row.watch}${row.detail}`
diff --git a/packages/shell/src/tui/state/store.ts b/packages/shell/src/tui/state/store.ts
index 520c3efe..10a88afe 100644
--- a/packages/shell/src/tui/state/store.ts
+++ b/packages/shell/src/tui/state/store.ts
@@ -16,8 +16,12 @@ export interface ShellStore {
   /** Ordered and folded for display: running, recent failures, plus the selection. */
   visible: Accessor
   hidden: Accessor
+  /** What the folded view keeps, whether or not it is expanded now: running, recent failures, the selection. */
+  folded: Accessor
   showAll: Accessor
   toggleAll(): void
+  /** Folded again, because nothing is left to unfold (`fold` in lib/sidebar.ts says when). */
+  foldAll(): void
   /** Which shells the panel is about: the conversation you are in, or the whole project. */
   scope: Accessor
   toggleScope(): void
@@ -52,7 +56,11 @@ export function createShellStore(api: Host, client: CockpitClient, options: Stor
     const [now, setNow] = createSignal(Date.now())
     const [frame, setFrame] = createSignal(0)
     const [selectedId, setSelectedId] = createSignal()
-    const [showAll, setShowAll] = createSignal(api.kv.get("cockpit.shells.showAll", false))
+    /**
+     * Expanded or folded, for this window only. It used to be kept across restarts, so a list
+     * expanded for six shells came back expanded over one, offering `− fewer` for nothing.
+     */
+    const [showAll, setShowAll] = createSignal(false)
     const [scope, setScope] = createSignal(
       api.kv.get("cockpit.shells.scope", options.scope ?? "session"),
     )
@@ -113,6 +121,9 @@ export function createShellStore(api: Host, client: CockpitClient, options: Stor
     const folded = createMemo(() =>
       partition(inScope(), { showAll: showAll(), historyMs, now: now(), keep: pick()?.id }),
     )
+    const foldedOnly = createMemo(
+      () => partition(inScope(), { showAll: false, historyMs, now: now(), keep: pick()?.id }).visible,
+    )
 
     return {
       client,
@@ -128,12 +139,10 @@ export function createShellStore(api: Host, client: CockpitClient, options: Stor
       all: () => state.list,
       visible: () => folded().visible,
       hidden: () => folded().hidden,
+      folded: foldedOnly,
       showAll,
-      toggleAll() {
-        const next = !showAll()
-        setShowAll(next)
-        api.kv.set("cockpit.shells.showAll", next)
-      },
+      toggleAll: () => setShowAll((all) => !all),
+      foldAll: () => setShowAll(false),
       connected,
       now,
       frame,
diff --git a/packages/shell/test/config-effects.test.ts b/packages/shell/test/config-effects.test.ts
index 3938e0d9..fecc06b4 100644
--- a/packages/shell/test/config-effects.test.ts
+++ b/packages/shell/test/config-effects.test.ts
@@ -2,13 +2,13 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"
 import type { ToolContext } from "@opencode-ai/plugin"
 import { startDaemon } from "../../client/test/helpers.ts"
 import { createTools } from "../src/agent/tools/index.ts"
-import type { CockpitConfig } from "../src/core/config.ts"
+import type { ShellConfig } from "../src/core/config.ts"
 
 /**
  * Settings are only worth having if they change what the agent's tools do, so these drive the real
  * tools against a real daemon with a config in place, and assert the observable difference.
  */
-const CONFIG: CockpitConfig = {
+const CONFIG: ShellConfig = {
   kinds: { e2e: "playwright|cypress" },
   watch: { presets: { e2e: { done: "\\d+ (passed|failed)", fail: "\\d+ failed" } } },
   defaults: { logFile: true, notifyOnExit: false, timeoutSeconds: 30 },
diff --git a/packages/shell/test/config.test.ts b/packages/shell/test/config.test.ts
index 34a2ffd9..b1f3c7a2 100644
--- a/packages/shell/test/config.test.ts
+++ b/packages/shell/test/config.test.ts
@@ -1,8 +1,8 @@
 import { afterEach, describe, expect, test } from "bun:test"
 import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
-import { dirname, join } from "node:path"
+import { join } from "node:path"
 import { watchArgs } from "../src/agent/tools/watch-args.ts"
-import { globalConfigPath, loadConfig, mergeConfig, readConfigFile } from "../src/core/config.ts"
+import { loadConfig, loadShell } from "../src/core/config.ts"
 
 const dirs: string[] = []
 const temp = () => {
@@ -14,47 +14,121 @@ afterEach(() => {
   for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
 })
 
+/** A global file and a project file, as written; the env points the loader at the global one. */
+function files(global: string | object | undefined, project?: string | object) {
+  const home = temp()
+  const directory = temp()
+  const text = (value: string | object) => (typeof value === "string" ? value : JSON.stringify(value))
+  if (global !== undefined) {
+    mkdirSync(join(home, "opencode-cockpit"), { recursive: true })
+    writeFileSync(join(home, "opencode-cockpit", "config.json"), text(global))
+  }
+  if (project !== undefined) writeFileSync(join(directory, ".cockpit.json"), text(project))
+  return { directory, env: { XDG_CONFIG_HOME: home } }
+}
+
 describe("config", () => {
   test("project settings override global ones, and plugin options override both", () => {
-    const home = temp()
-    const project = temp()
-    const globalFile = globalConfigPath({ XDG_CONFIG_HOME: home })
-    mkdirSync(dirname(globalFile), { recursive: true })
-    writeFileSync(globalFile, JSON.stringify({ guidance: false, ui: { dockHeight: 10, sidebarRows: 3 } }))
-    writeFileSync(join(project, ".cockpit.json"), JSON.stringify({ ui: { dockHeight: 20 } }))
-
-    const config = loadConfig(project, { ui: { sidebarRows: 9 } }, { XDG_CONFIG_HOME: home })
+    const { directory, env } = files(
+      { shell: { guidance: false, dockHeight: 10, sidebarRows: 3 } },
+      { shell: { dockHeight: 20 } },
+    )
+    const config = loadConfig(directory, { sidebarRows: 9 }, env)
     expect(config.guidance).toBe(false) // only the global file set it
-    expect(config.ui?.dockHeight).toBe(20) // project wins over global
-    expect(config.ui?.sidebarRows).toBe(9) // options win over both
+    expect(config.dockHeight).toBe(20) // project wins over global
+    expect(config.sidebarRows).toBe(9) // options win over both
   })
 
-  test("old flat plugin options still work", () => {
-    const config = loadConfig(
-      temp(),
-      { dockHeight: 16, keybinds: { "cockpit.shells.dock": "j" } },
-      {},
-    )
-    expect(config.ui?.dockHeight).toBe(16)
-    expect(config.ui?.keybinds).toEqual({ "cockpit.shells.dock": "j" })
+  test("the bundle's `shell` section and a standalone entry's own keys read the same", () => {
+    const { directory, env } = files(undefined)
+    const keys = { "cockpit.shells.dock": "j" }
+    for (const options of [
+      { shell: { dockHeight: 16, keybinds: keys } },
+      { dockHeight: 16, keybinds: keys },
+    ]) {
+      const config = loadConfig(directory, options, env)
+      expect(config.dockHeight).toBe(16)
+      expect(config.keybinds).toEqual(keys)
+    }
   })
 
   test("sections merge key by key instead of replacing each other", () => {
-    const merged = mergeConfig(
-      { watch: { auto: true, presets: { a: { done: "1" } } }, kinds: { db: "psql" } },
-      { watch: { presets: { b: { done: "2" } } } },
+    const { directory, env } = files(
+      { shell: { watch: { auto: true, presets: { a: { done: "1" } } }, kinds: { db: "psql" } } },
+      { shell: { watch: { presets: { b: { done: "2" } } } } },
+    )
+    const config = loadConfig(directory, undefined, env)
+    expect(config.watch?.auto).toBe(true)
+    expect(Object.keys(config.watch?.presets ?? {})).toEqual(["a", "b"])
+    expect(config.kinds).toEqual({ db: "psql" })
+  })
+
+  test("defaults: present when empty, finished shells stay 30 minutes, the screen view", () => {
+    const { directory, env } = files(undefined)
+    const config = loadConfig(directory, undefined, env)
+    expect(config).toMatchObject({
+      sidebarRows: 5,
+      hideWhenEmpty: false,
+      hideFinishedAfterMinutes: 30,
+      dockHeight: 14,
+      colors: true,
+      defaultView: "screen",
+      guidance: true,
+      listRunningShells: 15,
+    })
+    expect(config.dockOpen).toBeUndefined()
+  })
+
+  test("comments and trailing commas no longer drop the file", () => {
+    const { directory, env } = files(`{
+      // mine
+      "shell": { "dockHeight": 22, },
+    }`)
+    expect(loadConfig(directory, undefined, env).dockHeight).toBe(22)
+  })
+
+  test("a broken file is ignored with a notice, never fatal", () => {
+    const { directory, env } = files(undefined, "{ not json")
+    const { config } = loadShell(directory, undefined, env)
+    expect(config.dockHeight).toBe(14)
+  })
+
+  test("the old places are not read: root keys, `ui`, `ui.historyMinutes` — each a notice", () => {
+    const { directory, env } = files({
+      guidance: false,
+      ui: { dockHeight: 30, historyMinutes: 5, sidebarOrder: 1 },
+    })
+    const { config, notices } = loadShell(directory, { ui: { sidebarRows: 2 } }, env)
+    expect(config.guidance).toBe(true)
+    expect(config.dockHeight).toBe(14)
+    expect(config.hideFinishedAfterMinutes).toBe(30)
+    expect(config.sidebarRows).toBe(5)
+    expect(notices.map((notice) => [notice.old, notice.new])).toEqual(
+      expect.arrayContaining([
+        ["guidance", "shell.guidance"],
+        ["ui.dockHeight", "shell.dockHeight"],
+        ["ui.historyMinutes", "shell.hideFinishedAfterMinutes"],
+        ["ui.sidebarOrder", "sidebar"],
+        ["ui.sidebarRows", "shell.sidebarRows"],
+      ]),
     )
-    expect(merged.watch?.auto).toBe(true)
-    expect(Object.keys(merged.watch?.presets ?? {})).toEqual(["a", "b"])
-    expect(merged.kinds).toEqual({ db: "psql" })
+    for (const notice of notices) expect(notice.text).toContain("/cockpit-setup")
+  })
+
+  test("a value of the wrong kind is the default, and an unknown view is the screen", () => {
+    const { directory, env } = files({ shell: { dockHeight: "16", defaultView: "tv", dockOpen: "yes" } })
+    const { config, notices } = loadShell(directory, undefined, env)
+    expect(config.dockHeight).toBe(14)
+    expect(config.defaultView).toBe("screen")
+    expect(config.dockOpen).toBeUndefined()
+    expect(notices.some((notice) => notice.old === "shell.dockHeight")).toBe(true)
   })
 
-  test("a broken or missing file is ignored, never fatal", () => {
-    const dir = temp()
-    writeFileSync(join(dir, ".cockpit.json"), "{ not json")
-    expect(readConfigFile(join(dir, ".cockpit.json"))).toEqual({})
-    expect(readConfigFile(join(dir, "nope.json"))).toEqual({})
-    expect(loadConfig(dir, undefined, {})).toBeDefined()
+  test("the block's place is the `sidebar` list's; `enabled` and `features.shell` switch it off", () => {
+    const { directory, env } = files({ sidebar: ["shell"], features: { shell: false } })
+    const { order, config } = loadShell(directory, undefined, env)
+    expect(order).toBe(110)
+    expect(config.enabled).toBe(false)
   })
 })
 
diff --git a/packages/shell/test/tui-lib.test.ts b/packages/shell/test/tui-lib.test.ts
index 3103de79..1791c51e 100644
--- a/packages/shell/test/tui-lib.test.ts
+++ b/packages/shell/test/tui-lib.test.ts
@@ -1,6 +1,7 @@
 import { describe, expect, test } from "bun:test"
 import type { ScreenRun, ShellInfo } from "@opencode-cockpit/protocol/shell"
 import { friendlyError, splitMatches } from "../src/tui/lib/search.ts"
+import { fold } from "../src/tui/lib/sidebar.ts"
 import {
   BADGE_LABEL,
   badgeText,
@@ -264,3 +265,31 @@ describe("errors are rewritten for humans", () => {
     expect(friendlyError("plain string")).toBe("plain string")
   })
 })
+
+describe("folding the sidebar", () => {
+  const list = (n: number) => Array.from({ length: n }, (_, at) => `sh${at}`)
+  const at = (all: string[], folded: string[], showAll: boolean) =>
+    fold({ all, folded, showAll, rows: 5, expandedRows: 12 })
+
+  test("one shell: nothing to fold, so no toggle — expanded or not", () => {
+    expect(at(list(1), list(1), false)).toEqual({ shown: ["sh0"], more: 0, stale: false })
+    /** Expanded at six, down to one: no `− fewer`, and the expansion is stale. */
+    expect(at(list(1), list(1), true)).toEqual({ shown: ["sh0"], more: 0, stale: true })
+  })
+
+  test("more than the folded limit: `+ N more`, and `− fewer` once expanded", () => {
+    expect(at(list(7), list(7), false)).toMatchObject({ more: 2, toggle: "more", stale: false })
+    expect(at(list(7), list(7), true)).toMatchObject({ more: 0, toggle: "fewer", stale: false })
+    expect(at(list(7), list(7), true).shown).toHaveLength(7)
+  })
+
+  test("finished shells the folded view leaves out count too, though under the limit", () => {
+    /** Three shells, one running: folded shows one and offers the other two. */
+    expect(at(list(3), ["sh0"], false)).toMatchObject({ shown: ["sh0"], more: 2, toggle: "more" })
+    expect(at(list(3), ["sh0"], true)).toMatchObject({ more: 0, toggle: "fewer", stale: false })
+  })
+
+  test("expanded past the expanded limit: what is left, and the console", () => {
+    expect(at(list(20), list(20), true)).toMatchObject({ more: 8, toggle: "both" })
+  })
+})
diff --git a/packages/shell/test/tui-store.test.ts b/packages/shell/test/tui-store.test.ts
index 04b94f5a..ce3c9db0 100644
--- a/packages/shell/test/tui-store.test.ts
+++ b/packages/shell/test/tui-store.test.ts
@@ -3,6 +3,7 @@ import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
 import type { CockpitClient } from "@opencode-cockpit/client"
 import type { ShellInfo } from "@opencode-cockpit/protocol/shell"
 import { createMemo, createRoot, createSignal } from "solid-js"
+import { fold } from "../src/tui/lib/sidebar.ts"
 import { createShellStore, type ShellStore, useScreen } from "../src/tui/state/store.ts"
 
 /**
@@ -232,7 +233,46 @@ describe("folding", () => {
     expect(live.store.showAll()).toBe(true)
     expect(live.store.visible()).toHaveLength(2)
     expect(live.store.hidden()).toHaveLength(0)
-    expect(live.kv.get("cockpit.shells.showAll")).toBe(true)
+    /** What folding keeps is known while expanded too: it is what says whether there is anything to fold. */
+    expect(live.store.folded().map((s) => s.id)).toEqual([running.id])
+    /** For this window only: a list expanded for six shells came back expanded over one. */
+    expect(live.kv.has("cockpit.shells.showAll")).toBe(false)
+    live.store.foldAll()
+    expect(live.store.showAll()).toBe(false)
+  })
+
+  /**
+   * The roadmap's bug: expanded at six, down to one, the block still offered `− fewer` for a list
+   * nothing could fold. The toggle is there only while folding hides something, and the expansion
+   * is stale — the sidebar folds it back — once it does not.
+   */
+  test("one shell offers no toggle, and an expansion it outlived is stale", async () => {
+    const six = Array.from({ length: 6 }, (_, at) => shell({ session: "ses_a", startedAt: 100 + at }))
+    live = harness([], { historyMinutes: 1 })
+    await live.setList(six)
+    const folding = () =>
+      fold({
+        all: live?.store.shells() ?? [],
+        folded: live?.store.folded() ?? [],
+        showAll: live?.store.showAll() ?? false,
+        rows: 5,
+        expandedRows: 12,
+      })
+    expect(folding()).toMatchObject({ more: 1, toggle: "more", stale: false })
+    live.store.toggleAll()
+    expect(folding()).toMatchObject({ more: 0, toggle: "fewer", stale: false })
+    expect(folding().shown).toHaveLength(6)
+
+    await live.setList(six.slice(0, 1))
+    expect(folding().toggle).toBeUndefined()
+    expect(folding().stale).toBe(true)
+    live.store.foldAll()
+    expect(folding()).toMatchObject({ more: 0, stale: false })
+    expect(folding().toggle).toBeUndefined()
+
+    /** Back to six: it starts folded again, not expanded from before. */
+    await live.setList(six)
+    expect(folding()).toMatchObject({ more: 1, toggle: "more" })
   })
 
   // Folding is a display convenience; it must never hide the shell the console is showing.

From 30b551fabba263f15ceffa1054c208b91bcf6d83 Mon Sep 17 00:00:00 2001
From: Codestz 
Date: Sat, 3 Oct 2026 10:08:57 -0500
Subject: [PATCH 16/90] Subagents: settings from files, every row names its
 agent, [?] Keys

Both halves read the subagents section through baySettings (sidebarRows,
hideWhenEmpty, hideFinishedAfterMinutes, hideNestedAfterSeconds, guidance,
keybinds); old names are ! rows in the block. The block is present when
empty. Every row names its agent, muted, general too; the agent shortens
before the title, and a run with no title is named by its task's first
words. The pane has a [?] Keys screen like Review's and Trust's.

Co-Authored-By: Claude Opus 5.5 
---
 packages/subagents/README.md                |  33 +++-
 packages/subagents/src/agent/plugin.ts      |  12 +-
 packages/subagents/src/cli/preview.ts       |  50 +++++-
 packages/subagents/src/core/config.ts       |  66 ++++++++
 packages/subagents/src/core/model/model.ts  |  25 ++-
 packages/subagents/src/core/sample.ts       |  32 ++++
 packages/subagents/src/core/view/screen.ts  | 117 +++++++++++--
 packages/subagents/src/core/view/sidebar.ts | 114 +++++++++----
 packages/subagents/src/tui/index.tsx        |  81 +++++----
 packages/subagents/test/settings.test.ts    | 178 ++++++++++++++++++++
 packages/subagents/test/sidebar.test.ts     | 115 ++++++++++++-
 packages/subagents/test/truth.test.ts       |   6 +-
 packages/subagents/test/view.test.ts        |   2 +-
 13 files changed, 732 insertions(+), 99 deletions(-)
 create mode 100644 packages/subagents/src/core/config.ts
 create mode 100644 packages/subagents/test/settings.test.ts

diff --git a/packages/subagents/README.md b/packages/subagents/README.md
index b244d489..2dfe93f6 100644
--- a/packages/subagents/README.md
+++ b/packages/subagents/README.md
@@ -16,14 +16,15 @@ opencode plugin add @opencode-cockpit/subagents@0.8.0                   # OpenCo
 
 ## What it does
 
-**In the sidebar**, a Subagents block: each subagent in this conversation, its task — and its type,
-unless it is `general` — and under one still at it, what it is doing now: `grep "session" src/auth/**`,
+**In the sidebar**, a Subagents block: each subagent in this conversation, its type, muted, and its
+task — a run with no title is named by its task's first words — and under one still at it, what it is doing now: `grep "session" src/auth/**`,
 `thinking`, `waiting for permission`, `stopped` — with how many calls and how long the whole run has
 taken on the right. Working ones come first. A finished one is a single quiet row; one held on a
 permission is drawn in the warning tone and counted apart in the heading (`1 running · 1 needs you`).
 A subagent that launched its own has them indented under it, and the same helper launched again and
 again for the same task is one entry with a count (`×6`). A finished nested one leaves the sidebar
-after `hideNestedAfter` seconds; the heading still counts it.
+after `hideNestedAfterSeconds`; the heading still counts it. With none yet, the block still shows
+its heading and `none yet`, so you can tell it is there.
 
 **Click one** — or `ctrl+x w`, or `/subagents` — and its run opens in a pane on the right, half the
 window or all of it: its model and who launched it, the task, then the run. A shell command or a file
@@ -48,6 +49,7 @@ markdown too; the answer is markdown.
 | `w` | Half the window, or all of it (remembered) |
 | `[` `]` | Another subagent of this conversation |
 | `d` `u` · `g` `G` | Page down · up · to the start · follow the run |
+| `?` | Every key, in the pane — the footer has room for the ones you use constantly |
 | `esc` `q` | Back to the conversation |
 
 **Follow-ups keep their context.** The main agent is asked to continue the subagent that did the work
@@ -82,16 +84,33 @@ export OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true   # in ~/.zshrc, then sta
 
 ## Settings
 
-In the bundle's entry (`"subagents": { … }`) or this package's own:
+In the `subagents` section of Cockpit's config file — read by both halves, in every project:
+
+```
+~/.config/opencode-cockpit/config.json   →   /.cockpit.json   →   plugin-entry options
+```
+
+```jsonc
+{ "subagents": { "sidebarRows": 6, "hideWhenEmpty": false, "hideFinishedAfterMinutes": 60 } }
+```
+
+The same keys also work on the plugin entry (the bundle's `"subagents": { … }`, or this package's
+own), which wins over both files.
 
 | Setting | Default | |
 | --- | --- | --- |
 | `sidebarRows` | `6` | Subagents shown before the rest fold into a count — working ones first |
-| `hideFinishedAfter` | unset | Minutes a finished subagent stays in the sidebar; unset keeps it for the conversation |
-| `hideNestedAfter` | `30` | Seconds a finished *nested* subagent — one a subagent launched — stays in the sidebar; a negative number keeps them. The heading still counts them and `[` `]` still reach them |
-| `sidebarOrder` | `150` | Where the block sits in the sidebar; lower draws first |
+| `hideWhenEmpty` | `false` | With no subagents the block says `none yet` under its heading; `true` draws nothing instead |
+| `hideFinishedAfterMinutes` | unset | Minutes a finished subagent stays in the sidebar; unset keeps it for the conversation |
+| `hideNestedAfterSeconds` | `30` | Seconds a finished *nested* subagent — one a subagent launched — stays in the sidebar; a negative number keeps them. The heading still counts them and `[` `]` still reach them |
 | `keybinds` | `{ "cockpit.subagents.open": "w" }` | The key that opens the latest one |
 | `guidance` | `true` | Tell the main agent about background subagents and follow-ups (agent side) |
+| `enabled` | `true` | `false` switches both halves off |
+
+Where the block sits is the top-level `"sidebar"` list's to say (`["status", "subagents", "shell",
+"trail", "trust"]` by default). The names from before 0.9 — `hideFinishedAfter`, `hideNestedAfter`,
+`sidebarOrder` — are no longer read: the block shows a `!` row naming the new one, and
+`/cockpit-setup` fixes it.
 
 ## See it without OpenCode
 
diff --git a/packages/subagents/src/agent/plugin.ts b/packages/subagents/src/agent/plugin.ts
index feec9b5b..f95574d0 100644
--- a/packages/subagents/src/agent/plugin.ts
+++ b/packages/subagents/src/agent/plugin.ts
@@ -5,6 +5,7 @@ import { createdSession } from "../core/adapt/created.ts"
 import { endOf, finishedAt } from "../core/adapt/ends.ts"
 import { createV1Translator } from "../core/adapt/v1.ts"
 import { createV2Translator } from "../core/adapt/v2.ts"
+import { loadSubagents } from "../core/config.ts"
 import type { Change } from "../core/model/changes.ts"
 import {
   applyAll,
@@ -13,6 +14,7 @@ import {
   rootOf,
   type Session,
   subagentsOf,
+  titleOf,
   working,
 } from "../core/model/model.ts"
 import { backgroundOffered, subagentsGuidance } from "../core/view/guidance.ts"
@@ -90,7 +92,13 @@ export function createSubagentsServer({
       return {}
     }
     const log = host.log.child("subagents")
-    const options = (rawOptions ?? {}) as { guidance?: boolean }
+    /** The `subagents` section of the files, as the interface reads it, then the entry's options. */
+    const { config: options } = loadSubagents(host.directory, rawOptions)
+    if (!options.enabled) {
+      log.info("off in the settings")
+      claim.release()
+      return {}
+    }
     const background = backgroundOffered(host.version, process.env)
     log.info("background subagents", { offered: background })
     const guidance = subagentsGuidance({ version: host.version, background })
@@ -372,7 +380,7 @@ function createRuns(host: ServerHost) {
       known.length === 0
         ? `no subagent "${id}": this conversation has none. Ids look like ses_… and come from the task result or subagents_list.`
         : `no subagent "${id}". This conversation's subagents:\n${known
-            .map((node) => `- ${node.session.id} "${node.session.title || "subagent"}"`)
+            .map((node) => `- ${node.session.id} "${titleOf(node.session)}"`)
             .join("\n")}`,
     )
   }
diff --git a/packages/subagents/src/cli/preview.ts b/packages/subagents/src/cli/preview.ts
index 1dfe5468..cc6c44cd 100644
--- a/packages/subagents/src/cli/preview.ts
+++ b/packages/subagents/src/cli/preview.ts
@@ -24,6 +24,8 @@ import {
   finishedSample,
   LATE_ROOT,
   lateSample,
+  NAMES_ROOT,
+  namesSample,
   SAMPLE_NOW,
   SAMPLE_ROOT,
   sample,
@@ -77,20 +79,34 @@ const FIXTURES: Record Change[]; root: string; about: s
     root: FINISHED_ROOT,
     about: "everything ended: six done, one stopped",
   },
+  names: {
+    changes: namesSample,
+    root: NAMES_ROOT,
+    about: "every row names its agent: general, a long name, no title, a placeholder title",
+  },
+  empty: { changes: () => [], root: "ses_empty", about: "nothing launched yet: the heading and `none yet`" },
 }
 
+/** A settings notice as the block draws it (client/settings `noticeText`), for `--notice`. */
+const NOTICE = 'settings: "subagents.hideFinishedAfter" is no longer read — run /cockpit-setup'
+
 const args = process.argv.slice(2)
 if (args.includes("--help") || args.includes("-h")) {
   process.stdout.write(
     [
       "Usage: subagents preview [--width ] [--fixture ] [--columns ]",
-      "                         [--state closed|open|whole]",
+      "                         [--state closed|open|whole] [--widths 24,30,36,50] [--notice] [--hide]",
+      "                         [--keys]",
       "",
       ...Object.entries(FIXTURES).map(([name, { about }]) => `  ${name.padEnd(10)}${about}`),
       "  calls     a call of every kind: thinking as markdown, todos, an MCP tool, a task,",
       "            and an ask_advisor with a sixty-line question — folded, open and whole",
       "",
       "  --state   with --fixture calls, only that state",
+      "  --widths  the sidebar at each of these widths, and nothing else",
+      "  --notice  the block with a settings notice in it",
+      "  --hide    hideWhenEmpty: an empty block draws nothing",
+      "  --keys    the pane's [?] Keys screen",
       "",
     ].join("\n"),
   )
@@ -152,10 +168,30 @@ if (!fixture) {
 
 const model = applyAll(emptyModel(), fixture.changes())
 const nodes = subagentsOf(model, fixture.root)
-const out: string[] = ["", "Sidebar", ""]
 /** The host's default for nested ones: thirty seconds. */
-for (const line of sidebarLines({ nodes, width: sidebarWidth, now: SAMPLE_NOW, frame: 2, fadeAfter: 30_000 }))
-  out.push(paint(line.row))
+const sidebar = (width: number) =>
+  sidebarLines({
+    nodes,
+    width,
+    now: SAMPLE_NOW,
+    frame: 2,
+    fadeAfter: 30_000,
+    hideWhenEmpty: args.includes("--hide"),
+    ...(args.includes("--notice") ? { notices: [NOTICE] } : {}),
+  })
+const widths = option("--widths")
+if (widths) {
+  const out: string[] = []
+  for (const width of widths.split(",").map(Number).filter(Boolean)) {
+    out.push("", `Sidebar, ${width} columns`, "")
+    /** The edge drawn, so a row a cell short or long shows. */
+    for (const line of sidebar(width)) out.push(`${paint(line.row)}│`)
+  }
+  process.stdout.write(`${out.join("\n")}\n\n`)
+  process.exit(0)
+}
+const out: string[] = ["", "Sidebar", ""]
+for (const line of sidebar(sidebarWidth)) out.push(paint(line.row))
 const first = nodes[0]?.session
 if (first) {
   const base = {
@@ -171,6 +207,12 @@ if (first) {
     thinking: false,
     details: false,
   }
+  if (args.includes("--keys")) {
+    out.push("", `The pane's [?] Keys — ${columns} columns`, "")
+    for (const row of screenRows({ ...base, keys: true }).rows) out.push(paint(row))
+    process.stdout.write(`${out.join("\n")}\n\n`)
+    process.exit(0)
+  }
   out.push("", "The pane — the first subagent", "")
   const folded = screenRows(base)
   for (const row of folded.rows) out.push(paint(row))
diff --git a/packages/subagents/src/core/config.ts b/packages/subagents/src/core/config.ts
new file mode 100644
index 00000000..f8280bf0
--- /dev/null
+++ b/packages/subagents/src/core/config.ts
@@ -0,0 +1,66 @@
+/**
+ * Subagents' settings, through the loader every bay shares (`@opencode-cockpit/client/settings`):
+ *
+ *   ~/.config/opencode-cockpit/config.json  →  /.cockpit.json  →  plugin-entry options
+ *
+ * Only the `subagents` section of a file is read, by both halves: the interface's keys and the
+ * agent's `guidance` sit in one place instead of in `tui.json` and `opencode.json` apart. Before 0.9
+ * the bay read no file at all. An old name (`hideFinishedAfter`, `hideNestedAfter`, `sidebarOrder`)
+ * is not read; it is a notice naming the new one, drawn in the Subagents block.
+ */
+
+import { baySettings, type SettingsNotice } from "@opencode-cockpit/client/settings"
+
+export interface SubagentsConfig {
+  /** Off switch for this bay, both halves, wherever it is written. `features.subagents: false` too. */
+  enabled?: boolean
+  /** Subagents shown in the sidebar before the rest fold into `+ N more`. Default 6. */
+  sidebarRows?: number
+  /** Draw no Subagents block at all while there are none. Default false: the heading and `none yet`. */
+  hideWhenEmpty?: boolean
+  /**
+   * Minutes a finished subagent stays in the sidebar; unset keeps it for the conversation. It is only
+   * out of the sidebar — `/subagents` and the pane's `[` `]` still reach it, and it comes back if it
+   * works again.
+   */
+  hideFinishedAfterMinutes?: number
+  /**
+   * Seconds a finished *nested* subagent — one a subagent launched, an advisor it asks again and
+   * again — stays in the sidebar; 30 unless set, a negative number keeps them. As with
+   * `hideFinishedAfterMinutes` it is only out of the sidebar: the heading still counts it and the
+   * pane's `[` `]` still reach it. Seconds, because these come and go in seconds.
+   */
+  hideNestedAfterSeconds?: number
+  /** The system-prompt guidance that teaches the agent to follow, wait on and read its subagents. */
+  guidance?: boolean
+  keybinds?: Record
+}
+
+/** What every source left unset becomes; a written value of another kind is dropped, with a notice. */
+export const DEFAULTS = {
+  guidance: true,
+  hideNestedAfterSeconds: 30,
+  /** Unset keeps finished ones: a number only when written. */
+  hideFinishedAfterMinutes: undefined as number | undefined,
+}
+
+export interface LoadedSubagents {
+  config: SubagentsConfig & { sidebarRows: number; hideWhenEmpty: boolean; enabled: boolean }
+  /** The block's place, from the top-level `sidebar` list. */
+  order: number
+  /** Settings to fix, for a `!` row in the block. */
+  notices: SettingsNotice[]
+}
+
+/** Reads and merges every source. Never throws. */
+export function loadSubagents(
+  directory: string,
+  options?: unknown,
+  env: Record = process.env,
+): LoadedSubagents {
+  const loaded = baySettings("subagents", DEFAULTS, { options, where: { directory, env } })
+  const config: LoadedSubagents["config"] = { ...loaded.config }
+  /** Undefined has no kind to check against: anything but a number there means "keep them". */
+  if (typeof config.hideFinishedAfterMinutes !== "number") delete config.hideFinishedAfterMinutes
+  return { config, order: loaded.order, notices: loaded.notices }
+}
diff --git a/packages/subagents/src/core/model/model.ts b/packages/subagents/src/core/model/model.ts
index abf5f6c7..e2841508 100644
--- a/packages/subagents/src/core/model/model.ts
+++ b/packages/subagents/src/core/model/model.ts
@@ -286,8 +286,29 @@ export const working = (s: Session): boolean => s.status !== "done" && s.status
  * (docs/building/principles.md, rule 2).
  */
 
-/** What it is called: its title, else its task, else "subagent". */
-export const titleOf = (s: Session): string => s.title || s.task || "subagent"
+/** OpenCode's placeholder for a session nobody has titled yet: `Child session - 2026-10-03T…`. */
+const PLACEHOLDER = /^(new|child) session - \d{4}-\d{2}-\d{2}t/i
+
+/** Words of a task kept as a name: enough to tell two runs apart, short enough to be a name. */
+const NAME_WORDS = 8
+
+/** A task's first words, from its first line that says anything: a name for an untitled run. */
+export function firstWords(text: string | undefined): string {
+  const line = (text ?? "").split("\n").find((each) => each.trim() !== "") ?? ""
+  const words = line.trim().split(/\s+/).filter(Boolean)
+  return words.length > NAME_WORDS ? `${words.slice(0, NAME_WORDS).join(" ")}…` : words.join(" ")
+}
+
+/**
+ * What a subagent is called, everywhere it is named — the sidebar, the pane, the main agent's tools.
+ * Its title; with none (or only OpenCode's placeholder) its task's first words; never an empty name,
+ * which drew a row that was an agent and a time with nothing between them.
+ */
+export function titleOf(s: Session): string {
+  const title = s.title?.trim()
+  if (title && !PLACEHOLDER.test(title)) return title
+  return firstWords(s.task) || "subagent"
+}
 
 /** How many calls it made. */
 export const callsOf = (s: Session): number => s.entries.filter((entry) => entry.kind === "tool").length
diff --git a/packages/subagents/src/core/sample.ts b/packages/subagents/src/core/sample.ts
index 7c807e26..a42fbfba 100644
--- a/packages/subagents/src/core/sample.ts
+++ b/packages/subagents/src/core/sample.ts
@@ -346,6 +346,38 @@ export function finishedSample(): Change[] {
   ]
 }
 
+export const NAMES_ROOT = "ses_names"
+
+/**
+ * Names, as OpenCode hands them over: `general` beside `explore`, which used to be drawn as two kinds
+ * of row; an agent with a long name; a run with no title at all and one with only OpenCode's
+ * placeholder, which drew an agent and a time with nothing between them.
+ */
+export function namesSample(): Change[] {
+  const t = (s: number) => SAMPLE_NOW - 300_000 + s * 1000
+  const root = NAMES_ROOT
+  const untitled = (id: string, agent: string, title: string, task: string, start: number, end?: number) => [
+    { type: "session", id, parentID: root, agent, title, at: t(start) } as Change,
+    { type: "prompt", id, key: "u1", text: task, at: t(start) } as Change,
+    { type: "status", id, status: "busy", at: t(start) } as Change,
+    ...(end === undefined ? [] : [{ type: "status", id, status: "idle", at: t(end) } as Change]),
+  ]
+  return [
+    { type: "session", id: root, agent: "build", title: "Plan the billing export", at: t(0) },
+    ...run("ses_n_plan", root, "general", "Write a long plan for the export", t(5), t(140), 12),
+    ...run("ses_n_scan", root, "explore", "Explore the export module", t(10), undefined, 4),
+    ...run("ses_n_rev", root, "security-reviewer", "Review the export for PII", t(20), t(200), 9),
+    ...untitled("ses_n_bare", "general", "", "Find every caller of exportCsv\nand list them", 30, 90),
+    ...untitled(
+      "ses_n_auto",
+      "explore",
+      "Child session - 2026-10-03T10:00:00.000Z",
+      "Check the export's tests for flakiness across the whole suite please",
+      40,
+    ),
+  ]
+}
+
 export const LATE_ROOT = "ses_late"
 
 /**
diff --git a/packages/subagents/src/core/view/screen.ts b/packages/subagents/src/core/view/screen.ts
index 0ce627ba..c3a511ad 100644
--- a/packages/subagents/src/core/view/screen.ts
+++ b/packages/subagents/src/core/view/screen.ts
@@ -23,7 +23,15 @@
  * the body scrolls. Pure, like everything in `core/`.
  */
 
-import { closeHint, fitHints, type Hint, hintRuns, stateMark, toneOf } from "@opencode-cockpit/client/design"
+import {
+  closeHint,
+  fitHints,
+  type Hint,
+  hintRuns,
+  keyName,
+  stateMark,
+  toneOf,
+} from "@opencode-cockpit/client/design"
 import { callsOf, type Entry, type Node, type Session, titleOf } from "../model/model.ts"
 import { ARG_PREVIEW, argumentRows, large } from "./args.ts"
 import { markdownRows, plain } from "./markdown.ts"
@@ -70,6 +78,8 @@ export interface ScreenInput {
   thinking: boolean
   /** The details view in place of the timeline. */
   details: boolean
+  /** `?`: every key the pane takes, in the body's place. */
+  keys?: boolean
   /** A message being typed at the bottom of the pane. */
   input?: { draft: string; busy: boolean }
   /** A line under the keys: what just happened. */
@@ -744,7 +754,7 @@ function header(input: ScreenInput, width: number): Row[] {
     const each = Math.max(12, Math.floor((width - 14) / nodes.length) - 3)
     for (const node of nodes) {
       const current = node.session.id === session.id
-      const label = cut(`${node.session.agent} ${node.session.title}`, each)
+      const label = cut(`${node.session.agent} ${titleOf(node.session)}`, each)
       runs.push(
         { text: label, tone: current ? "accent" : "muted", bold: current, fill: "band" },
         { text: "   ", fill: "band" },
@@ -783,10 +793,16 @@ function footer(input: ScreenInput, width: number): Row[] {
       ),
     ]
   }
+  /** On the keys screen the only key worth a row is the way back to the run (Review's, Trust's). */
+  if (input.keys) {
+    const back: Run[] = [{ text: PAD }, ...fitHints([closeHint("Hide Keys")], width - PAD.length).runs]
+    return [fit(back, width), fit([], width)]
+  }
   /**
    * In the order drawn, each with its rank: at half width the lowest-ranked go first, and the way out
    * never does — it used to be the lowest, so the first key a narrow pane lost was how to leave it.
-   * The cutting, the `…` and the shape are the ones every bay shares (client/design).
+   * The cutting, the `…` and the shape are the ones every bay shares (client/design). `[?] Keys`
+   * gives way late: it is where every key the row dropped can still be found.
    */
   const all: Hint[] = [
     { key: "j/k", label: "Select", priority: 5 },
@@ -797,6 +813,7 @@ function footer(input: ScreenInput, width: number): Row[] {
     { key: "t", label: input.thinking ? "Hide Thinking" : "Show Thinking", priority: 3 },
     { key: "i", label: input.details ? "Timeline" : "Details", priority: 6 },
     { key: "w", label: "Width", priority: 4 },
+    { key: "?", label: "Keys", priority: 8.5 },
     closeHint("Back"),
   ]
   const keys: Run[] = [{ text: PAD }, ...fitHints(all, width - PAD.length).runs]
@@ -806,6 +823,71 @@ function footer(input: ScreenInput, width: number): Row[] {
   return [fit(keys, width), fit([{ text: `${PAD}${note}`, tone: "muted" }], width)]
 }
 
+export interface KeyLine {
+  keys: string[]
+  does: string
+}
+
+/**
+ * `[?] Keys`: every key the pane takes, in the order a run is read — move, open, act on the
+ * subagent, the view — then the way out. The footer has room for the ones used constantly and says
+ * `…` for the rest; this is the rest, the same screen Review and Trust have.
+ */
+export const SUBAGENT_KEYS: readonly KeyLine[] = [
+  { keys: ["j/k", "↑/↓"], does: "Move the cursor through the run: calls, thinking, your messages" },
+  {
+    keys: ["enter", "space"],
+    does: "Open or fold the item under the cursor; on a task call, go into that subagent",
+  },
+  { keys: ["a"], does: "A call's whole output, or back to its first lines" },
+  { keys: ["e"], does: "Open every call, or fold them all" },
+  { keys: ["�/→"], does: "Previous or next subagent of this conversation (also [ and ])" },
+  { keys: ["m"], does: "Message this subagent; finished, its answer is passed on to the main agent" },
+  { keys: ["x"], does: "Stop it (press twice), or once it has ended, take it off the list" },
+  { keys: ["X"], does: "Take every finished subagent off the list" },
+  { keys: ["b"], does: "Move it to the background: the main agent stops waiting for it" },
+  { keys: ["t"], does: "Show or hide thinking" },
+  { keys: ["i"], does: "Details: model, tokens, cost, the session id — or back to the timeline" },
+  { keys: ["w"], does: "Half the window, or all of it" },
+  { keys: ["d", "u"], does: "Scroll down or up (also pgdn, pgup)" },
+  { keys: ["g", "G"], does: "The start of the run, or follow it as it grows (also home, end)" },
+  { keys: ["?", "esc"], does: "Hide these keys; on the run, esc lets go of the cursor, then closes" },
+]
+
+/** The keys in the body's place: each line's keys in a column, what it does wrapped beside them. */
+function keyLines(width: number): Line[] {
+  const column = Math.max(
+    ...SUBAGENT_KEYS.map((line) => widthOf(line.keys.map((name) => `[${keyName(name)}]`).join(" "))),
+  )
+  const indent = PAD.length + column + 3
+  const lines: Line[] = [
+    { row: fit([{ text: `${PAD}KEYS`, tone: "text", bold: true }], width) },
+    { row: fit([], width) },
+  ]
+  for (const line of SUBAGENT_KEYS) {
+    const keys: Run[] = line.keys.flatMap((name, at): Run[] => [
+      ...(at > 0 ? [{ text: " " }] : []),
+      { text: `[${keyName(name)}]`, tone: "accent", bold: true },
+    ])
+    const used = widthOf(rowText(keys))
+    wrap(line.does, Math.max(1, width - indent - PAD.length)).forEach((text, at) => {
+      lines.push({
+        row: fit(
+          [
+            { text: PAD },
+            ...(at === 0
+              ? [...keys, { text: " ".repeat(column - used + 3) }]
+              : [{ text: " ".repeat(column + 3) }]),
+            { text, tone: "muted" },
+          ],
+          width,
+        ),
+      })
+    })
+  }
+  return lines
+}
+
 /** The selected item's rows, marked: a coloured edge and the selection fill. */
 function mark(lines: Line[], selected: string | undefined, width: number): Line[] {
   if (!selected) return lines
@@ -839,14 +921,18 @@ export function screenRows(input: ScreenInput): Screen {
   const room = Math.max(1, height - top.length - bottom.length - 2)
   const opened: string[] = []
   const links = new Map()
-  const body = input.details
-    ? detailLines(input, width)
-    : mark(bodyLines(input, width, opened, links), input.selected, width)
-  const keys = input.details
-    ? []
-    : [...new Set(body.map((line) => line.item).filter((item): item is string => Boolean(item)))]
+  const body = input.keys
+    ? keyLines(width)
+    : input.details
+      ? detailLines(input, width)
+      : mark(bodyLines(input, width, opened, links), input.selected, width)
+  const keys =
+    input.details || input.keys
+      ? []
+      : [...new Set(body.map((line) => line.item).filter((item): item is string => Boolean(item)))]
   const most = Math.max(0, body.length - room)
-  let first = input.top === undefined ? most : Math.min(Math.max(0, input.top), most)
+  /** Following the run means its end; the keys are read from their first line (and scroll as the run does). */
+  let first = input.top === undefined ? (input.keys ? 0 : most) : Math.min(Math.max(0, input.top), most)
   /**
    * The cursor moved onto an item: bring it into view. Only then — pinned on every paint, a selected
    * item longer than the pane snapped back to its first line whenever you scrolled into it.
@@ -857,6 +943,17 @@ export function screenRows(input: ScreenInput): Screen {
     if (at >= first + room) first = Math.min(most, at - room + 3)
   }
   const shown = body.slice(first, first + room)
+  /** A key list cut short says so, and how to reach the rest, as Review's and Trust's do. */
+  const below = body.length - (first + room)
+  if (input.keys && below > 0 && shown.length > 1) {
+    const left = below + 1
+    shown[shown.length - 1] = {
+      row: fit(
+        [{ text: `${PAD}↓ ${left} more line${left === 1 ? "" : "s"} — [d] scrolls`, tone: "muted" }],
+        width,
+      ),
+    }
+  }
   while (shown.length < room) shown.push({ row: fit([], width) })
   const blank = fit([], width)
   return {
diff --git a/packages/subagents/src/core/view/sidebar.ts b/packages/subagents/src/core/view/sidebar.ts
index 37d67c9c..d41df70c 100644
--- a/packages/subagents/src/core/view/sidebar.ts
+++ b/packages/subagents/src/core/view/sidebar.ts
@@ -10,7 +10,12 @@
  *     └ grep "session"  9 calls · 51s
  *     ⠙ advisor Review the plan      ×6
  *       └ 2 running · thinking    3s
- *   � Update README       3 calls · 28s
+ *   � general Update README  3 calls · 28s
+ *
+ * Every row names its agent, muted, before its title; when the row is short the agent shortens
+ * first (`expl…`), then the title, and a finished row's numbers give up their calls before the name
+ * gets too short to read. With nothing to list the block still says it exists: the heading and
+ * `none yet` in the row the first entry will take (client/design `emptyBlock`).
  *
  * A finished entry is one row: there is nothing it is doing, and a second row saying `done` under
  * every one of seven finished subagents was what pushed the rest under `+ 3 more`. What is still
@@ -22,6 +27,7 @@
  */
 
 import {
+  emptyBlock,
   HEADING,
   HEADING_GAP,
   moreText,
@@ -29,6 +35,7 @@ import {
   type State,
   stateMark,
   summaryRuns,
+  warnRows,
 } from "@opencode-cockpit/client/design"
 import {
   type Activity,
@@ -44,7 +51,7 @@ import {
   titleOf,
   working,
 } from "../model/model.ts"
-import { elapsed, fit, type Row, type Run, rowText, spread, widthOf } from "./rows.ts"
+import { cut, elapsed, fit, type Row, type Run, spread, widthOf } from "./rows.ts"
 
 export interface SidebarLine {
   row: Row
@@ -66,6 +73,13 @@ export interface SidebarInput {
    * They only leave the sidebar: the heading still counts them, and the pane still reaches them.
    */
   fadeAfter?: number
+  /** Draw nothing at all while there is nothing to list (`hideWhenEmpty`); else `none yet`. */
+  hideWhenEmpty?: boolean
+  /**
+   * Settings to fix, each said once in the block as a `!` row (client/settings `noticeText`). A
+   * failure always speaks: with these, the block shows even when `hideWhenEmpty` would hide it.
+   */
+  notices?: readonly string[]
 }
 
 /** Stopped — by you, or with the main agent — is not broken: OpenCode reports it as an error. */
@@ -143,16 +157,46 @@ function doing(activity: Activity): Row {
   }
 }
 
+/** Cells an agent's name keeps once it has started to give way: `exp…`, `gen…`. */
+const AGENT_FLOOR = 4
+
 /**
- * The agent's name, when it says something. `general` is what a subagent is when nobody chose one,
- * and on every row it cost eight columns the title needed; the pane's header still names it. Any
- * other agent is named, muted — it qualifies the title rather than competing with it.
+ * Who it is, in at most `room` cells: the agent, muted, then the title. Every row names its agent the
+ * same way — `general` too: hiding it to save width made "Write a long plan" and "explore Explore t…"
+ * read as two different kinds of thing. The space comes from the agent first: it shortens to
+ * `AGENT_FLOOR` before the title loses a letter, because the title is what tells two rows apart.
  */
-const agentRun = (agent: string): Run[] => (agent === "general" ? [] : [{ text: `${agent} `, tone: "muted" }])
+export function nameRuns(agent: string, title: string, room: number): Run[] {
+  const space = Math.max(0, room)
+  const whole = widthOf(agent) + 1 + widthOf(title)
+  if (whole <= space)
+    return [
+      { text: `${agent} `, tone: "muted" },
+      { text: title, tone: "text" },
+    ]
+  const floor = Math.min(widthOf(agent), AGENT_FLOOR)
+  const shortAgent = cut(agent, Math.max(floor, space - 1 - widthOf(title)))
+  const left = space - widthOf(shortAgent) - 1
+  /** Too narrow for both: the title alone, as the more telling of the two. */
+  if (left < 1) return [{ text: cut(title, space), tone: "text" }]
+  return [
+    { text: `${shortAgent} `, tone: "muted" },
+    { text: cut(title, left), tone: "text" },
+  ]
+}
 
-/** Columns a finished row keeps for its agent and title before its round count gives way. */
+/**
+ * Columns a finished row keeps for its agent and title before its numbers give way: the agent at its
+ * floor, and a title still worth reading.
+ */
 const TITLE_ROOM = 16
 
+/**
+ * Rows a settings notice may wrap to. Its last words are the fix (`run /cockpit-setup`), and at 30
+ * columns a long key name alone takes a row: three rows cut the fix off.
+ */
+const NOTICE_ROWS = 5
+
 /** When the last of a group's members ended. */
 const endedAt = (group: Group): number => Math.max(...group.members.map((s) => s.ended ?? s.started))
 
@@ -188,8 +232,17 @@ function place(groups: readonly Group[], now: number, fadeAfter: number | undefi
 
 export function sidebarLines(input: SidebarInput): SidebarLine[] {
   const { nodes, width, now, frame } = input
-  /** Silence is the rule: no subagents, no block. */
-  if (nodes.length === 0 || width < 8) return []
+  if (width < 8) return []
+  const title = "Subagents"
+  /** Each settings notice once, in the warning tone, wrapped to the column (client/design). */
+  const warnings: SidebarLine[] = (input.notices ?? []).flatMap((text) =>
+    warnRows(text, width, NOTICE_ROWS).map((row) => ({ row: fit(row, width) })),
+  )
+  /** Present when empty: the heading and `none yet`, unless asked for silence (and nothing to fix). */
+  if (nodes.length === 0) {
+    const hide = input.hideWhenEmpty === true && warnings.length === 0
+    return [...emptyBlock(title, width, hide).map((row) => ({ row: fit(row, width) })), ...warnings]
+  }
   /**
    * Every subagent, faded and grouped ones included: the heading counts sessions, not rows. Every
    * state there is, not only the worst — `1 failed` sat over six done ones — in the words and order
@@ -200,7 +253,6 @@ export function sidebarLines(input: SidebarInput): SidebarLine[] {
     const state = stateOf(node.session)
     counts[state] = (counts[state] ?? 0) + 1
   }
-  const title = "Subagents"
   const summary: Run[] = summaryRuns(counts, width - title.length - 1)
   const lines: SidebarLine[] = [
     { row: spread([{ text: title, ...HEADING }], summary, width) },
@@ -243,35 +295,39 @@ export function sidebarLines(input: SidebarInput): SidebarLine[] {
     /** Said in words: a bare "157 · 34m" read as a puzzle. */
     const took = calls > 0 ? `${calls} call${calls === 1 ? "" : "s"} · ${elapsed(since)}` : elapsed(since)
     const count = group.members.length > 1 ? `×${group.members.length}` : ""
-    const name: Row = [
-      { text: indent },
-      stateMark(state, frame),
-      { text: " " },
-      ...agentRun(session.agent),
-      { text: titleOf(session), tone: "text" },
+    const lead: Row = [{ text: indent }, stateMark(state, frame), { text: " " }]
+    const leadWidth = widthOf(indent) + 2
+    const title = titleOf(session)
+    /** The row's left part: its name in whatever `right` (and the gap before it) leaves. */
+    const named = (right: string): Row => [
+      ...lead,
+      ...nameRuns(session.agent, title, width - leadWidth - (right ? widthOf(right) + 1 : 0)),
     ]
 
     /**
-     * Finished, and nothing under it still working: one row, its numbers on the right. After more
-     * than one round its time is the last round's, so it says how many rounds — giving up the number
-     * of calls first, then the rounds, before the title is left too little room to be read (the
-     * pane's header says both).
+     * Finished, and nothing under it still working: one row, its numbers on the right and the name in
+     * what they leave. After more than one round its time is the last round's, so it says how many
+     * rounds. Only when the name would be left less than `TITLE_ROOM` do the numbers give way — the
+     * calls first, then the rounds, then the calls of the plain figure; the pane's header says all.
      */
     if (state === "done" && !groupWorking(group)) {
       const tail = count ? `  ${count}` : ""
-      const ladder =
-        rounds > 1 ? [`${took} · ${rounds} rounds${tail}`, `${elapsed(since)} · ${rounds} rounds${tail}`] : []
-      const lead = widthOf(rowText(name.slice(0, 3)))
-      const numbers =
-        ladder.find((text) => width - widthOf(text) - 1 - lead >= TITLE_ROOM) ?? `${took}${tail}`
-      lines.push({ id, row: spread(name, [{ text: numbers, tone: "muted" }], width) })
+      const short = elapsed(since)
+      const ladder = [
+        ...(rounds > 1 ? [`${took} · ${rounds} rounds${tail}`, `${short} · ${rounds} rounds${tail}`] : []),
+        `${took}${tail}`,
+        `${short}${tail}`,
+      ]
+      const room = (text: string) => width - leadWidth - widthOf(text) - 1
+      const numbers = ladder.find((text) => room(text) >= TITLE_ROOM) ?? `${short}${tail}`
+      lines.push({ id, row: spread(named(numbers), [{ text: numbers, tone: "muted" }], width) })
       continue
     }
 
     lines.push({
       id,
-      /** The count stays whole at the edge; the task gives way to it. */
-      row: count ? spread(name, [{ text: count, tone: "muted" }], width) : fit(name, width),
+      /** The count stays whole at the edge; the name gives way to it. */
+      row: count ? spread(named(count), [{ text: count, tone: "muted" }], width) : fit(named(""), width),
     })
     /**
      * The line describes one member; when more than one is at it, it says so — first, where the
@@ -299,5 +355,5 @@ export function sidebarLines(input: SidebarInput): SidebarLine[] {
     .filter((entry) => !chosen.has(entry))
     .reduce((sum, entry) => sum + entry.group.members.length, 0)
   if (hidden > 0) lines.push({ row: fit([{ text: `  ${moreText(hidden)}`, tone: "muted" }], width) })
-  return lines
+  return [...lines, ...warnings]
 }
diff --git a/packages/subagents/src/tui/index.tsx b/packages/subagents/src/tui/index.tsx
index f62fb57b..8973af44 100644
--- a/packages/subagents/src/tui/index.tsx
+++ b/packages/subagents/src/tui/index.tsx
@@ -2,11 +2,20 @@
 
 import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client/feature"
 import { bindingLookup, dualTui, type Host, type Layer, onPaste } from "@opencode-cockpit/client/host"
-import { sidebarOrder } from "@opencode-cockpit/client/sidebar"
+import { noticeText } from "@opencode-cockpit/client/settings"
 import type { BoxRenderable } from "@opentui/core"
 import { createSignal } from "solid-js"
+import { loadSubagents, type SubagentsConfig } from "../core/config.ts"
 import type { Change } from "../core/model/changes.ts"
-import { applyAll, emptyModel, type Node, rootOf, type Session, subagentsOf } from "../core/model/model.ts"
+import {
+  applyAll,
+  emptyModel,
+  type Node,
+  rootOf,
+  type Session,
+  subagentsOf,
+  titleOf,
+} from "../core/model/model.ts"
 import { createScreenCache, type Screen, screenRows } from "../core/view/screen.ts"
 import { type SidebarLine, sidebarLines } from "../core/view/sidebar.ts"
 import { createRowPool, type RowPool, solidSurface } from "./render.ts"
@@ -20,26 +29,8 @@ const DEFAULT_KEYS = {
   "cockpit.subagents.open": "w",
 }
 
-export interface SubagentsTuiOptions {
-  /** Subagents shown in the sidebar before the rest fold into a count. */
-  sidebarRows?: number
-  /**
-   * Minutes a finished subagent stays in the sidebar; unset keeps it for the conversation. It is only
-   * out of the sidebar — `/subagents` and the pane's `[` `]` still reach it, and it comes back if it
-   * works again.
-   */
-  hideFinishedAfter?: number
-  /**
-   * Seconds a finished *nested* subagent — one a subagent launched, an advisor it asks again and
-   * again — stays in the sidebar; 30 unless set, a negative number keeps them. As with
-   * `hideFinishedAfter` it is only out of the sidebar: the heading still counts it and the pane's
-   * `[` `]` still reach it. Seconds rather than minutes because these come and go in seconds.
-   */
-  hideNestedAfter?: number
-  /** Where the block sits among sidebar blocks; lower draws first (Shell 150, statusline 200). */
-  sidebarOrder?: number
-  keybinds?: Record
-}
+/** The `subagents` section of the config files, then the plugin entry's options (core/config.ts). */
+export type SubagentsTuiOptions = SubagentsConfig
 
 /** The pane's state: which subagent, and how it is being looked at. */
 interface Surface {
@@ -52,6 +43,8 @@ interface Surface {
   closed: Set
   thinking: boolean
   details: boolean
+  /** `?`: every key, in the body's place. */
+  keys?: boolean
   /** Half the window, or all of it. Remembered. */
   full: boolean
   /** A message being typed, at the foot of the pane. */
@@ -97,7 +90,14 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st
       return
     }
     api.lifecycle.onDispose(() => claim.release())
-    const options = (rawOptions ?? {}) as SubagentsTuiOptions
+    const { config: options, order, notices } = loadSubagents(api.state.path.directory, rawOptions)
+    for (const notice of notices) log.warn("settings", { file: notice.file, notice: notice.text })
+    if (!options.enabled) {
+      log.info("off in the settings")
+      return
+    }
+    /** Said in the block for the session, one `!` row each, until the file is fixed. */
+    const warnings = notices.map(noticeText)
     const keys = bindingLookup({ ...DEFAULT_KEYS, ...options.keybinds })
 
     const model = emptyModel()
@@ -171,7 +171,7 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st
     // --- painting ----------------------------------------------------------------------------------
 
     /** Milliseconds a finished nested subagent stays in the sidebar; undefined keeps it. */
-    const nested = typeof options.hideNestedAfter === "number" ? options.hideNestedAfter : 30
+    const nested = options.hideNestedAfterSeconds ?? 30
     const fadeAfter = nested >= 0 ? nested * 1000 : undefined
     /** A finished nested one that has yet to leave: the clock has to keep drawing until it does. */
     const fading = () =>
@@ -187,7 +187,7 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st
       const now = Date.now()
       const list = nodes()
       drawnAt = sidebarWidth()
-      const after = options.hideFinishedAfter
+      const after = options.hideFinishedAfterMinutes
       const listed =
         typeof after === "number" && after >= 0
           ? list.filter(
@@ -201,8 +201,10 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st
         width: drawnAt,
         now,
         frame,
-        limit: options.sidebarRows ?? 6,
+        limit: options.sidebarRows,
         ...(fadeAfter !== undefined ? { fadeAfter } : {}),
+        hideWhenEmpty: options.hideWhenEmpty,
+        notices: warnings,
       })
       /** Only when they changed: new rows rebuild every line of the block, and a scroll is many paints. */
       const said = JSON.stringify(next)
@@ -238,6 +240,7 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st
             closed: surface.closed,
             thinking: surface.thinking,
             details: surface.details,
+            keys: surface.keys === true,
             whole: surface.whole,
             yours: (entry) => yours.has(`${session.id}:${entry.text}`),
             reveal: surface.reveal === true,
@@ -359,7 +362,7 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st
       if (working()) {
         frame++
         draw()
-      } else if (options.hideFinishedAfter !== undefined || fading()) draw() // finished ones age out with nothing running
+      } else if (options.hideFinishedAfterMinutes !== undefined || fading()) draw() // finished ones age out with nothing running
     }, 1000)
     let fast: ReturnType | undefined
 
@@ -395,7 +398,7 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st
         notice: undefined,
         stopping: undefined,
         draft: undefined,
-        ...(same ? {} : { top: undefined, selected: undefined, details: false }),
+        ...(same ? {} : { top: undefined, selected: undefined, details: false, keys: false }),
       })
       takeKeys()
       fast ??= setInterval(() => {
@@ -557,7 +560,7 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st
         const how = api.v1 ? "task_id" : "sessionID"
         const text = [
           "[Cockpit notification — information, not a request. Nothing to do unless the user asks.]",
-          `The user messaged your ${session.agent} subagent "${session.title}" (${how} ${id}) directly.`,
+          `The user messaged your ${session.agent} subagent "${titleOf(session)}" (${how} ${id}) directly.`,
           `They asked: ${pending.question}`,
           session.status === "failed"
             ? `It failed: ${session.error ?? "no reason given"}`
@@ -818,6 +821,12 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st
           run: () => set({ details: !surface.details, top: undefined }),
         },
         { name: "cockpit.subagents.width", title: "Half or full width", run: () => width() },
+        {
+          name: "cockpit.subagents.keys",
+          title: "Show every key",
+          /** From the top of the list going in; back where the run was coming out. */
+          run: () => set({ keys: !surface.keys, top: surface.keys ? undefined : 0 }),
+        },
         { name: "cockpit.subagents.pageDown", title: "Scroll down", run: () => scroll(10) },
         { name: "cockpit.subagents.pageUp", title: "Scroll up", run: () => scroll(-10) },
         {
@@ -829,8 +838,13 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st
         {
           name: "cockpit.subagents.close",
           title: "Close",
-          /** Esc first lets go of the cursor, then closes. */
-          run: () => (surface.selected ? set({ selected: undefined, top: undefined }) : close()),
+          /** Esc first hides the keys, then lets go of the cursor, then closes. */
+          run: () =>
+            surface.keys
+              ? set({ keys: false, top: undefined })
+              : surface.selected
+                ? set({ selected: undefined, top: undefined })
+                : close(),
         },
       ],
       bindings: [
@@ -848,6 +862,7 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st
         { key: "t", cmd: "cockpit.subagents.thinking" },
         { key: "i", cmd: "cockpit.subagents.details" },
         { key: "w", cmd: "cockpit.subagents.width" },
+        { key: "?,shift+/", cmd: "cockpit.subagents.keys" },
         { key: "d,pagedown", cmd: "cockpit.subagents.pageDown" },
         { key: "u,pageup", cmd: "cockpit.subagents.pageUp" },
         { key: "shift+g,end", cmd: "cockpit.subagents.follow" },
@@ -902,8 +917,8 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st
     })
 
     api.slots.register({
-      /** Between the statusline (140) and the shells (170) by default; lower draws first. */
-      order: sidebarOrder("subagents", 150, options.sidebarOrder, { directory: api.state.path.directory }),
+      /** Where the top-level `sidebar` list puts it: under Status and above Shells by default. */
+      order,
       slots: {
         sidebar_content: () => (
            {
+  const dir = mkdtempSync("/tmp/ck-sub-")
+  dirs.push(dir)
+  return dir
+}
+afterEach(() => {
+  for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
+})
+
+/** A global file and a project file, as written; the env points the loader at the global one. */
+function files(global: string | object | undefined, project?: string | object) {
+  const home = temp()
+  const directory = temp()
+  const text = (value: string | object) => (typeof value === "string" ? value : JSON.stringify(value))
+  if (global !== undefined) {
+    mkdirSync(join(home, "opencode-cockpit"), { recursive: true })
+    writeFileSync(join(home, "opencode-cockpit", "config.json"), text(global))
+  }
+  if (project !== undefined) writeFileSync(join(directory, ".cockpit.json"), text(project))
+  return { directory, env: { XDG_CONFIG_HOME: home } }
+}
+
+describe("settings", () => {
+  test("the `subagents` section of either file, and the entry's options over both", () => {
+    const { directory, env } = files(
+      `{ // comments are fine
+        "subagents": { "sidebarRows": 3, "hideNestedAfterSeconds": 10, "guidance": false, },
+      }`,
+      { subagents: { sidebarRows: 4, hideWhenEmpty: true } },
+    )
+    const { config, notices } = loadSubagents(directory, { hideFinishedAfterMinutes: 15 }, env)
+    expect(config.sidebarRows).toBe(4)
+    expect(config.hideWhenEmpty).toBe(true)
+    expect(config.hideNestedAfterSeconds).toBe(10)
+    expect(config.hideFinishedAfterMinutes).toBe(15)
+    expect(config.guidance).toBe(false)
+    expect(notices).toEqual([])
+  })
+
+  test("defaults: six rows, present when empty, nested ones leave after 30 s, finished ones stay", () => {
+    const { directory, env } = files(undefined)
+    const { config } = loadSubagents(directory, undefined, env)
+    expect(config).toMatchObject({
+      sidebarRows: 6,
+      hideWhenEmpty: false,
+      hideNestedAfterSeconds: 30,
+      guidance: true,
+    })
+    expect(config.hideFinishedAfterMinutes).toBeUndefined()
+    expect(config.enabled).toBe(true)
+  })
+
+  test("old names are not read: each is a notice naming the new one", () => {
+    const { directory, env } = files({ subagents: { hideFinishedAfter: 5, sidebarOrder: 2 } })
+    const { config, notices } = loadSubagents(directory, { hideNestedAfter: 9 }, env)
+    expect(config.hideFinishedAfterMinutes).toBeUndefined()
+    expect(config.hideNestedAfterSeconds).toBe(30)
+    expect(notices.map((notice) => [notice.old, notice.new])).toEqual(
+      expect.arrayContaining([
+        ["subagents.hideFinishedAfter", "subagents.hideFinishedAfterMinutes"],
+        ["subagents.sidebarOrder", "sidebar"],
+        ["hideNestedAfter", "subagents.hideNestedAfterSeconds"],
+      ]),
+    )
+  })
+
+  test("a value of the wrong kind is the default, with a notice; the order is the `sidebar` list's", () => {
+    const { directory, env } = files({
+      sidebar: ["shell", "subagents"],
+      subagents: { sidebarRows: "8", hideFinishedAfterMinutes: "ten" },
+    })
+    const { config, order, notices } = loadSubagents(directory, undefined, env)
+    expect(config.sidebarRows).toBe(6)
+    expect(config.hideFinishedAfterMinutes).toBeUndefined()
+    expect(notices.some((notice) => notice.old === "subagents.sidebarRows")).toBe(true)
+    /** Second in the list: 110, then 120 (client/settings `orderOf`). */
+    expect(order).toBe(120)
+  })
+
+  test("`features.subagents: false` in a file turns both halves off", () => {
+    const { directory, env } = files({ features: { subagents: false } })
+    expect(loadSubagents(directory, undefined, env).config.enabled).toBe(false)
+  })
+})
+
+describe("a name, always", () => {
+  const session = (title: string, task?: string) => {
+    const m = applyAll(emptyModel(), [
+      { type: "session", id: "c", parentID: "p", agent: "general", title, at: 1 },
+      ...(task ? [{ type: "prompt" as const, id: "c", key: "u", text: task, at: 1 }] : []),
+    ])
+    return subagentsOf(m, "p")[0]?.session as NonNullable[number]>["session"]
+  }
+
+  test("its title; with none, or only OpenCode's placeholder, its task's first words", () => {
+    expect(titleOf(session("Map the auth flow", "Read every file"))).toBe("Map the auth flow")
+    expect(titleOf(session("", "\n  Find every caller of exportCsv\nthen list them"))).toBe(
+      "Find every caller of exportCsv",
+    )
+    expect(titleOf(session("Child session - 2026-10-03T10:00:00.000Z", "Check the tests"))).toBe(
+      "Check the tests",
+    )
+    expect(titleOf(session("   ", undefined))).toBe("subagent")
+  })
+
+  test("first words are a name, not the task", () => {
+    expect(firstWords("one two three four five six seven eight nine ten")).toBe(
+      "one two three four five six seven eight…",
+    )
+    expect(firstWords(undefined)).toBe("")
+  })
+})
+
+describe("[?] Keys", () => {
+  const nodes = subagentsOf(applyAll(emptyModel(), sample()), SAMPLE_ROOT)
+  const session = nodes[0]?.session
+  if (!session) throw new Error("the sample has no subagent")
+  const input = (width: number, height: number, keys: boolean) => ({
+    session,
+    nodes,
+    width,
+    height,
+    now: SAMPLE_NOW,
+    frame: 0,
+    open: new Set(),
+    closed: new Set(),
+    thinking: false,
+    details: false,
+    keys,
+  })
+
+  test("the footer offers it, and on it the only key is the way back", () => {
+    const run = screenRows(input(120, 40, false))
+      .rows.map(rowText)
+      .join("\n")
+    expect(run).toContain("[?] Keys")
+    const keys = screenRows(input(120, 40, true))
+    const text = keys.rows.map(rowText).join("\n")
+    expect(text).toContain("KEYS")
+    for (const line of SUBAGENT_KEYS) expect(text).toContain(line.does.split(" ").slice(0, 3).join(" "))
+    expect(text).toContain("[esc] Hide Keys")
+    expect(text).not.toContain("[m] Message")
+    /** Nothing on it is an item: j/k and enter have nothing to land on. */
+    expect(keys.keys).toEqual([])
+  })
+
+  test("every row its width, every screen its height; cut short, it says how much is below", () => {
+    for (const [width, height] of [
+      [72, 16],
+      [72, 40],
+      [140, 40],
+    ] as const) {
+      const screen = screenRows(input(width, height, true))
+      expect(screen.rows).toHaveLength(height)
+      for (const row of screen.rows) expect(widthOf(rowText(row))).toBe(width)
+    }
+    expect(
+      screenRows(input(72, 16, true))
+        .rows.map(rowText)
+        .join("\n"),
+    ).toMatch(/↓ \d+ more lines — \[d\] scrolls/)
+  })
+})
diff --git a/packages/subagents/test/sidebar.test.ts b/packages/subagents/test/sidebar.test.ts
index 0f6f9ede..8f323716 100644
--- a/packages/subagents/test/sidebar.test.ts
+++ b/packages/subagents/test/sidebar.test.ts
@@ -10,13 +10,15 @@ import {
   finishedSample,
   LATE_ROOT,
   lateSample,
+  NAMES_ROOT,
+  namesSample,
   SAMPLE_NOW,
   SAMPLE_ROOT,
   sample,
 } from "../src/core/sample.ts"
-import { rowText } from "../src/core/view/rows.ts"
+import { rowText, widthOf } from "../src/core/view/rows.ts"
 import { rowWidth } from "../src/core/view/screen.ts"
-import { type SidebarInput, sidebarLines } from "../src/core/view/sidebar.ts"
+import { nameRuns, type SidebarInput, sidebarLines } from "../src/core/view/sidebar.ts"
 
 /**
  * The sidebar's order and its nesting: working first, children under their parent, an advisor asked
@@ -55,7 +57,7 @@ describe("order", () => {
     ])
     const lines = text({ nodes: subagentsOf(m, LATE_ROOT), width: 40, now: SAMPLE_NOW, frame: 0 })
     expect(lines[2]).toContain("Document the middleware")
-    expect(lines[4]).toMatch(/^ {2}\S explore Collect examples/)
+    expect(lines[4]).toMatch(/^ {2}\S exp… Collect examples/)
     expect(lines[6]).toContain("Fix the flaky refresh test")
   })
 
@@ -354,10 +356,107 @@ describe("everything ended (the screenshots)", () => {
     }
   })
 
-  test("`general` is not named on every row; any other agent is, quietly", () => {
-    const lines = sidebarLines(input(60))
-    expect(lines.some((line) => rowText(line.row).includes("general"))).toBe(false)
-    const advisor = lines.find((line) => rowText(line.row).includes("orchestrator"))
-    expect(advisor?.row.find((run) => run.text.startsWith("orchestrator"))?.tone).toBe("muted")
+  /**
+   * `general` used to be left out to save width, so a row with an agent and one without read as two
+   * kinds of thing. Every row names its agent now, the same way: muted, before the title.
+   */
+  test("every row names its agent, muted, before its title — `general` too", () => {
+    const lines = sidebarLines(input(80))
+    const entries = lines.filter((line) => line.id && !rowText(line.row).includes("â””"))
+    expect(entries.length).toBeGreaterThan(0)
+    for (const line of entries) {
+      const agent = line.row.find((run) => /^(general|orchestrator) $/.test(run.text))
+      expect(agent?.tone).toBe("muted")
+      const at = line.row.indexOf(agent as (typeof line.row)[number])
+      expect(line.row[at + 1]?.tone).toBe("text")
+    }
+  })
+})
+
+describe("names", () => {
+  const nodes = subagentsOf(applyAll(emptyModel(), namesSample()), NAMES_ROOT)
+  const rows = (width: number) =>
+    sidebarLines({ nodes, width, now: SAMPLE_NOW, frame: 0 }).map((line) => rowText(line.row))
+
+  test("the agent shortens before the title does", () => {
+    expect(nameRuns("general", "Write a plan", 40).map((run) => run.text)).toEqual([
+      "general ",
+      "Write a plan",
+    ])
+    /** Short of room: the agent gives it up, down to its floor, and the title stays whole. */
+    expect(nameRuns("general", "Write a plan", 18).map((run) => run.text)).toEqual(["gene… ", "Write a plan"])
+    expect(nameRuns("general", "Write a plan", 17).map((run) => run.text)).toEqual(["gen… ", "Write a plan"])
+    /** Shorter still: the agent is at its floor, so now the title is cut. */
+    expect(nameRuns("general", "Write a plan", 12).map((run) => run.text)).toEqual(["gen… ", "Write …"])
+    /** An agent already shorter than the floor is never cut. */
+    expect(nameRuns("qa", "Write a plan", 10).map((run) => run.text)).toEqual(["qa ", "Write …"])
+    /** No room for both: the title, which tells two rows apart. */
+    expect(nameRuns("general", "Write a plan", 3).map((run) => run.text)).toEqual(["Wr…"])
+    for (const room of [0, 1, 3, 6, 12, 16, 40])
+      expect(widthOf(rowText(nameRuns("security-reviewer", "Review the export", room)))).toBeLessThanOrEqual(
+        room,
+      )
+  })
+
+  test("the title takes what the right column leaves, and the numbers stay whole", () => {
+    for (const width of [24, 30, 36, 50]) {
+      for (const row of rows(width)) expect(widthOf(row)).toBe(width)
+      const plan = rows(width).find((row) => row.includes("Write"))
+      expect(plan).toMatch(/\d+m\d{2}s$/)
+    }
+    expect(rows(36).find((row) => row.includes("Write"))).toBe("� gen… Write a lon… 12 calls · 2m15s")
+  })
+
+  test("a subagent with no title is named by its task's first words, never by nothing", () => {
+    const bare = rows(50).find((row) => row.includes("Find every"))
+    expect(bare).toContain("general Find every caller of exportCsv")
+    /** OpenCode's placeholder is no title either. */
+    const auto = rows(80).find((row) => row.includes("Check the export"))
+    expect(auto).toContain("explore Check the export's tests for flakiness across the…")
+    expect(rows(80).join("\n")).not.toContain("Child session")
+  })
+})
+
+describe("present when empty", () => {
+  const NOTICE = 'settings: "subagents.hideFinishedAfter" is no longer read — run /cockpit-setup'
+  const empty = (extra: Partial = {}) =>
+    sidebarLines({ nodes: [], width: 30, now: SAMPLE_NOW, frame: 0, ...extra }).map((line) =>
+      rowText(line.row),
+    )
+
+  test("no subagents: the heading, and `none yet` in the row the first one will take", () => {
+    const rows = empty()
+    expect(rows.map((row) => row.trimEnd())).toEqual(["Subagents", "", "none yet"])
+    for (const row of rows) expect(widthOf(row)).toBe(30)
+    const lines = sidebarLines({ nodes: [], width: 30, now: SAMPLE_NOW, frame: 0 })
+    expect(lines.at(-1)?.row[0]?.tone).toBe("muted")
+    expect(lines.every((line) => line.id === undefined)).toBe(true)
+  })
+
+  test("one finished subagent takes the `none yet` row: the block is as tall as it was", () => {
+    const m = applyAll(emptyModel(), sub("a", "p", 1, 2, "Only one"))
+    const one = sidebarLines({ nodes: subagentsOf(m, "p"), width: 30, now: SAMPLE_NOW, frame: 0 })
+    expect(one).toHaveLength(empty().length)
+    expect(rowText(one[2]?.row ?? [])).toContain("Only one")
+  })
+
+  test("`hideWhenEmpty` draws nothing — unless a setting needs fixing", () => {
+    expect(empty({ hideWhenEmpty: true })).toEqual([])
+    const rows = empty({ hideWhenEmpty: true, notices: [NOTICE] })
+    expect(rows[0]?.trimEnd()).toBe("Subagents")
+    expect(rows.join(" ")).toContain("/cockpit-setup")
+  })
+
+  test("a settings notice is said in the block, wrapped, its fix kept, at every width", () => {
+    const nodes = subagentsOf(applyAll(emptyModel(), sample()), SAMPLE_ROOT)
+    for (const width of [24, 30, 36, 50]) {
+      const rows = sidebarLines({ nodes, width, now: SAMPLE_NOW, frame: 0, notices: [NOTICE] }).map((line) =>
+        rowText(line.row),
+      )
+      for (const row of rows) expect(widthOf(row)).toBe(width)
+      const at = rows.findIndex((row) => row.startsWith("! settings"))
+      expect(at).toBeGreaterThan(2)
+      expect(rows.slice(at).join(" ")).toContain("/cockpit-setup")
+    }
   })
 })
diff --git a/packages/subagents/test/truth.test.ts b/packages/subagents/test/truth.test.ts
index fa499912..eb307e24 100644
--- a/packages/subagents/test/truth.test.ts
+++ b/packages/subagents/test/truth.test.ts
@@ -213,9 +213,9 @@ describe("a subagent continued the next day (two rounds, a day apart)", () => {
         .find((text) => text.includes("Review"))
         ?.trimEnd()
     expect(row(60)).toEndWith("17 calls · 4m00s · 2 rounds")
-    /** Narrower, the calls give way to the rounds, then the rounds to the title. */
-    expect(row(40)).toEndWith("� Review the export qu… 4m00s · 2 rounds")
-    expect(row(30)).toEndWith("17 calls · 4m00s")
+    /** Narrower, the calls give way to the rounds, then the rounds to the name. */
+    expect(row(40)).toEndWith("� gen… Review the expo… 4m00s · 2 rounds")
+    expect(row(30)).toEndWith("� gen… Review the expor… 4m00s")
     for (const width of [30, 40, 60]) expect(row(width)).not.toContain("24h")
   })
 
diff --git a/packages/subagents/test/view.test.ts b/packages/subagents/test/view.test.ts
index fa031c4e..46b9e1cc 100644
--- a/packages/subagents/test/view.test.ts
+++ b/packages/subagents/test/view.test.ts
@@ -61,7 +61,7 @@ for (const version of [1, 2] as const) {
       const id = nodes[0]?.session.id
       expect(lines.map((line) => line.id)).toEqual([undefined, undefined, id])
       expect(rowText(lines[0]?.row ?? []).trimEnd()).toMatch(/^Subagents +1 done$/)
-      expect(rowText(lines[2]?.row ?? []).trimEnd()).toMatch(/^� explore .+ \d+ calls · \d+s$/)
+      expect(rowText(lines[2]?.row ?? []).trimEnd()).toMatch(/^� exp(lore|…) .+ \d+ calls · \d+s$/)
     })
 
     test("the pane: exactly its height, every row exactly its width, in every state", async () => {

From 93715917e0f75bfc885f8259d2df5d8a41e85a9e Mon Sep 17 00:00:00 2001
From: Codestz 
Date: Sat, 3 Oct 2026 10:08:57 -0500
Subject: [PATCH 17/90] Review: settings from the review section of the files

variant and source are read through baySettings from the files and the
plugin entry; an unknown value is the default and a notice, said once as a
toast since Review has no sidebar block.

Co-Authored-By: Claude Opus 5.5 
---
 packages/review/src/core/config.ts  | 80 +++++++++++++++++++++++++++++
 packages/review/src/tui/index.tsx   | 40 +++++++++------
 packages/review/test/config.test.ts | 70 +++++++++++++++++++++++++
 3 files changed, 174 insertions(+), 16 deletions(-)
 create mode 100644 packages/review/src/core/config.ts
 create mode 100644 packages/review/test/config.test.ts

diff --git a/packages/review/src/core/config.ts b/packages/review/src/core/config.ts
new file mode 100644
index 00000000..41e03afa
--- /dev/null
+++ b/packages/review/src/core/config.ts
@@ -0,0 +1,80 @@
+/**
+ * Review's settings, through the loader every bay shares (`@opencode-cockpit/client/settings`):
+ *
+ *   ~/.config/opencode-cockpit/config.json  →  /.cockpit.json  →  plugin-entry options
+ *
+ * Only the `review` section of a file is read. Before 0.9 Review read no file at all: its settings
+ * existed only on the plugin entry in `tui.json`. A value Review does not know (`"variant": "left"`)
+ * is a notice and the default, never a placement or a source that does not exist.
+ */
+
+import { baySettings, OPTIONS_SOURCE, type SettingsNotice } from "@opencode-cockpit/client/settings"
+import type { Source } from "./model/review.ts"
+import { VARIANTS, type Variant } from "./view/frame.ts"
+
+const SOURCES: readonly Source[] = ["worktree", "branch"]
+
+export interface ReviewConfig {
+  /** Off switch for this bay, wherever it is written. `features.review: false` too. */
+  enabled: boolean
+  /** Which placement to open in: right | full. */
+  variant: Variant
+  /**
+   * What to review on open: worktree | branch.
+   *
+   * Uncommitted by default, because that is what you are looking at nine times in ten — the work
+   * that just happened. Branch is for reading a pull request, which is a thing you choose to do.
+   */
+  source: Source
+  keybinds: Record
+}
+
+export const DEFAULTS = { variant: "right" as Variant, source: "worktree" as Source }
+
+export interface LoadedReview {
+  config: ReviewConfig
+  /** Settings to fix: an old name, a wrong kind, a value Review does not know. */
+  notices: SettingsNotice[]
+}
+
+/** Reads and merges every source. Never throws. */
+export function loadReview(
+  directory: string,
+  options?: unknown,
+  env: Record = process.env,
+): LoadedReview {
+  const loaded = baySettings("review", DEFAULTS, { options, where: { directory, env } })
+  const notices = [...loaded.notices]
+  /** Where the value in force was written: the entry's options win, then the last file that set it. */
+  const own = (options ?? {}) as Record | undefined>
+  const fileOf = (key: string): string => {
+    const entry = typeof own.review === "object" && own.review !== null ? own.review : own
+    if ((entry as Record)[key] !== undefined) return OPTIONS_SOURCE
+    const layer = [...loaded.settings.layers]
+      .reverse()
+      .find((each) => each.sections.review?.[key] !== undefined)
+    return layer?.path ?? OPTIONS_SOURCE
+  }
+  /** One of the names it knows, or the default and a notice saying which names those are. */
+  const known = (key: "variant" | "source", valid: readonly T[], fallback: T): T => {
+    const value = loaded.config[key]
+    if ((valid as readonly string[]).includes(value)) return value as T
+    notices.push({
+      bay: "review",
+      file: fileOf(key),
+      kind: "invalid",
+      old: `review.${key}`,
+      text: `"review.${key}" is "${value}"; it is one of ${valid.join(", ")} — "${fallback}" is used`,
+    })
+    return fallback
+  }
+  return {
+    config: {
+      enabled: loaded.config.enabled,
+      variant: known("variant", VARIANTS, DEFAULTS.variant),
+      source: known("source", SOURCES, DEFAULTS.source),
+      keybinds: loaded.config.keybinds,
+    },
+    notices,
+  }
+}
diff --git a/packages/review/src/tui/index.tsx b/packages/review/src/tui/index.tsx
index 22cd7af6..41ebecf6 100644
--- a/packages/review/src/tui/index.tsx
+++ b/packages/review/src/tui/index.tsx
@@ -2,7 +2,9 @@
 
 import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client/feature"
 import { bindingLookup, dualTui, type Host } from "@opencode-cockpit/client/host"
+import { noticeText } from "@opencode-cockpit/client/settings"
 import type { BoxRenderable } from "@opentui/core"
+import { 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"
@@ -10,7 +12,7 @@ import { matchesBinding, paletteBindings } from "../core/palette.ts"
 import { metrics } from "../core/perf.ts"
 import { reviewPaths } from "../core/store/paths.ts"
 import { createPersistence } from "../core/store/persist.ts"
-import { frameBounds, VARIANTS, type Variant } from "../core/view/frame.ts"
+import { frameBounds, VARIANTS } from "../core/view/frame.ts"
 import { FOOTER_ROWS, HEADER_ROWS } from "../core/view/geometry.ts"
 import { createViewer, systemSpawn, systemWhich } from "../core/viewer.ts"
 import { createStore } from "./data/changes.ts"
@@ -37,18 +39,8 @@ const SLOT_ORDER = 180
 
 const SOURCES: Source[] = ["worktree", "branch"]
 
-export interface ReviewTuiOptions {
-  /** Which placement to open in: right | full. */
-  variant?: Variant
-  /**
-   * What to review on open: worktree | branch | session.
-   *
-   * Uncommitted by default, because that is what you are looking at nine times in ten — the work
-   * that just happened. Branch is for reading a pull request, which is a thing you choose to do.
-   */
-  source?: Source
-  keybinds?: Record
-}
+/** The `review` section of the config files, then the plugin entry's options (core/config.ts). */
+export type ReviewTuiOptions = Partial
 
 /**
  * Review's TUI half: what it is made of, and who owns what.
@@ -77,10 +69,26 @@ export function createReviewTui({ source = REVIEW_PACKAGE }: { source?: string }
     }
     api.lifecycle.onDispose(() => claim.release())
 
-    const options = (rawOptions ?? {}) as ReviewTuiOptions
+    const { config: options, notices } = loadReview(api.state.path.directory, rawOptions)
+    if (!options.enabled) {
+      api.log.info("review: off in the settings")
+      return
+    }
+    /**
+     * Review draws no sidebar block for a `!` row, so its settings notices are said once, as the
+     * session starts, and kept in the log.
+     */
+    for (const notice of notices) api.log.warn("review: settings", { file: notice.file, notice: notice.text })
+    if (notices.length > 0)
+      api.ui.toast({
+        variant: "warning",
+        title: "Review",
+        message: notices.map(noticeText).join("\n"),
+        duration: 10_000,
+      })
     const keys = bindingLookup({ ...DEFAULT_KEYS, ...options.keybinds })
-    const store = createStore(api, options.source ?? "worktree")
-    const surface = createSurface(options.variant ?? "right")
+    const store = createStore(api, options.source)
+    const surface = createSurface(options.variant)
 
     /**
      * The renderables the slot hands back, which do not exist until it mounts.
diff --git a/packages/review/test/config.test.ts b/packages/review/test/config.test.ts
new file mode 100644
index 00000000..4826473d
--- /dev/null
+++ b/packages/review/test/config.test.ts
@@ -0,0 +1,70 @@
+import { afterEach, describe, expect, test } from "bun:test"
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
+import { join } from "node:path"
+import { loadReview } from "../src/core/config.ts"
+
+/**
+ * Review reads the files now, through the loader every bay shares: the `review` section, then the
+ * plugin entry. Before 0.9 its settings lived only on the entry in `tui.json`.
+ */
+
+const dirs: string[] = []
+const temp = () => {
+  const dir = mkdtempSync("/tmp/ck-review-conf-")
+  dirs.push(dir)
+  return dir
+}
+afterEach(() => {
+  for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
+})
+
+function files(global: string | object | undefined, project?: string | object) {
+  const home = temp()
+  const directory = temp()
+  const text = (value: string | object) => (typeof value === "string" ? value : JSON.stringify(value))
+  if (global !== undefined) {
+    mkdirSync(join(home, "opencode-cockpit"), { recursive: true })
+    writeFileSync(join(home, "opencode-cockpit", "config.json"), text(global))
+  }
+  if (project !== undefined) writeFileSync(join(directory, ".cockpit.json"), text(project))
+  return { directory, env: { XDG_CONFIG_HOME: home } }
+}
+
+describe("settings", () => {
+  test("defaults: the right pane, uncommitted work", () => {
+    const { directory, env } = files(undefined)
+    const { config, notices } = loadReview(directory, undefined, env)
+    expect(config).toMatchObject({ enabled: true, variant: "right", source: "worktree", keybinds: {} })
+    expect(notices).toEqual([])
+  })
+
+  test("the `review` section of either file, and the entry's options over both", () => {
+    const { directory, env } = files(
+      `{ "review": { "variant": "full", "source": "branch", } // comments are fine
+      }`,
+      { review: { keybinds: { "cockpit.review.open": "c" } } },
+    )
+    const fromFiles = loadReview(directory, undefined, env).config
+    expect(fromFiles).toMatchObject({ variant: "full", source: "branch" })
+    expect(fromFiles.keybinds).toEqual({ "cockpit.review.open": "c" })
+    /** A standalone entry carries its own keys; the bundle hands over a `review` section. */
+    for (const options of [{ variant: "right" }, { review: { variant: "right" } }])
+      expect(loadReview(directory, options, env).config.variant).toBe("right")
+  })
+
+  test("a value Review does not know is the default and a notice naming the ones it does", () => {
+    const { directory, env } = files({ review: { variant: "left" } })
+    const { config, notices } = loadReview(directory, { source: "session" }, env)
+    expect(config.variant).toBe("right")
+    expect(config.source).toBe("worktree")
+    const variant = notices.find((notice) => notice.old === "review.variant")
+    expect(variant?.text).toContain("right, full")
+    expect(variant?.file).toEndWith("config.json")
+    expect(notices.find((notice) => notice.old === "review.source")?.file).toBe("plugin options")
+  })
+
+  test("`features.review: false` in a file turns it off", () => {
+    const { directory, env } = files({ features: { review: false } })
+    expect(loadReview(directory, undefined, env).config.enabled).toBe(false)
+  })
+})

From 58b507fcebd57e6b0c3ad248fb9c99ab70979146 Mon Sep 17 00:00:00 2001
From: Codestz 
Date: Sat, 3 Oct 2026 10:19:22 -0500
Subject: [PATCH 18/90] =?UTF-8?q?Subagents:=20no=20rows,=20no=20box=20?=
 =?UTF-8?q?=E2=80=94=20an=20empty=20one=20took=20a=20row=20of=20sidebar=20?=
 =?UTF-8?q?space?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Seen on OpenCode 1 with hideWhenEmpty: the block's empty box still got the
host's gap between blocks, so the sidebar had a blank row for nothing.

Co-Authored-By: Claude Opus 5.5 
---
 packages/subagents/src/tui/view/sidebar.tsx | 76 +++++++++++----------
 1 file changed, 41 insertions(+), 35 deletions(-)

diff --git a/packages/subagents/src/tui/view/sidebar.tsx b/packages/subagents/src/tui/view/sidebar.tsx
index 3ce02b32..9336fcf1 100644
--- a/packages/subagents/src/tui/view/sidebar.tsx
+++ b/packages/subagents/src/tui/view/sidebar.tsx
@@ -2,7 +2,7 @@
 // biome-ignore-all lint/a11y/noStaticElementInteractions: these are terminal boxes, not DOM elements
 import type { Host } from "@opencode-cockpit/client/host"
 import type { BoxRenderable } from "@opentui/core"
-import { For, type JSX } from "solid-js"
+import { For, type JSX, Show } from "solid-js"
 import type { SidebarLine } from "../../core/view/sidebar.ts"
 import { fillColour, toneColour } from "../render.ts"
 
@@ -25,40 +25,46 @@ export interface SidebarProps {
  */
 export function SidebarBlock(props: SidebarProps): JSX.Element {
   const theme = () => props.api.theme.current
+  /**
+   * No rows (`hideWhenEmpty`), no box: an empty box still took the row of space the host puts
+   * between blocks (seen on OpenCode 1).
+   */
   return (
-     props.onReady?.(box)}>
-      
-        {(line) => (
-           {
-              if (line.id) props.onOpen(line.id)
-            }}
-          >
-            
-              
-                {(run) => {
-                  const style = {
-                    fg: toneColour(theme(), run.tone),
-                    ...(run.fill && run.fill !== "none" ? { bg: fillColour(theme(), run.fill) } : {}),
-                  }
-                  return run.bold ? (
-                    
-                      {run.text}
-                    
-                  ) : run.faint ? (
-                    
-                      {run.text}
-                    
-                  ) : (
-                    {run.text}
-                  )
-                }}
-              
-            
-          
-        )}
-      
-    
+     0}>
+       props.onReady?.(box)}>
+        
+          {(line) => (
+             {
+                if (line.id) props.onOpen(line.id)
+              }}
+            >
+              
+                
+                  {(run) => {
+                    const style = {
+                      fg: toneColour(theme(), run.tone),
+                      ...(run.fill && run.fill !== "none" ? { bg: fillColour(theme(), run.fill) } : {}),
+                    }
+                    return run.bold ? (
+                      
+                        {run.text}
+                      
+                    ) : run.faint ? (
+                      
+                        {run.text}
+                      
+                    ) : (
+                      {run.text}
+                    )
+                  }}
+                
+              
+            
+          )}
+        
+      
+    
   )
 }

From 7376dcae7a0c94256009d8bbeedb6ba7fda7f198 Mon Sep 17 00:00:00 2001
From: Codestz 
Date: Sat, 3 Oct 2026 10:20:01 -0500
Subject: [PATCH 19/90] client/design: warnRows keeps up to five rows, so a
 24-cell sidebar still shows the fix

Co-Authored-By: Claude Opus 5.5 
---
 packages/client/src/design.ts     | 2 +-
 packages/shell/src/cli/preview.ts | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/packages/client/src/design.ts b/packages/client/src/design.ts
index 441cfd88..56c7997e 100644
--- a/packages/client/src/design.ts
+++ b/packages/client/src/design.ts
@@ -279,7 +279,7 @@ export function emptyBlock(title: string, width: number, hideWhenEmpty = false):
  * sidebar is 24–36 cells and the sentence is wider, so it wraps rather than losing its last words
  * (the fix it names). At most `maxRows`; the last one ends in `…` when there was more.
  */
-export function warnRows(text: string, width: number, maxRows = 3): ToneRun[][] {
+export function warnRows(text: string, width: number, maxRows = 5): ToneRun[][] {
   const room = Math.max(0, width)
   const prefix = `${GLYPH.warn} `
   const indent = " ".repeat(prefix.length)
diff --git a/packages/shell/src/cli/preview.ts b/packages/shell/src/cli/preview.ts
index 303e4dea..2b8afd05 100644
--- a/packages/shell/src/cli/preview.ts
+++ b/packages/shell/src/cli/preview.ts
@@ -114,7 +114,7 @@ interface SidebarState {
 
 function sidebar(list: readonly ShellInfo[], width: number, state: SidebarState = {}): Row[] {
   /** As `components/sidebar.tsx` draws them: the design module's rows for an empty block and a notice. */
-  const warnings = (state.notices ?? []).flatMap((text) => warnRows(text, width, 5).map((row) => row as Row))
+  const warnings = (state.notices ?? []).flatMap((text) => warnRows(text, width).map((row) => row as Row))
   if (list.length === 0)
     return [
       ...emptyBlock("Shells", width, state.hideWhenEmpty === true && warnings.length === 0).map(

From 3bf8cbada5e04292aa2ea8eb4fff0a1b116f069e Mon Sep 17 00:00:00 2001
From: Codestz 
Date: Sat, 3 Oct 2026 10:02:03 -0500
Subject: [PATCH 20/90] =?UTF-8?q?Trail:=20both=20halves=20=E2=80=94=20trai?=
 =?UTF-8?q?l=5Fadd,=20trail=5Flist,=20the=20guidance=20and=20the=20safety?=
 =?UTF-8?q?=20net;=20the=20sidebar,=20/trail=20and=20/link?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The agent half (dualServer): the two tools, GUIDANCE on every request with the
conversation's produced line and the links seen in what it ran, rebuilt from
events.ndjson every time; subagents' records go to the root session and name the
subagent; each record keeps the conversation's title. The interface half: the
sidebar block at the settings' order (client emptyBlock, warnRows for notices),
rows that open the page or /trail, /trail on both versions, /link, and the
conversation's stored history read for finds, offered and never added.

Co-Authored-By: Claude Opus 5.5 
---
 bun.lock                                |   7 +
 packages/trail/package.json             |  16 +
 packages/trail/server.js                |   6 +
 packages/trail/src/agent/plugin.ts      | 205 ++++++++
 packages/trail/src/cli/preview.ts       |  22 +
 packages/trail/src/core/config.ts       |  47 ++
 packages/trail/src/core/index.ts        |   3 +
 packages/trail/src/core/open.ts         |  45 ++
 packages/trail/src/core/scan.ts         | 149 ++++++
 packages/trail/src/core/tools.ts        |  18 +-
 packages/trail/src/core/view/sidebar.ts |  46 +-
 packages/trail/src/server.ts            |   2 +
 packages/trail/src/tui/index.tsx        | 632 ++++++++++++++++++++++++
 packages/trail/src/tui/open.ts          |  43 ++
 packages/trail/src/tui/render.ts        |  95 ++++
 packages/trail/src/tui/source.ts        | 148 ++++++
 packages/trail/src/tui/view/dialog.tsx  |  55 +++
 packages/trail/src/tui/view/rows.tsx    |  57 +++
 packages/trail/test/config.test.ts      |  63 +++
 packages/trail/test/scan.test.ts        | 175 +++++++
 packages/trail/test/server.test.ts      | 188 +++++++
 packages/trail/test/view.test.ts        |  42 ++
 packages/trail/tsconfig.json            |   2 +
 packages/trail/tui.js                   |   6 +
 24 files changed, 2060 insertions(+), 12 deletions(-)
 create mode 100644 packages/trail/server.js
 create mode 100644 packages/trail/src/agent/plugin.ts
 create mode 100644 packages/trail/src/core/config.ts
 create mode 100644 packages/trail/src/core/open.ts
 create mode 100644 packages/trail/src/core/scan.ts
 create mode 100644 packages/trail/src/server.ts
 create mode 100644 packages/trail/src/tui/index.tsx
 create mode 100644 packages/trail/src/tui/open.ts
 create mode 100644 packages/trail/src/tui/render.ts
 create mode 100644 packages/trail/src/tui/source.ts
 create mode 100644 packages/trail/src/tui/view/dialog.tsx
 create mode 100644 packages/trail/src/tui/view/rows.tsx
 create mode 100644 packages/trail/test/config.test.ts
 create mode 100644 packages/trail/test/scan.test.ts
 create mode 100644 packages/trail/test/server.test.ts
 create mode 100644 packages/trail/tui.js

diff --git a/bun.lock b/bun.lock
index 207a125b..8d544273 100644
--- a/bun.lock
+++ b/bun.lock
@@ -51,6 +51,7 @@
         "@opencode-cockpit/shell": "workspace:*",
         "@opencode-cockpit/status": "workspace:*",
         "@opencode-cockpit/subagents": "workspace:*",
+        "@opencode-cockpit/trail": "workspace:*",
         "@opencode-cockpit/trust": "workspace:*",
         "@opencode-cockpit/updater": "workspace:*",
       },
@@ -150,6 +151,12 @@
         "@opencode-ai/plugin": "1.18.31",
         "@opencode-cockpit/client": "workspace:*",
       },
+      "devDependencies": {
+        "@opentui/core": "0.4.5",
+        "@opentui/keymap": "0.4.5",
+        "@opentui/solid": "0.4.5",
+        "solid-js": "1.9.12",
+      },
     },
     "packages/trust": {
       "name": "@opencode-cockpit/trust",
diff --git a/packages/trail/package.json b/packages/trail/package.json
index 9ee989ec..f6e8f0e8 100644
--- a/packages/trail/package.json
+++ b/packages/trail/package.json
@@ -23,6 +23,14 @@
     "terminal"
   ],
   "exports": {
+    "./server": {
+      "types": "./types/server.d.ts",
+      "default": "./dist/server.js"
+    },
+    "./tui": {
+      "types": "./types/tui/index.d.ts",
+      "default": "./dist/tui/index.js"
+    },
     "./core": {
       "types": "./types/core/index.d.ts",
       "default": "./dist/core/index.js"
@@ -33,6 +41,8 @@
     "bun": ">=1.3.5"
   },
   "files": [
+    "server.js",
+    "tui.js",
     "dist",
     "types",
     "README.md",
@@ -45,6 +55,12 @@
     "@opencode-cockpit/client": "workspace:*",
     "@opencode-ai/plugin": "1.18.31"
   },
+  "devDependencies": {
+    "@opentui/core": "0.4.5",
+    "@opentui/keymap": "0.4.5",
+    "@opentui/solid": "0.4.5",
+    "solid-js": "1.9.12"
+  },
   "bin": {
     "opencode-trail": "./dist/cli/preview.js",
     "trail": "./dist/cli/preview.js"
diff --git a/packages/trail/server.js b/packages/trail/server.js
new file mode 100644
index 00000000..10f25fa6
--- /dev/null
+++ b/packages/trail/server.js
@@ -0,0 +1,6 @@
+/**
+ * OpenCode 2 finds a plugin configured by *path* by the files at its root — `/tui`,
+ * `/server` — rather than through `exports` (docs/opencode/v2.md). A package installed by
+ * name resolves through `exports` as before; this file is only the door for the path case.
+ */
+export { default } from "./dist/server.js"
diff --git a/packages/trail/src/agent/plugin.ts b/packages/trail/src/agent/plugin.ts
new file mode 100644
index 00000000..f3441553
--- /dev/null
+++ b/packages/trail/src/agent/plugin.ts
@@ -0,0 +1,205 @@
+/**
+ * Trail's agent half: the two tools, the guidance on every request, and the safety net.
+ *
+ * The agent writes the trail — it alone knows what it just did, with whatever tools the person has —
+ * and this half keeps what it says (docs/roadmap/v0.9/trail.md). Four layers make sure it does:
+ *
+ * 1. **The guidance** (`GUIDANCE`), in the system prompt of every request, subagents' included.
+ * 2. **The tool's description**, when-to-call first: OpenCode 2's Code Mode catalog shows only its
+ *    first ~115 characters (docs/opencode/trail-server.md).
+ * 3. **Two lines rebuilt on every request** from the trail file, never from a summary: what this
+ *    conversation produced (so it survives compaction), and PR or issue links seen in the output of
+ *    something it ran and not recorded — worded as a choice, because a printed link is not a made one.
+ * 4. **The measurement** (`measure/`): a fake `gh pr create` and a real turn that must end in trail_add.
+ *
+ * A record belongs to the conversation — the root session — whichever subagent made it, and says
+ * which one did. Each keeps the conversation's title: OpenCode 2's agent half cannot list sessions,
+ * and a deleted one is gone from both versions.
+ */
+
+import { type ToolDefinition, tool } from "@opencode-ai/plugin"
+import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client"
+import { dualServer, type ServerHost, type ServerStart } from "@opencode-cockpit/client/server"
+import { loadTrail } from "../core/config.ts"
+import { createJournal } from "../core/journal.ts"
+import { type Found, notRecorded } from "../core/model.ts"
+import { trailPaths } from "../core/paths.ts"
+import { addFinds, findsOf } from "../core/scan.ts"
+import { emptyState, type State } from "../core/store.ts"
+import {
+  ADD_ARGS,
+  ADD_DESCRIPTION,
+  GUIDANCE,
+  LIST_ARGS,
+  LIST_DESCRIPTION,
+  producedLine,
+  seenLine,
+} from "../core/text.ts"
+import { runAdd, runList } from "../core/tools.ts"
+
+export const TRAIL_PACKAGE = "@opencode-cockpit/trail"
+
+/**
+ * How long a link seen in output is put to the agent. Long enough for the turn that printed it and
+ * the next one; not forever, or a link it rightly declined (one it only echoed) would ride along on
+ * every request for the rest of the conversation. `/trail` keeps offering it to the person.
+ */
+export const SEEN_FOR_MS = 60 * 60_000
+
+/** How far up `parentID` a subagent's session is walked to its conversation. */
+const DEPTH = 8
+
+export interface TrailServerOptions {
+  source?: string
+}
+
+/** The session → conversation walk, cached: a session's parent never changes. */
+export function createRoots(host: Pick) {
+  const parents = new Map()
+  const parentOf = async (id: string): Promise => {
+    if (parents.has(id)) return parents.get(id) ?? undefined
+    const info = await host.session.get(id).catch(() => undefined)
+    /** Unknown is not cached: a lookup that failed now may answer next time. */
+    if (info) parents.set(id, info.parentID ?? null)
+    return info?.parentID
+  }
+  return {
+    rootOf: async (id: string): Promise => {
+      let at = id
+      for (let hop = 0; hop < DEPTH; hop++) {
+        const parent = await parentOf(at)
+        if (!parent) return at
+        at = parent
+      }
+      return at
+    },
+  }
+}
+
+/** The agent half as a factory, so the `opencode-cockpit` bundle can include it. */
+export function createTrailServer({ source = TRAIL_PACKAGE }: TrailServerOptions = {}): ServerStart {
+  return async (host, rawOptions) => {
+    const log = host.log.child("trail")
+    const { settings, notices } = loadTrail(host.directory, rawOptions)
+    for (const notice of notices) log.warn("settings", { notice })
+    if (!settings.enabled) {
+      log.info("off by config", { directory: host.directory })
+      return {}
+    }
+    const claim = claimFeature(host.scope, "trail", source)
+    if (!claim.active) {
+      log.warn(duplicateFeatureMessage("Trail", claim.owner, source))
+      return {}
+    }
+
+    const paths = trailPaths(host.directory)
+    const journal = createJournal(paths)
+    let state: State = emptyState()
+    /** Every window, and on OpenCode 1 every directory's instance, appends to the same file. */
+    const sync = async () => {
+      state = await journal.sync(state).catch((error) => {
+        log.warn("trail unreadable", { file: paths.events, error })
+        return state
+      })
+      return state
+    }
+    const roots = createRoots(host)
+    /** PR and issue links seen in what each conversation ran, by its root session. */
+    const seen = new Map()
+
+    const who = async (sessionID: string, agent: string | undefined) => {
+      const root = await roots.rootOf(sessionID)
+      const title = (await host.session.get(root).catch(() => undefined))?.title
+      return {
+        session: sessionID,
+        rootSession: root,
+        ...(title ? { sessionTitle: title } : {}),
+        by: "agent" as const,
+        /** The subagent that made it: `agent` names the one running in the session that called. */
+        ...(sessionID !== root && agent ? { subagent: agent } : {}),
+        at: Date.now(),
+      }
+    }
+
+    const z = tool.schema
+    const add: ToolDefinition = tool({
+      description: ADD_DESCRIPTION,
+      args: {
+        title: z.string().describe(ADD_ARGS.title),
+        url: z.string().optional().describe(ADD_ARGS.url),
+        ref: z.string().optional().describe(ADD_ARGS.ref),
+        kind: z.string().optional().describe(ADD_ARGS.kind),
+        action: z.string().optional().describe(ADD_ARGS.action),
+        for: z.string().optional().describe(ADD_ARGS.for),
+        note: z.string().optional().describe(ADD_ARGS.note),
+      },
+      async execute(args, context) {
+        await sync()
+        const out = runAdd(state, args, await who(context.sessionID, context.agent))
+        if (!out.ok) {
+          log.debug("refused", { sessionID: context.sessionID, why: out.text })
+          return out.text
+        }
+        await journal.append([out.event])
+        log.info("recorded", {
+          session: out.event.rootSession,
+          url: out.event.url,
+          ref: out.event.ref,
+          action: out.event.action,
+          ...(out.event.subagent ? { subagent: out.event.subagent } : {}),
+        })
+        return out.text
+      },
+    })
+
+    const list: ToolDefinition = tool({
+      description: LIST_DESCRIPTION,
+      args: {
+        all: z.boolean().optional().describe(LIST_ARGS.all),
+        query: z.string().optional().describe(LIST_ARGS.query),
+      },
+      async execute(args, context) {
+        await sync()
+        return runList(state, args, await roots.rootOf(context.sessionID), Date.now())
+      },
+    })
+
+    return {
+      tools: { trail_add: add, trail_list: list },
+      /** Before every model request, the subagents' too: the guidance, and the two lines for this conversation. */
+      system: async (sessionID) => {
+        if (!sessionID) return [GUIDANCE]
+        const root = await roots.rootOf(sessionID)
+        await sync()
+        const since = Date.now() - SEEN_FOR_MS
+        const fresh = (seen.get(root) ?? []).filter((found) => found.at >= since)
+        return [GUIDANCE, producedLine(state, root), seenLine(notRecorded(state, root, fresh))].filter(
+          (line): line is string => line !== undefined,
+        )
+      },
+      toolAfter: async (call) => {
+        const found = findsOf(call, Date.now())
+        if (found.length === 0) return
+        const root = await roots.rootOf(call.sessionID)
+        const list = seen.get(root) ?? []
+        if (addFinds(list, found))
+          log.debug("seen", { session: root, tool: call.tool, urls: found.map((f) => f.url) })
+        seen.set(root, list)
+      },
+      /** A deleted conversation keeps its records, marked; OpenCode cannot say its title afterwards. */
+      sessionDeleted: async (sessionID) => {
+        seen.delete(sessionID)
+        await sync()
+        const has = [...state.records.values()].some((record) => record.session === sessionID)
+        if (!has || state.conversations.get(sessionID)?.deletedAt !== undefined) return
+        await journal.append([
+          { v: 1, at: Date.now(), id: `del_${sessionID}`, type: "deleted", rootSession: sessionID },
+        ])
+        log.info("conversation deleted; its records stay", { session: sessionID })
+      },
+      dispose: () => claim.release(),
+    }
+  }
+}
+
+export default dualServer("opencode-cockpit.trail", createTrailServer())
diff --git a/packages/trail/src/cli/preview.ts b/packages/trail/src/cli/preview.ts
index 8d30b5b1..901694c8 100644
--- a/packages/trail/src/cli/preview.ts
+++ b/packages/trail/src/cli/preview.ts
@@ -112,6 +112,8 @@ const frame = (rows: Row[]) => {
   return rows.map((row) => `${edge}${paint(row)}${edge}`)
 }
 
+const NOTICE = 'settings: "trail.sidebarRows" should be a number; the default is used'
+
 const out: string[] = []
 for (const name of names) {
   const make = SAMPLES[name]
@@ -124,6 +126,26 @@ for (const name of names) {
   const mine = arrange(conversationThings(state, session))
   out.push("", `Sidebar (${sidebarWidth} columns)`, "")
   out.push(...frame(sidebarRows({ width: sidebarWidth, arranged: mine, now: SAMPLE_NOW, limit: 6 }).rows))
+  /** A settings notice, at a narrow sidebar's width: it wraps rather than losing the fix it names. */
+  if (name === "empty" || name === "one") {
+    out.push(
+      "",
+      `Sidebar with a settings notice (30 columns${name === "empty" ? ", and hideWhenEmpty" : ""})`,
+      "",
+    )
+    out.push(
+      ...frame(
+        sidebarRows({
+          width: 30,
+          arranged: mine,
+          now: SAMPLE_NOW,
+          limit: 6,
+          hideWhenEmpty: name === "empty",
+          notices: [NOTICE],
+        }).rows,
+      ),
+    )
+  }
 
   const dialog = (
     title: string,
diff --git a/packages/trail/src/core/config.ts b/packages/trail/src/core/config.ts
new file mode 100644
index 00000000..e87463c8
--- /dev/null
+++ b/packages/trail/src/core/config.ts
@@ -0,0 +1,47 @@
+/**
+ * Trail's settings, through the loader every bay shares (`@opencode-cockpit/client/settings`):
+ *
+ *   ~/.config/opencode-cockpit/config.json  →  /.cockpit.json  →  plugin-entry options
+ *
+ * Only the `trail` section is read, and Trail has nothing of its own beyond the keys every bay shares:
+ * `enabled`, `sidebar` (on by default — Gate 1: a core piece, present like Shells and Subagents),
+ * `sidebarRows`, `hideWhenEmpty` and `keybinds`. Where the block sits is the top-level `sidebar`
+ * list's to say. Both halves read it: the agent half for `enabled`, the interface for the rest.
+ */
+
+import { baySettings, noticeText, type SettingsWhere } from "@opencode-cockpit/client/settings"
+
+export interface TrailSettings {
+  enabled: boolean
+  /** Draw the block in the sidebar. */
+  sidebar: boolean
+  /** Records listed before `+ N more · /trail`. */
+  sidebarRows: number
+  /** Draw no block at all while this conversation's trail is empty. */
+  hideWhenEmpty: boolean
+  keybinds: Record
+}
+
+export interface LoadedTrail {
+  settings: TrailSettings
+  /** The block's `order`, from the top-level `sidebar` list. */
+  order: number
+  /** Settings to fix, as the sentences a `!` row says. */
+  notices: string[]
+}
+
+/** Every source merged, with the client's defaults. Never throws: a bad file is a notice. */
+export function loadTrail(directory: string, options?: unknown, where: SettingsWhere = {}): LoadedTrail {
+  const { config, order, notices } = baySettings("trail", {}, { options, where: { directory, ...where } })
+  return {
+    settings: {
+      enabled: config.enabled,
+      sidebar: config.sidebar,
+      sidebarRows: Math.max(1, config.sidebarRows),
+      hideWhenEmpty: config.hideWhenEmpty,
+      keybinds: config.keybinds,
+    },
+    order,
+    notices: notices.map(noticeText),
+  }
+}
diff --git a/packages/trail/src/core/index.ts b/packages/trail/src/core/index.ts
index c41d6fed..f06fbc20 100644
--- a/packages/trail/src/core/index.ts
+++ b/packages/trail/src/core/index.ts
@@ -1,9 +1,12 @@
 /** 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 "./open.ts"
 export * from "./paths.ts"
+export * from "./scan.ts"
 export * from "./store.ts"
 export * from "./text.ts"
 export * from "./tools.ts"
diff --git a/packages/trail/src/core/open.ts b/packages/trail/src/core/open.ts
new file mode 100644
index 00000000..ed7042b6
--- /dev/null
+++ b/packages/trail/src/core/open.ts
@@ -0,0 +1,45 @@
+/**
+ * Which program opens a link in the browser, decided without running anything — the interface half
+ * spawns it (`tui/open.ts`), asynchronously and detached, so a click never stalls a frame.
+ *
+ * Neither OpenCode gives a plugin an opener of its own (docs/opencode/trail-interface.md, spike 5),
+ * so this is what OpenCode 2's bundled `open` does: `open` on macOS, `start` through `cmd` on
+ * Windows, `xdg-open` elsewhere. On macOS `/usr/bin/open` is tried before PATH, for the reason
+ * gotchas.md gives about `ps`: OpenCode can be started with a PATH that has lost the system's
+ * directories, and a click that does nothing is worse than one that names what failed.
+ *
+ * Only `http(s)` is ever handed over.
+ */
+
+import { openable } from "./links.ts"
+
+export interface Opener {
+  command: string
+  args: string[]
+}
+
+export interface OpenerWhere {
+  platform: string
+  /** Whether a file exists and can be run. */
+  exists: (path: string) => boolean
+  /** A program's full path from PATH, or nothing. */
+  which: (name: string) => string | undefined
+  /** `COCKPIT_OPENER`: a program to run instead, for a sandbox or a test (a stub that logs the link). */
+  override?: string
+}
+
+/** The command that opens `url`, or nothing: a link that is not http(s), or no opener to be found. */
+export function openerFor(url: string, where: OpenerWhere): Opener | undefined {
+  if (!openable(url)) return undefined
+  if (where.override) return { command: where.override, args: [url] }
+  if (where.platform === "darwin") {
+    const command = where.exists("/usr/bin/open") ? "/usr/bin/open" : where.which("open")
+    return command ? { command, args: [url] } : undefined
+  }
+  if (where.platform === "win32") {
+    /** `start`'s first quoted argument is a window title; an empty one keeps the link from being read as it. */
+    return { command: where.which("cmd") ?? "cmd", args: ["/c", "start", '""', url.replace(/&/g, "^&")] }
+  }
+  const command = where.which("xdg-open")
+  return command ? { command, args: [url] } : undefined
+}
diff --git a/packages/trail/src/core/scan.ts b/packages/trail/src/core/scan.ts
new file mode 100644
index 00000000..d05204c4
--- /dev/null
+++ b/packages/trail/src/core/scan.ts
@@ -0,0 +1,149 @@
+/**
+ * The safety net's eyes: PR and issue links in the output of something the agent *ran* — a shell
+ * command, an MCP call — and never in a file it read or a page it fetched, which measured as mostly
+ * noise (docs/roadmap/v0.9/trail.md, "A safety net, never automatic"). What is found is offered; it is
+ * never recorded here.
+ *
+ * Live, the agent half hears every finished call (`toolAfter`); for an older conversation — or one
+ * from before OpenCode started — the interface reads the stored history the same way. Both pass
+ * through `ran`, so they agree on which calls count.
+ */
+
+import { type Found, foundIn } from "./model.ts"
+
+/**
+ * Tools whose output is not something the agent made happen: reading and searching (files, the web,
+ * code), editing (its own words back), planning, and asking. A subagent's answer (`task`/`subagent`)
+ * repeats what the subagent printed — counted once already, from the subagent's own calls — beside
+ * links it only read about. Code Mode's `execute` carries `search(…)` results: the tool catalog.
+ * Everything else counts: `bash`/`shell`, Cockpit's shells, and MCP tools (`_`, whose
+ * names nobody can list in advance).
+ */
+const NOT_RUN = new Set([
+  "read",
+  "glob",
+  "grep",
+  "list",
+  "ls",
+  "webfetch",
+  "websearch",
+  "codesearch",
+  "edit",
+  "write",
+  "multiedit",
+  "patch",
+  "apply_patch",
+  "todoread",
+  "todowrite",
+  "task",
+  "subagent",
+  "skill",
+  "question",
+  "lsp",
+  "invalid",
+  "batch",
+  "execute",
+  "search",
+  "plan_enter",
+  "plan_exit",
+  "trail_add",
+  "trail_list",
+  "subagents_list",
+  "subagents_read",
+  "subagents_wait",
+  "review_list",
+  "review_open",
+  "review_reply",
+])
+
+/** Whether a call's output is something the agent ran: where a PR it opened would be printed. */
+export function ran(tool: string): boolean {
+  const name = tool.toLowerCase()
+  return !NOT_RUN.has(name) && !name.startsWith("lsp_")
+}
+
+/** The PRs and issues in one finished call's output, or none when the call does not count. */
+export function findsOf(call: { tool: string; output: string }, at: number): Found[] {
+  return ran(call.tool) && call.output ? foundIn(call.output, at) : []
+}
+
+/** Finds added to a list, each link once — the earliest sighting kept. */
+export function addFinds(list: Found[], more: readonly Found[]): boolean {
+  let added = false
+  for (const found of more)
+    if (!list.some((each) => each.url === found.url)) {
+      list.push(found)
+      added = true
+    }
+  return added
+}
+
+/* ─── history ────────────────────────────────────────────────────────────────────────────────── */
+
+const isObject = (value: unknown): value is Record =>
+  typeof value === "object" && value !== null
+
+const texts = (content: unknown): string =>
+  Array.isArray(content)
+    ? content
+        .flatMap((part) => (isObject(part) && typeof part.text === "string" ? [part.text] : []))
+        .join("\n")
+    : ""
+
+const number = (value: unknown): number | undefined =>
+  typeof value === "number" && Number.isFinite(value) ? value : undefined
+
+/**
+ * OpenCode 1's stored messages (`session.messages` → `[{ info, parts }]`): every tool part that
+ * completed, its output a string even for MCP. Only tool parts — a link in the person's own prompt
+ * is not something the agent ran (docs/opencode/trail-server.md).
+ */
+export function findsInV1(messages: readonly unknown[]): Found[] {
+  const out: Found[] = []
+  for (const message of messages) {
+    const parts = isObject(message) && Array.isArray(message.parts) ? message.parts : []
+    for (const part of parts) {
+      if (!isObject(part) || part.type !== "tool" || typeof part.tool !== "string") continue
+      const state = isObject(part.state) ? part.state : {}
+      if (state.status !== "completed") continue
+      const output = typeof state.output === "string" ? state.output : texts(state.content)
+      const time = isObject(state.time) ? state.time : {}
+      addFinds(out, findsOf({ tool: part.tool, output }, number(time.end) ?? number(time.start) ?? 0))
+    }
+  }
+  return out
+}
+
+/**
+ * OpenCode 2's messages (`session.message.list`, or `session.context`): an assistant's
+ * `content: [{ type: "tool", name, state: { status, content: [{ text }], metadata } }]`. A plugin or
+ * MCP call is an `execute` part there, its inner calls named only in `metadata.toolCalls` and its
+ * output merged — so an `execute` counts when one of its calls is MCP (`server.tool`, with a dot) or
+ * a shell, and none of them is `search` (the catalog, full of links).
+ */
+export function findsInV2(messages: readonly unknown[]): Found[] {
+  const out: Found[] = []
+  for (const message of messages) {
+    const content = isObject(message) && Array.isArray(message.content) ? message.content : []
+    const created = isObject(message) && isObject(message.time) ? number(message.time.created) : undefined
+    for (const part of content) {
+      if (!isObject(part) || part.type !== "tool" || typeof part.name !== "string") continue
+      const state = isObject(part.state) ? part.state : {}
+      if (state.status !== "completed") continue
+      const time = isObject(part.time) ? part.time : {}
+      const at = number(time.completed) ?? number(time.created) ?? created ?? 0
+      const output = texts(state.content)
+      if (part.name !== "execute") {
+        addFinds(out, findsOf({ tool: part.name, output }, at))
+        continue
+      }
+      const metadata = isObject(state.metadata) ? state.metadata : {}
+      const calls = (Array.isArray(metadata.toolCalls) ? metadata.toolCalls : []).flatMap((call) =>
+        isObject(call) && typeof call.tool === "string" ? [call.tool] : [],
+      )
+      const counts = calls.some((tool) => tool.includes(".") || tool.startsWith("shell"))
+      if (counts && !calls.includes("search")) addFinds(out, foundIn(output, at))
+    }
+  }
+  return out
+}
diff --git a/packages/trail/src/core/tools.ts b/packages/trail/src/core/tools.ts
index 5abec596..97f14f66 100644
--- a/packages/trail/src/core/tools.ts
+++ b/packages/trail/src/core/tools.ts
@@ -4,6 +4,7 @@
  */
 
 import { checkAdd, checkList, recordedEvent, type Who } from "./add.ts"
+import { workOf } from "./links.ts"
 import { conversationThings, type Thing } from "./model.ts"
 import { apply, type Event, matchRecord, type State } from "./store.ts"
 import { addedText, listText } from "./text.ts"
@@ -11,7 +12,7 @@ import { addedText, listText } from "./text.ts"
 export type AddOutcome =
   | { ok: false; text: string }
   /** `event` is what to append; `state` already holds it. */
-  | { ok: true; text: string; event: Event; thing: Thing }
+  | { ok: true; text: string; event: Extract; thing: Thing }
 
 /** `trail_add`: checked, folded into `state`, and answered. Nothing is written here. */
 export function runAdd(state: State, args: unknown, who: Who): AddOutcome {
@@ -41,6 +42,21 @@ export function runAdd(state: State, args: unknown, who: Who): AddOutcome {
   }
 }
 
+/**
+ * What a person typed into `/link` — ` [note]` — as `trail_add`'s arguments. A person adding a
+ * link by hand has no title to give, so the note is the title when there is one (it is what they
+ * will recognise), else a PR or issue's own name (`owner/repo#33`), else the link without its scheme.
+ * Everything else is `checkAdd`'s to judge, so a person and the agent are refused the same way.
+ */
+export function linkArgs(text: string): { title: string; url: string } | undefined {
+  const [url = "", ...rest] = text.trim().split(/\s+/)
+  if (!url) return undefined
+  const note = rest.join(" ").trim()
+  if (note) return { title: note, url }
+  const work = workOf(url)
+  return { title: work ? work.ref : url.replace(/^https?:\/\//i, "").replace(/\/+$/, ""), url }
+}
+
 /** `trail_list`: the trail as text, the same order as `/trail`. */
 export function runList(state: State, args: unknown, session: string, now: number): string {
   return listText({ state, session, now, ...checkList(args) })
diff --git a/packages/trail/src/core/view/sidebar.ts b/packages/trail/src/core/view/sidebar.ts
index 26eedd0b..344b3ccc 100644
--- a/packages/trail/src/core/view/sidebar.ts
+++ b/packages/trail/src/core/view/sidebar.ts
@@ -16,13 +16,25 @@
  * state's colour. `↗` marks a row a click opens in the browser.
  *
  * **Present when empty** (Gate 1): the heading and a muted `none yet` in the slot the first record
- * will take, so the first record replaces the line instead of pushing the blocks below down.
+ * will take, so the first record replaces the line instead of pushing the blocks below down — the
+ * client's `emptyBlock`, the same two rows every bay draws.
+ *
+ * **A settings notice always speaks**: under the heading, `!` and the words wrapped to the column
+ * (`warnRows`), even with `hideWhenEmpty` — a typo in the config is never silent.
  *
  * Narrow, the row gives up its columns in order — the action, then the system, then the ref's width —
  * before the title drops below a readable few cells; whatever is cut ends in `…`.
  */
 
-import { HEADING, HEADING_GAP, moreText } from "@opencode-cockpit/client/design"
+import {
+  EMPTY_TEXT,
+  emptyBlock,
+  HEADING,
+  HEADING_GAP,
+  moreText,
+  type ToneRun,
+  warnRows,
+} from "@opencode-cockpit/client/design"
 import { type Arranged, type Line, lastOf, linesOf, type Thing } from "../model.ts"
 import { age, cut, fit, type Row, type Run, spread, widthOf } from "./rows.ts"
 
@@ -35,6 +47,8 @@ export interface SidebarInput {
   limit: number
   /** Draw nothing at all while the trail is empty (`hideWhenEmpty`). */
   hideWhenEmpty?: boolean
+  /** Settings to fix, as sentences (`noticeText`): `!` rows under the heading, wrapped to fit. */
+  notices?: readonly string[]
 }
 
 /** What a click on a row does: open a page, open `/trail` at a record, or open `/trail`. */
@@ -49,7 +63,9 @@ export interface SidebarView {
 }
 
 export const OPEN_MARK = "↗"
-export const EMPTY_TEXT = "none yet"
+/** The client's words for an empty block, so every bay says the same. */
+export { EMPTY_TEXT }
+
 /** The fewest cells a title is given before a column is dropped for it. */
 const TITLE_MIN = 12
 const LABEL_MAX = 10
@@ -113,19 +129,27 @@ function thingRow(thing: Thing, depth: 0 | 1, columns: Columns, width: number, n
   return spread([...label, { text: thing.title, tone: "text" }], right, width)
 }
 
+/** The client's rows are tone names and text, as ours are: each made exactly the width. */
+const asRow = (runs: readonly ToneRun[], width: number): Row => fit([...runs], width)
+
 export function sidebarRows(input: SidebarInput): SidebarView {
   const { width, arranged, now } = input
-  if (width < 8 || (input.hideWhenEmpty && arranged.total === 0)) return { rows: [], hits: [] }
+  const notices = input.notices ?? []
+  if (width < 8 || (input.hideWhenEmpty && arranged.total === 0 && notices.length === 0))
+    return { rows: [], hits: [] }
+  const warned = notices.flatMap((text) => warnRows(text, width).map((runs) => asRow(runs, width)))
+  if (arranged.total === 0) {
+    /** Hidden when empty, a notice still draws, under the heading: a failure always speaks. */
+    const [heading = [], ...rest] = emptyBlock("Trail", width).map((runs) => asRow(runs, width))
+    const air = rest.slice(0, HEADING_GAP)
+    const body = input.hideWhenEmpty ? [] : rest.slice(HEADING_GAP)
+    return { rows: [heading, ...air, ...warned, ...body], hits: [] }
+  }
   const rows: Row[] = []
   const hits: SidebarHit[] = []
-  rows.push(
-    spread([{ text: "Trail", ...HEADING }], arranged.total > 0 ? [muted(String(arranged.total))] : [], width),
-  )
+  rows.push(spread([{ text: "Trail", ...HEADING }], [muted(String(arranged.total))], width))
   for (let gap = 0; gap < HEADING_GAP; gap++) rows.push(fit([], width))
-  if (arranged.total === 0) {
-    rows.push(fit([muted(EMPTY_TEXT)], width))
-    return { rows, hits }
-  }
+  rows.push(...warned)
 
   const lines = linesOf(arranged)
   const shown = lines.length > input.limit ? lines.slice(0, Math.max(1, input.limit)) : lines
diff --git a/packages/trail/src/server.ts b/packages/trail/src/server.ts
new file mode 100644
index 00000000..74a2823e
--- /dev/null
+++ b/packages/trail/src/server.ts
@@ -0,0 +1,2 @@
+/** Published entry point: `@opencode-cockpit/trail/server`. */
+export { createTrailServer, default, TRAIL_PACKAGE, type TrailServerOptions } from "./agent/plugin.ts"
diff --git a/packages/trail/src/tui/index.tsx b/packages/trail/src/tui/index.tsx
new file mode 100644
index 00000000..19976312
--- /dev/null
+++ b/packages/trail/src/tui/index.tsx
@@ -0,0 +1,632 @@
+/** @jsxImportSource @opentui/solid */
+
+/**
+ * Trail's interface half: the sidebar block — what this conversation made, one click from the page —
+ * `/trail`, and `/link` for a person to add one by hand.
+ *
+ * Everything that decides is in `core/`; this file wires it to the host: the trail file read (every
+ * window, and the agent half, append to it) and appended (`a`, `x`, `/link`), the conversation on
+ * screen found, its stored history read for links it printed and never recorded, and the two surfaces
+ * drawn from the same `arrange` as `trail_list`.
+ */
+
+import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client/feature"
+import { bindingLookup, dualTui, type Host, type Layer } from "@opencode-cockpit/client/host"
+import type { BoxRenderable } from "@opentui/core"
+import { createSignal } from "solid-js"
+import { loadTrail } from "../core/config.ts"
+import { createJournal } from "../core/journal.ts"
+import { arrange, conversationThings, type Found } from "../core/model.ts"
+import { trailPaths } from "../core/paths.ts"
+import { type Event, emptyState, type State } from "../core/store.ts"
+import { markdownOf } from "../core/text.ts"
+import { linkArgs, runAdd } from "../core/tools.ts"
+import { type DialogView, dialogRows, type Tab } from "../core/view/dialog.ts"
+import type { Row } from "../core/view/rows.ts"
+import { type SidebarView, sidebarRows } from "../core/view/sidebar.ts"
+import { openUrl } from "./open.ts"
+import { createSessions } from "./source.ts"
+import { Dialog } from "./view/dialog.tsx"
+import { Rows } from "./view/rows.tsx"
+
+const TRAIL_PACKAGE = "@opencode-cockpit/trail"
+
+/** `f`: free on both OpenCodes (1.18.32's and 2.0.18's defaults) and in Cockpit. */
+const DEFAULT_KEYS = {
+  "cockpit.trail.open": "f",
+}
+
+/** How often the route is looked at; the trail file is read every few of these. */
+const TICK_MS = 1_000
+const SYNC_EVERY = 3
+/** The host's dialog: as wide as xlarge allows (Shell's console measured it). */
+const DIALOG_COLUMNS = 116
+const DEPTH = 8
+
+/** Trail's interface half as a factory, so the `opencode-cockpit` bundle can include it. */
+export function createTrailTui({ source = TRAIL_PACKAGE }: { source?: string } = {}) {
+  return async (api: Host, rawOptions?: unknown) => {
+    const log = api.log.child("trail")
+    const directory = api.state.path.directory
+    const { settings, order, notices } = loadTrail(directory, rawOptions)
+    for (const notice of notices) log.warn("settings", { notice })
+    if (!settings.enabled) {
+      log.info("off by config", { directory })
+      return
+    }
+    const claim = claimFeature(api.renderer, "trail", source)
+    if (!claim.active) {
+      log.warn("configured twice", { owner: claim.owner, skipped: source })
+      api.ui.toast({
+        variant: "warning",
+        title: "Trail",
+        message: duplicateFeatureMessage("Trail", claim.owner, source),
+      })
+      return
+    }
+    api.lifecycle.onDispose(() => claim.release())
+
+    const keys = bindingLookup({ ...DEFAULT_KEYS, ...settings.keybinds })
+    const paths = trailPaths(directory)
+    const journal = createJournal(paths)
+    const sessions = createSessions(api, log)
+    const project = directory.split(/[\\/]/).filter(Boolean).at(-1) ?? ""
+    let state: State = emptyState()
+    /** Something you should know about: drawn as a `!` row whatever else the block says. */
+    let trouble: string | undefined
+    /** Live conversation titles from the host's list, over the ones written down when recording. */
+    const titles = new Map()
+    /** Links each conversation's stored history printed, by root session: offered, never added. */
+    const finds = new Map()
+
+    // --- the conversation on screen ----------------------------------------------------------------
+
+    const parents = new Map()
+    let root: string | undefined
+    let routed: string | undefined
+    /** The conversation — the root session — of the session on screen; a subagent's view counts as its parent's. */
+    const resolve = async (id: string): Promise => {
+      let at = id
+      for (let hop = 0; hop < DEPTH; hop++) {
+        if (!parents.has(at)) {
+          const info = await sessions.get(at)
+          if (!info) return at
+          parents.set(at, info.parentID ?? null)
+          if (info.title && !info.parentID) titles.set(info.id, info.title)
+        }
+        const parent = parents.get(at)
+        if (!parent) return at
+        at = parent
+      }
+      return at
+    }
+    const follow = () => {
+      const route = api.route.current
+      const id = route.name === "session" ? (route.params?.sessionID as string | undefined) : undefined
+      if (id === routed) return
+      routed = id
+      if (!id) {
+        root = undefined
+        return draw()
+      }
+      void resolve(id).then((found) => {
+        if (routed !== id) return
+        root = found
+        draw()
+      })
+    }
+
+    // --- painting ----------------------------------------------------------------------------------
+
+    const [sidebarLines, setLines] = createSignal([])
+    const [dialogLines, setDialogLines] = createSignal([])
+    let block: BoxRenderable | undefined
+    let drawnAt = 0
+    let said = ""
+    let sidebarView: SidebarView = { rows: [], hits: [] }
+    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))
+    }
+
+    /** `/trail`: which tab, what is under the cursor, and the search. */
+    const dialog = {
+      open: false,
+      tab: "this" as Tab,
+      selected: undefined as string | undefined,
+      query: "",
+      searching: false,
+    }
+    let shown: DialogView | undefined
+
+    /** Not remembered across restarts: remembered UI state makes a command look dead (gotchas.md). */
+    let inSidebar = settings.sidebar
+    const paint = () => {
+      drawnAt = sidebarWidth()
+      const now = Date.now()
+      const warnings = [...notices, ...(trouble ? [trouble] : [])]
+      const mine = arrange(root ? conversationThings(state, root) : [])
+      /** Hidden, the block says nothing — except a notice or trouble: a failure always speaks. */
+      sidebarView = inSidebar
+        ? sidebarRows({
+            width: drawnAt,
+            arranged: mine,
+            now,
+            limit: settings.sidebarRows,
+            hideWhenEmpty: settings.hideWhenEmpty,
+            notices: warnings,
+          })
+        : sidebarRows({
+            width: drawnAt,
+            arranged: arrange([]),
+            now,
+            limit: 0,
+            hideWhenEmpty: true,
+            notices: warnings,
+          })
+      /** Only when they changed: new rows rebuild every line of the block. */
+      const text = JSON.stringify(sidebarView.rows)
+      if (text !== said) {
+        said = text
+        setLines(sidebarView.rows)
+      }
+      if (dialog.open) {
+        const height = api.renderer.height
+        shown = dialogRows({
+          width: Math.max(40, Math.min(DIALOG_COLUMNS, api.renderer.width - 2)),
+          height: Math.max(11, height - Math.floor(height / 4) * 2),
+          tab: dialog.tab,
+          state,
+          session: root ?? "",
+          found: root ? (finds.get(root) ?? []) : [],
+          now,
+          project,
+          ...(dialog.selected !== undefined ? { selected: dialog.selected } : {}),
+          query: dialog.query,
+          searching: dialog.searching,
+        })
+        dialog.selected = shown.item?.key
+        setDialogLines(shown.rows)
+      }
+      api.renderer.requestRender()
+    }
+    /** Several changes in one turn are one paint (Shell's painter). */
+    let scheduled = false
+    const draw = () => {
+      if (scheduled) return
+      scheduled = true
+      setTimeout(() => {
+        scheduled = false
+        try {
+          paint()
+        } catch (error) {
+          log.error("paint failed", { error })
+        }
+      }, 0)
+    }
+
+    // --- the trail file ----------------------------------------------------------------------------
+
+    /** The host's live titles over the written ones: a conversation renamed since still reads right. */
+    const retitle = () => {
+      for (const [id, title] of titles) {
+        const conversation = state.conversations.get(id)
+        if (conversation) conversation.title = title
+      }
+    }
+    const sync = async () => {
+      try {
+        const before = state.seen.size
+        state = await journal.sync(state)
+        if (trouble?.startsWith("trail unreadable")) trouble = undefined
+        if (state.seen.size !== before) {
+          retitle()
+          draw()
+        }
+      } catch (error) {
+        log.error("trail unreadable", { file: paths.events, error })
+        trouble = "trail unreadable — see cockpit.log"
+        draw()
+      }
+    }
+    const write = async (events: readonly Event[]): Promise => {
+      try {
+        await journal.append(events)
+        if (trouble?.startsWith("trail not saved")) trouble = undefined
+        await sync()
+        return true
+      } catch (error) {
+        log.error("trail not saved", { file: paths.events, error })
+        trouble = `trail not saved: ${(error as NodeJS.ErrnoException).code ?? "error"}`
+        draw()
+        return false
+      }
+    }
+
+    // --- acting ------------------------------------------------------------------------------------
+
+    const toast = (message: string, variant: "info" | "success" | "warning" | "error" = "info") =>
+      api.ui.toast({ variant, title: "Trail", message })
+
+    const open = (url: string) => {
+      log.debug("open", { url })
+      openUrl(url, (why) => {
+        log.warn("open failed", { url, why })
+        toast(`Could not open the link (${why}). It is: ${url}`, "warning")
+      })
+    }
+
+    const copy = (text: string, what: string) => {
+      const ok = api.renderer.copyToClipboardOSC52?.(text) ?? false
+      if (ok) toast(`Copied ${what}.`, "success")
+      else toast(`This terminal refused the clipboard. ${what}: ${text}`, "warning")
+    }
+
+    /** Recorded by you — a find you accepted, or `/link` — through the agent's own door, `runAdd`. */
+    const addByYou = async (args: { title: string; url: string }): Promise => {
+      if (!root) {
+        toast("Open a conversation first: a link belongs to the conversation it was made in.", "warning")
+        return undefined
+      }
+      await sync()
+      const title = titles.get(root) ?? (await sessions.get(root))?.title
+      const out = runAdd(state, args, {
+        session: root,
+        rootSession: root,
+        ...(title ? { sessionTitle: title } : {}),
+        by: "you",
+        at: Date.now(),
+      })
+      if (!out.ok) {
+        toast(out.text, "warning")
+        return undefined
+      }
+      if (!(await write([out.event]))) {
+        toast("The trail could not be saved — see cockpit.log.", "error")
+        return undefined
+      }
+      log.info("added by you", { session: root, url: out.event.url })
+      toast(out.text.split("\n")[0] ?? "Recorded.", "success")
+      return out.thing.key
+    }
+
+    const link = async () => {
+      if (!root) {
+        toast("Open a conversation first: a link belongs to the conversation it was made in.", "warning")
+        return
+      }
+      const text = await api.ui.prompt({
+        title: "Add a link to this conversation's trail",
+        description: "The link, then a note if you like: https://… what it is",
+        placeholder: "https://github.com/acme/web/pull/40 the checkout fix",
+      })
+      if (text === undefined) return
+      const args = linkArgs(text)
+      if (!args) return toast("Nothing added: paste a link first, then a note if you like.", "warning")
+      await addByYou(args)
+    }
+
+    // --- /trail ------------------------------------------------------------------------------------
+
+    /** The conversation's history, read for what it printed and never recorded — offered by `a`. */
+    const scan = (at: string) =>
+      sessions
+        .finds(at)
+        .then((found) => {
+          finds.set(at, found)
+          log.debug("finds", { session: at, count: found.length })
+          draw()
+        })
+        .catch((error) => log.warn("history unreadable", { session: at, error }))
+
+    /** Live titles for All conversations: written-down titles can be a conversation's first, placeholder one. */
+    const listTitles = () =>
+      sessions
+        .list()
+        .then((list) => {
+          for (const each of list) if (each.title) titles.set(each.id, each.title)
+          retitle()
+          draw()
+        })
+        .catch((error) => log.debug("session list failed", { error }))
+
+    const move = (by: number) => {
+      const items = shown?.items ?? []
+      const at = Math.max(
+        0,
+        items.findIndex((item) => item.key === dialog.selected),
+      )
+      dialog.selected = items[Math.max(0, Math.min(items.length - 1, at + by))]?.key
+      draw()
+    }
+
+    const target = () => shown?.target ?? {}
+
+    const enter = () => {
+      const url = target().open
+      if (url) open(url)
+    }
+
+    const go = () => {
+      const to = target().go
+      if (!to) return
+      /** Close first: both versions drew the switch cleanly that way (docs/opencode/trail-interface.md). */
+      api.ui.dialog.clear()
+      log.debug("go", { session: to })
+      sessions.navigate(to)
+    }
+
+    const remove = async () => {
+      const key = target().remove
+      const record = key ? state.records.get(key) : undefined
+      if (!key || !record) return
+      const ok = await write([
+        {
+          v: 1,
+          at: Date.now(),
+          id: `rm_${key}_${Date.now().toString(36)}`,
+          type: "removed",
+          record: key,
+          rootSession: record.session,
+        },
+      ])
+      if (ok) toast(`Removed "${record.title}" from the trail.`, "success")
+    }
+
+    const accept = async () => {
+      const found = target().add
+      if (!found) return
+      const reopen = { ...dialog }
+      const title = await api.ui.prompt({
+        title: `Add ${found.label} to this conversation's trail`,
+        description: "Its own title reads best — the PR's, the issue's.",
+        value: found.ref,
+      })
+      if (title?.trim()) {
+        const key = await addByYou({ title: title.trim(), url: found.url })
+        if (key) reopen.selected = key
+      }
+      /** The prompt took the dialog's place; put it back where it was. */
+      openDialog(reopen)
+    }
+
+    const markdown = () => {
+      const arranged = shown?.arranged
+      if (!arranged || arranged.total === 0) return toast("Nothing to copy yet: the trail is empty.", "info")
+      copy(markdownOf(arranged), `${arranged.shown} ${arranged.shown === 1 ? "thing" : "things"} as markdown`)
+    }
+
+    const switchTab = () => {
+      dialog.tab = dialog.tab === "this" ? "all" : "this"
+      dialog.selected = undefined
+      if (dialog.tab === "all") void listTitles()
+      draw()
+    }
+
+    /** A click on a row takes the cursor; on the row already under it, `enter`. */
+    const click = (_x: number, y: number) => {
+      const hit = shown?.hits.find((each) => each.y === y)
+      if (!hit) return
+      if (hit.key === dialog.selected) return enter()
+      dialog.selected = hit.key
+      draw()
+    }
+
+    const run = (fn: () => unknown) => () => {
+      if (dialog.searching) return
+      void Promise.resolve(fn()).catch((error) => log.warn("action failed", { error }))
+    }
+
+    const dialogLayer = (): Layer => ({
+      priority: 100,
+      commands: [
+        { name: "cockpit.trail.down", title: "Next", run: run(() => move(1)) },
+        { name: "cockpit.trail.up", title: "Previous", run: run(() => move(-1)) },
+        { name: "cockpit.trail.tab", title: "This conversation, or all", run: run(() => switchTab()) },
+        { name: "cockpit.trail.enter", title: "Open the page", run: run(() => enter()) },
+        { name: "cockpit.trail.go", title: "Go to the conversation", run: run(() => go()) },
+        {
+          name: "cockpit.trail.copyLink",
+          title: "Copy the link",
+          run: run(() => target().copy && copy(target().copy as string, "the link")),
+        },
+        { name: "cockpit.trail.add", title: "Add what was found", run: run(() => accept()) },
+        { name: "cockpit.trail.remove", title: "Remove from the trail", run: run(() => remove()) },
+        { name: "cockpit.trail.markdown", title: "Copy as markdown", run: run(() => markdown()) },
+        {
+          name: "cockpit.trail.search",
+          title: "Search",
+          run: run(() => {
+            dialog.searching = true
+            draw()
+          }),
+        },
+        { name: "cockpit.trail.close", title: "Close", run: run(() => api.ui.dialog.clear()) },
+      ],
+      bindings: [
+        { key: "j,down", cmd: "cockpit.trail.down" },
+        { key: "k,up", cmd: "cockpit.trail.up" },
+        { key: "tab", cmd: "cockpit.trail.tab" },
+        { key: "return", cmd: "cockpit.trail.enter" },
+        { key: "g", cmd: "cockpit.trail.go" },
+        { key: "c", cmd: "cockpit.trail.copyLink" },
+        { key: "a", cmd: "cockpit.trail.add" },
+        { key: "x", cmd: "cockpit.trail.remove" },
+        { key: "m", cmd: "cockpit.trail.markdown" },
+        { key: "/", cmd: "cockpit.trail.search" },
+        { key: "q", cmd: "cockpit.trail.close" },
+      ],
+    })
+
+    /**
+     * Ahead of the keymap, because the host's dialog takes `esc` before any layer hears it (Trust's
+     * filter does the same): the search being typed gets every key, and `esc` clears a search before
+     * the host closes the dialog.
+     */
+    api.lifecycle.onDispose(
+      api.keymap.intercept(
+        (ctx) => {
+          if (!dialog.open) return
+          const event = ctx.event
+          if (dialog.searching) {
+            ctx.consume({ preventDefault: true, stopPropagation: true })
+            if (event.name === "escape") {
+              dialog.searching = false
+              dialog.query = ""
+            } else if (event.name === "return" || event.name === "enter") dialog.searching = false
+            else if (event.name === "backspace") dialog.query = dialog.query.slice(0, -1)
+            else if (event.sequence && !event.ctrl && !event.meta && event.sequence >= " ")
+              dialog.query += event.sequence
+            dialog.selected = undefined
+            return draw()
+          }
+          if (event.name !== "escape" || dialog.query === "") return
+          ctx.consume({ preventDefault: true, stopPropagation: true })
+          dialog.query = ""
+          dialog.selected = undefined
+          draw()
+        },
+        { priority: 10_000 },
+      ),
+    )
+
+    const openDialog = (at: Partial = {}) => {
+      Object.assign(dialog, {
+        open: true,
+        /** With no conversation on screen there is no "this": the project's trail. */
+        tab: root ? (at.tab ?? "this") : "all",
+        selected: at.selected,
+        query: at.query ?? "",
+        searching: false,
+      })
+      void sync()
+      if (root) void scan(root)
+      if (dialog.tab === "all") void listTitles()
+      paint()
+      api.ui.dialog.replace(
+        () => (
+           move(by)}
+            onClick={(x, y) => click(x, y)}
+          />
+        ),
+        () => {
+          dialog.open = false
+          dialog.searching = false
+        },
+      )
+      api.ui.dialog.setSize("xlarge")
+      log.debug("trail: open", { tab: dialog.tab, items: shown?.items.length ?? 0 })
+    }
+
+    // --- the sidebar -------------------------------------------------------------------------------
+
+    /** A row with a page opens it; one without opens `/trail` on it; `+ N more` opens `/trail`. */
+    const sidebarClick = (y: number) => {
+      const hit = sidebarView.hits.find((each) => each.y === y)
+      if (!hit) return
+      if (hit.kind === "open") open(hit.url)
+      else if (hit.kind === "select") openDialog({ selected: hit.key })
+      else openDialog()
+    }
+
+    api.keymap.registerLayer({
+      commands: [
+        {
+          name: "cockpit.trail.open",
+          title: "Show what this conversation made",
+          category: "Cockpit · Trail",
+          namespace: "palette",
+          slashName: "trail",
+          run: () => openDialog(),
+        },
+        {
+          name: "cockpit.trail.link",
+          title: "Add a link to this conversation's trail",
+          desc: "a PR, a ticket, a page",
+          category: "Cockpit · Trail",
+          namespace: "palette",
+          slashName: "link",
+          run: () => void link().catch((error) => log.warn("link failed", { error })),
+        },
+        {
+          name: "cockpit.trail.copyAll",
+          title: "Copy this conversation's trail as markdown",
+          category: "Cockpit · Trail",
+          namespace: "palette",
+          run: () => {
+            const arranged = arrange(root ? conversationThings(state, root) : [])
+            if (arranged.total === 0)
+              return toast(
+                root
+                  ? "Nothing to copy yet: this conversation's trail is empty."
+                  : "Open a conversation first.",
+              )
+            copy(
+              markdownOf(arranged),
+              `${arranged.total} ${arranged.total === 1 ? "thing" : "things"} as markdown`,
+            )
+          },
+        },
+        {
+          name: "cockpit.trail.sidebar",
+          title: "Show or hide Trail in the sidebar",
+          desc: "for this session",
+          category: "Cockpit · Trail",
+          namespace: "palette",
+          run: () => {
+            inSidebar = !inSidebar
+            paint()
+            /** Said as well as drawn: on the home screen there is no sidebar to show it in. */
+            toast(inSidebar ? "Shown in the sidebar." : "Hidden from the sidebar.")
+          },
+        },
+      ],
+      bindings: keys.gather("cockpit", Object.keys(DEFAULT_KEYS)),
+    })
+
+    api.slots.register({
+      /** After Shells and before Trust by default; the top-level `sidebar` list moves it. */
+      order,
+      slots: {
+        sidebar_content: () => (
+           {
+              block = box
+              draw()
+            }}
+            onRow={(y) => sidebarClick(y)}
+          />
+        ),
+      },
+    })
+
+    let ticks = 0
+    let ticking: ReturnType | undefined = setInterval(() => {
+      follow()
+      if (++ticks % SYNC_EVERY === 0) void sync()
+      /** The sidebar was laid out, or resized, since the rows were drawn. */
+      if (sidebarWidth() !== drawnAt) draw()
+    }, TICK_MS)
+    api.lifecycle.onDispose(() => {
+      clearInterval(ticking)
+      ticking = undefined
+    })
+
+    follow()
+    await sync()
+    draw()
+    log.info("ready", { trail: paths.events, sidebar: inSidebar, order })
+  }
+}
+
+/** One entry for both OpenCodes: v1 calls `tui`, v2 calls `setup` (docs/opencode/v2.md). */
+export default dualTui("opencode-cockpit.trail", createTrailTui())
diff --git a/packages/trail/src/tui/open.ts b/packages/trail/src/tui/open.ts
new file mode 100644
index 00000000..c5159f71
--- /dev/null
+++ b/packages/trail/src/tui/open.ts
@@ -0,0 +1,43 @@
+/**
+ * A link opened in the browser from the interface thread, without stalling it: `spawn`, detached,
+ * output ignored, unreferenced — measured on both OpenCodes at a few milliseconds and no frame lost
+ * (docs/opencode/trail-interface.md, spike 5). Never `spawnSync` here: a synchronous spawn on the
+ * interface thread takes the renderer down with it (gotchas.md).
+ *
+ * A missing opener arrives later, as an `error` event rather than a throw, so it has a listener;
+ * either way the person is told, with the link to open by hand.
+ */
+
+import { spawn } from "node:child_process"
+import { accessSync, constants } from "node:fs"
+import { openerFor } from "../core/open.ts"
+
+const runnable = (path: string): boolean => {
+  try {
+    accessSync(path, constants.X_OK)
+    return true
+  } catch {
+    return false
+  }
+}
+
+/** Opens `url`, and calls `failed` with why if it could not be. Returns at once. */
+export function openUrl(url: string, failed: (why: string) => void): void {
+  const opener = openerFor(url, {
+    platform: process.platform,
+    exists: runnable,
+    which: (name) => Bun.which(name, { PATH: process.env.PATH ?? "" }) ?? undefined,
+    ...(process.env.COCKPIT_OPENER ? { override: process.env.COCKPIT_OPENER } : {}),
+  })
+  if (!opener) {
+    failed(/^https?:\/\//i.test(url) ? "no program here opens links" : "only http(s) links are opened")
+    return
+  }
+  try {
+    const child = spawn(opener.command, opener.args, { detached: true, stdio: "ignore", windowsHide: true })
+    child.on("error", (error) => failed(error.message))
+    child.unref()
+  } catch (error) {
+    failed(error instanceof Error ? error.message : String(error))
+  }
+}
diff --git a/packages/trail/src/tui/render.ts b/packages/trail/src/tui/render.ts
new file mode 100644
index 00000000..69beb0f8
--- /dev/null
+++ b/packages/trail/src/tui/render.ts
@@ -0,0 +1,95 @@
+/**
+ * The only place that knows what a tone looks like: every colour from the user's OpenCode theme,
+ * through the host (so OpenCode 2's tokens arrive under the same names — client/host `themeFromV2`).
+ * Trust's (`trust/src/tui/render.ts`), copied rather than imported: bays never import each other.
+ */
+
+import type { Theme } from "@opencode-cockpit/client/host"
+import type { RGBA } from "@opentui/core"
+import { type Fill, TINTS, type Tone } from "../core/view/rows.ts"
+
+const opaque = (colour: RGBA | undefined): colour is RGBA => colour !== undefined && colour.a > 0
+
+/** What the dialog is drawn on: the panel colour, or the background when a theme leaves the panel clear. */
+const surface = (theme: Theme): RGBA | undefined =>
+  opaque(theme.backgroundPanel)
+    ? theme.backgroundPanel
+    : opaque(theme.background)
+      ? theme.background
+      : undefined
+
+export function toneColour(theme: Theme, tone: Tone | undefined): RGBA {
+  switch (tone) {
+    case "muted":
+      return theme.textMuted
+    case "accent":
+      return theme.accent
+    case "info":
+      return theme.info
+    case "tool":
+      return theme.primary
+    case "success":
+      return theme.success
+    case "error":
+      return theme.error
+    case "warning":
+      return theme.warning
+    case "border":
+      return theme.border
+    /** Text cut out of a solid accent: the surface's own colour, or the text's when there is none. */
+    case "ink":
+      return surface(theme) ?? theme.text
+    default:
+      return theme.text
+  }
+}
+
+const mixed = new WeakMap>>()
+
+/**
+ * `tone` faded into `behind`, `amount` of the way from it: a tint that follows the theme. Built from
+ * the colour's own class, as Review's soften does, so nothing of OpenTUI is imported.
+ */
+function mix(tone: RGBA, behind: RGBA, amount: number): RGBA | undefined {
+  const byBack = mixed.get(tone) ?? new WeakMap>()
+  mixed.set(tone, byBack)
+  const byAmount = byBack.get(behind) ?? new Map()
+  byBack.set(behind, byAmount)
+  const known = byAmount.get(amount)
+  if (known) return known
+  const Colour = tone.constructor as unknown as { clone?: (colour: RGBA) => RGBA }
+  if (typeof Colour.clone !== "function" || typeof behind.r !== "number") return undefined
+  const out = Colour.clone(behind)
+  out.r = behind.r + (tone.r - behind.r) * amount
+  out.g = behind.g + (tone.g - behind.g) * amount
+  out.b = behind.b + (tone.b - behind.b) * amount
+  byAmount.set(amount, out)
+  return out
+}
+
+/**
+ * What sits behind a run. The row-wide surfaces and the buttons are the theme's raised element; a
+ * focused button is the accent; a chip or a badge is its tone's tint, and — on a theme whose dialog
+ * has no colour to tint — no fill at all: the word keeps its tone and still says what it is.
+ */
+export function fillColour(theme: Theme, fill: Fill | undefined): RGBA | undefined {
+  switch (fill) {
+    case "selected":
+    case "panel":
+    case "button":
+      return theme.backgroundElement
+    case "buttonOn":
+      return theme.accent
+    case "chip":
+    case "ok":
+    case "warn":
+    case "err": {
+      const behind = surface(theme)
+      if (!behind) return undefined
+      const tint = TINTS[fill]
+      return mix(toneColour(theme, tint.tone), behind, tint.amount)
+    }
+    default:
+      return undefined
+  }
+}
diff --git a/packages/trail/src/tui/source.ts b/packages/trail/src/tui/source.ts
new file mode 100644
index 00000000..be4fd0d3
--- /dev/null
+++ b/packages/trail/src/tui/source.ts
@@ -0,0 +1,148 @@
+/**
+ * What the interface asks OpenCode about conversations, on either version: a session's parent and
+ * title, the project's sessions, a session's children, its stored history (for the safety net's
+ * finds), and the jump to one. The client host has none of these, so they are reached through the
+ * version's own API (`api.v1`, `api.v2`) — the calls docs/opencode/trail-interface.md measured.
+ *
+ * - **Lists are the project's**: v1 `list({ scope: "project" })`; v2 `list({ project })` always —
+ *   v2's bare `list({})` is every session on the machine.
+ * - **Don't use the interface's caches as a list**: both versions' synced state holds only what this
+ *   interface happened to load. Everything here goes through the client.
+ * - The v1 client answers `{ data, error }`; the v2 client answers the data and throws.
+ */
+
+import type { Host } from "@opencode-cockpit/client/host"
+import type { Log } from "@opencode-cockpit/client/log"
+import type { Found } from "../core/model.ts"
+import { addFinds, findsInV1, findsInV2 } from "../core/scan.ts"
+
+export interface SessionInfo {
+  id: string
+  parentID?: string
+  title?: string
+}
+
+export interface Sessions {
+  get(id: string): Promise
+  /** The project's conversations, children included; empty when the host would not say. */
+  list(): Promise
+  /** PR and issue links in what a conversation ran: its own calls and its subagents', from storage. */
+  finds(root: string): Promise
+  /** Switch the interface to a conversation. */
+  navigate(id: string): void
+}
+
+type Call = (input: Record) => Promise
+
+interface V1Client {
+  session: { get: Call; list: Call; children: Call; messages: Call }
+}
+interface V2Client {
+  session: { get: Call; list: Call; message?: { list: Call } }
+}
+
+const isObject = (value: unknown): value is Record =>
+  typeof value === "object" && value !== null
+
+function infoOf(value: unknown): SessionInfo | undefined {
+  if (!isObject(value) || typeof value.id !== "string") return undefined
+  return {
+    id: value.id,
+    ...(typeof value.parentID === "string" ? { parentID: value.parentID } : {}),
+    ...(typeof value.title === "string" ? { title: value.title } : {}),
+  }
+}
+
+const listOf = (value: unknown): unknown[] =>
+  Array.isArray(value) ? value : isObject(value) && Array.isArray(value.data) ? value.data : []
+
+/** How many of a conversation's subagents (and theirs) are read for finds. */
+const CHILDREN_MAX = 40
+/** Pages of a v2 conversation's history read, newest first. */
+const PAGES_MAX = 8
+const PAGE = 200
+
+export function createSessions(api: Host, log: Log): Sessions {
+  /** The conversation and its subagents, theirs included, breadth first, as each version lists them. */
+  const family = async (root: string, children: (id: string) => Promise) => {
+    const out: string[] = [root]
+    for (let at = 0; at < out.length && out.length < CHILDREN_MAX; at++)
+      for (const child of await children(out[at] as string).catch(() => []))
+        if (!out.includes(child.id)) out.push(child.id)
+    return out.slice(0, CHILDREN_MAX)
+  }
+
+  if (api.v1) {
+    const v1 = api.v1
+    const client = v1.client as unknown as V1Client
+    /** v1 answers `{ data, error }` and does not throw. */
+    const data = async (call: Promise) => {
+      const answer = await call
+      return isObject(answer) && "data" in answer ? answer.data : undefined
+    }
+    const children = async (id: string) =>
+      listOf(await data(client.session.children({ sessionID: id }))).flatMap((s) => infoOf(s) ?? [])
+    return {
+      get: async (id) => infoOf(await data(client.session.get({ sessionID: id })).catch(() => undefined)),
+      list: async () =>
+        listOf(await data(client.session.list({ scope: "project" })).catch(() => [])).flatMap(
+          (s) => infoOf(s) ?? [],
+        ),
+      finds: async (root) => {
+        const out: Found[] = []
+        for (const id of await family(root, children)) {
+          const messages = listOf(await data(client.session.messages({ sessionID: id })).catch(() => []))
+          addFinds(out, findsInV1(messages))
+        }
+        return out
+      },
+      navigate: (id) => v1.route.navigate("session", { sessionID: id }),
+    }
+  }
+
+  const ctx = api.v2
+  if (!ctx) throw new Error("trail: neither OpenCode 1 nor 2")
+  const client = ctx.client as V2Client
+  const project = (ctx.location as { project?: { id?: string } } | undefined)?.project?.id
+  const children = async (id: string) =>
+    listOf(await client.session.list({ parentID: id })).flatMap((s) => infoOf(s) ?? [])
+  /** Newest first, page by page, until the history runs out or the pages do. */
+  const history = async (id: string): Promise => {
+    const list = client.session.message?.list
+    if (!list) return []
+    const out: unknown[] = []
+    let cursor: unknown
+    for (let page = 0; page < PAGES_MAX; page++) {
+      const answer = await list({ sessionID: id, limit: PAGE, ...(cursor ? { cursor } : {}) })
+      const items = listOf(answer)
+      out.push(...items)
+      const next = isObject(answer) && isObject(answer.cursor) ? answer.cursor.next : undefined
+      if (!next || items.length < PAGE) break
+      cursor = next
+    }
+    return out
+  }
+  return {
+    get: async (id) => infoOf(await client.session.get({ sessionID: id }).catch(() => undefined)),
+    list: async () => {
+      if (!project) {
+        log.debug("no project id; not listing sessions")
+        return []
+      }
+      return listOf(await client.session.list({ project, limit: 500 }).catch(() => [])).flatMap(
+        (s) => infoOf(s) ?? [],
+      )
+    },
+    finds: async (root) => {
+      const out: Found[] = []
+      for (const id of await family(root, children))
+        addFinds(out, findsInV2(await history(id).catch(() => [])))
+      return out
+    },
+    navigate: (id) =>
+      (ctx.ui.router as unknown as { navigate(to: { type: string; sessionID: string }): void }).navigate({
+        type: "session",
+        sessionID: id,
+      }),
+  }
+}
diff --git a/packages/trail/src/tui/view/dialog.tsx b/packages/trail/src/tui/view/dialog.tsx
new file mode 100644
index 00000000..705d6398
--- /dev/null
+++ b/packages/trail/src/tui/view/dialog.tsx
@@ -0,0 +1,55 @@
+/** @jsxImportSource @opentui/solid */
+// biome-ignore-all lint/a11y/noStaticElementInteractions: these are terminal boxes, not DOM elements
+import type { Host, Layer } from "@opencode-cockpit/client/host"
+import type { BoxRenderable, MouseEvent } from "@opentui/core"
+import type { JSX } from "solid-js"
+import type { Row } from "../../core/view/rows.ts"
+import { Rows } from "./rows.tsx"
+
+export interface DialogProps {
+  api: Host
+  rows: () => readonly Row[]
+  /**
+   * The dialog's keys. Registered from inside the dialog, as Shell's console does: while the host's
+   * dialog is open it takes the keys, so a global layer would never hear them, and a layer owned by
+   * the component goes when the dialog does — on either OpenCode.
+   */
+  keys: () => Layer
+  /** The wheel moves the cursor, three rows a notch, and the list follows it as `j`/`k` do. */
+  onScroll: (by: number) => void
+  /** A click, in the rows' own cells: column and row from their top-left corner. */
+  onClick: (x: number, y: number) => void
+}
+
+/**
+ * `/trail` in the host's dialog: rows from `core/view/dialog.ts`, and the keys and clicks that act on
+ * them — Trust's dialog, the same shape. Clicks are read on release: on press, anything a click opens is up in time
+ * to catch the release and take it for its own (Review's overlay learned this).
+ */
+export function Dialog(props: DialogProps): JSX.Element {
+  props.api.keymap.useLayer(props.keys)
+  let rows: BoxRenderable | undefined
+  return (
+     {
+        const scroll = event.scroll
+        if (!scroll) return
+        event.stopPropagation()
+        props.onScroll(scroll.direction === "up" ? -3 : 3)
+      }}
+      onMouseUp={(event: MouseEvent) => {
+        if (!rows) return
+        event.stopPropagation()
+        props.onClick(event.x - rows.x, event.y - rows.y)
+      }}
+    >
+       {
+          rows = box
+        }}
+      />
+    
+  )
+}
diff --git a/packages/trail/src/tui/view/rows.tsx b/packages/trail/src/tui/view/rows.tsx
new file mode 100644
index 00000000..cbc5fec1
--- /dev/null
+++ b/packages/trail/src/tui/view/rows.tsx
@@ -0,0 +1,57 @@
+/** @jsxImportSource @opentui/solid */
+// biome-ignore-all lint/a11y/noStaticElementInteractions: these are terminal boxes, not DOM elements
+import type { Host } from "@opencode-cockpit/client/host"
+import type { BoxRenderable } from "@opentui/core"
+import { For, type JSX } from "solid-js"
+import type { Row } from "../../core/view/rows.ts"
+import { fillColour, toneColour } from "../render.ts"
+
+export interface RowsProps {
+  api: Host
+  /** The rows `core/view/` produced; a signal, so `` redraws them. */
+  rows: () => readonly Row[]
+  onReady?: (box: BoxRenderable) => void
+  /** A click on a row, by its index. Read on release: the host acts on the release that follows a press. */
+  onRow?: (y: number) => void
+}
+
+/**
+ * Rows of runs, one `` per row, each in a box of its own so a click knows its row — the drawing
+ * both the sidebar block and `/trail` use.
+ *
+ * Shell's and Subagents' pattern, proven live on both OpenCodes: a signal read by `` redraws,
+ * where anything decided once in a slot's tree never would (docs/opencode/gotchas.md, "Slots"). The
+ * shape never changes; only the list of rows does.
+ */
+export function Rows(props: RowsProps): JSX.Element {
+  const theme = () => props.api.theme.current
+  return (
+     props.onReady?.(box)}>
+      
+        {(row, index) => (
+           props.onRow?.(index())}>
+            
+              
+                {(run) => {
+                  const fill = fillColour(theme(), run.fill)
+                  const style = { fg: toneColour(theme(), run.tone), ...(fill ? { bg: fill } : {}) }
+                  return run.bold ? (
+                    
+                      {run.text}
+                    
+                  ) : run.faint ? (
+                    
+                      {run.text}
+                    
+                  ) : (
+                    {run.text}
+                  )
+                }}
+              
+            
+          
+        )}
+      
+    
+  )
+}
diff --git a/packages/trail/test/config.test.ts b/packages/trail/test/config.test.ts
new file mode 100644
index 00000000..704f7061
--- /dev/null
+++ b/packages/trail/test/config.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, test } from "bun:test"
+import { loadTrail } from "../src/core/config.ts"
+
+/** Settings files as text on a pretend disk: the loader every bay shares, Trail's section of it. */
+const at = (files: Record) => ({
+  env: { XDG_CONFIG_HOME: "/home/me/.config" },
+  read: (path: string) => files[path],
+})
+const GLOBAL = "/home/me/.config/opencode-cockpit/config.json"
+const PROJECT = "/work/app/.cockpit.json"
+
+describe("Trail's settings", () => {
+  test("nothing written: on, in the sidebar, five rows, after Shells", () => {
+    const { settings, order, notices } = loadTrail("/work/app", undefined, at({}))
+    expect(settings).toEqual({
+      enabled: true,
+      sidebar: true,
+      sidebarRows: 5,
+      hideWhenEmpty: false,
+      keybinds: {},
+    })
+    /** status, subagents, shell, trail: the fourth place. */
+    expect(order).toBe(140)
+    expect(notices).toEqual([])
+  })
+
+  test("the shared keys, from a file with comments, the project over the global one", () => {
+    const { settings } = loadTrail(
+      "/work/app",
+      undefined,
+      at({
+        [GLOBAL]: '{\n  // mine\n  "trail": { "sidebarRows": 8, "hideWhenEmpty": true, },\n}',
+        [PROJECT]: '{ "trail": { "sidebar": false, "keybinds": { "cockpit.trail.open": "j" } } }',
+      }),
+    )
+    expect(settings).toEqual({
+      enabled: true,
+      sidebar: false,
+      sidebarRows: 8,
+      hideWhenEmpty: true,
+      keybinds: { "cockpit.trail.open": "j" },
+    })
+  })
+
+  test("the top-level list moves the block; a wrong kind is a notice and the default", () => {
+    const loaded = loadTrail(
+      "/work/app",
+      undefined,
+      at({ [GLOBAL]: '{ "sidebar": ["trail", "status"], "trail": { "sidebarRows": "lots" } }' }),
+    )
+    expect(loaded.order).toBe(110)
+    expect(loaded.settings.sidebarRows).toBe(5)
+    expect(loaded.notices).toEqual(['settings: "trail.sidebarRows" should be a number; the default is used'])
+  })
+
+  test("off: `enabled: false` in plugin options, or `features.trail: false` in a file", () => {
+    expect(loadTrail("/work/app", { enabled: false }, at({})).settings.enabled).toBe(false)
+    expect(
+      loadTrail("/work/app", undefined, at({ [PROJECT]: '{ "features": { "trail": false } }' })).settings
+        .enabled,
+    ).toBe(false)
+  })
+})
diff --git a/packages/trail/test/scan.test.ts b/packages/trail/test/scan.test.ts
new file mode 100644
index 00000000..a44d7be1
--- /dev/null
+++ b/packages/trail/test/scan.test.ts
@@ -0,0 +1,175 @@
+import { describe, expect, test } from "bun:test"
+import { openerFor } from "../src/core/open.ts"
+import { findsInV1, findsInV2, findsOf, ran } from "../src/core/scan.ts"
+import { linkArgs } from "../src/core/tools.ts"
+
+/** Shapes as measured on 1.18.32 and 2.0.18 (docs/opencode/trail-server.md). */
+
+describe("which calls count: what the agent ran", () => {
+  test("shells and MCP tools; never reading, fetching, a subagent's answer or Code Mode's outer call", () => {
+    for (const tool of ["bash", "shell", "shell_read", "github_create_pull_request", "spike_open_pr"])
+      expect(ran(tool)).toBe(true)
+    for (const tool of ["read", "webfetch", "websearch", "grep", "task", "subagent", "execute", "trail_add"])
+      expect(ran(tool)).toBe(false)
+  })
+
+  test("only PR and issue links, by their own address", () => {
+    const found = findsOf(
+      {
+        tool: "bash",
+        output:
+          "https://github.com/acme/web/pull/40/files\nhttps://acme.dev/docs\nhttps://acme.atlassian.net/browse/COM-9.",
+      },
+      5,
+    )
+    expect(found.map((f) => f.url)).toEqual([
+      "https://github.com/acme/web/pull/40",
+      "https://acme.atlassian.net/browse/COM-9",
+    ])
+    expect(found[0]).toMatchObject({ ref: "acme/web#40", label: "PR #40", system: "GitHub", at: 5 })
+  })
+})
+
+describe("stored history", () => {
+  test("OpenCode 1: tool parts only — the person's own prompt is not something the agent ran", () => {
+    const messages = [
+      { info: { role: "user" }, parts: [{ type: "text", text: "run echo https://github.com/a/b/pull/1" }] },
+      {
+        info: { role: "assistant" },
+        parts: [
+          {
+            type: "tool",
+            tool: "bash",
+            state: { status: "completed", output: "https://github.com/a/b/pull/1\n", time: { end: 9 } },
+          },
+          {
+            type: "tool",
+            tool: "spike_open_pr",
+            state: { status: "completed", output: "https://github.com/a/b/pull/2" },
+          },
+          {
+            type: "tool",
+            tool: "read",
+            state: { status: "completed", output: "https://github.com/a/b/pull/3" },
+          },
+          { type: "tool", tool: "bash", state: { status: "error", output: "https://github.com/a/b/pull/4" } },
+        ],
+      },
+    ]
+    const found = findsInV1(messages)
+    expect(found.map((f) => f.url)).toEqual([
+      "https://github.com/a/b/pull/1",
+      "https://github.com/a/b/pull/2",
+    ])
+    expect(found[0]?.at).toBe(9)
+  })
+
+  test("OpenCode 2: shell parts, and an execute that called an MCP tool — not one that searched", () => {
+    const messages = [
+      {
+        type: "assistant",
+        time: { created: 1 },
+        content: [
+          {
+            type: "tool",
+            name: "shell",
+            state: {
+              status: "completed",
+              content: [{ type: "text", text: "https://github.com/a/b/pull/33\n" }],
+            },
+            time: { completed: 7 },
+          },
+          {
+            type: "tool",
+            name: "execute",
+            state: {
+              status: "completed",
+              content: [{ type: "text", text: "Created pull request: https://github.com/a/mcp/pull/77" }],
+              metadata: { toolCalls: [{ tool: "spike.open_pr", status: "completed" }] },
+            },
+          },
+          {
+            type: "tool",
+            name: "execute",
+            state: {
+              status: "completed",
+              content: [{ type: "text", text: "catalog https://github.com/a/b/pull/5" }],
+              metadata: { toolCalls: [{ tool: "search" }, { tool: "spike.open_pr" }] },
+            },
+          },
+          {
+            type: "tool",
+            name: "execute",
+            state: {
+              status: "completed",
+              content: [{ type: "text", text: "recorded https://github.com/a/b/pull/6" }],
+              metadata: { toolCalls: [{ tool: "trail_add" }] },
+            },
+          },
+        ],
+      },
+    ]
+    expect(findsInV2(messages).map((f) => f.url)).toEqual([
+      "https://github.com/a/b/pull/33",
+      "https://github.com/a/mcp/pull/77",
+    ])
+  })
+})
+
+describe("opening a link", () => {
+  const where = (platform: string, have: string[] = []) => ({
+    platform,
+    exists: (path: string) => have.includes(path),
+    which: (name: string) => (have.includes(name) ? `/somewhere/${name}` : undefined),
+  })
+
+  test("macOS: /usr/bin/open first, then PATH", () => {
+    const url = "https://github.com/a/b/pull/1"
+    expect(openerFor(url, where("darwin", ["/usr/bin/open", "open"]))).toEqual({
+      command: "/usr/bin/open",
+      args: [url],
+    })
+    expect(openerFor(url, where("darwin", ["open"]))?.command).toBe("/somewhere/open")
+    expect(openerFor(url, where("darwin"))).toBeUndefined()
+  })
+
+  test("Linux: xdg-open; Windows: start, with & kept from cmd", () => {
+    expect(openerFor("https://a.dev/x", where("linux", ["xdg-open"]))?.command).toBe("/somewhere/xdg-open")
+    expect(openerFor("https://a.dev/x", where("linux"))).toBeUndefined()
+    expect(openerFor("https://a.dev/?a=1&b=2", where("win32"))?.args).toEqual([
+      "/c",
+      "start",
+      '""',
+      "https://a.dev/?a=1^&b=2",
+    ])
+  })
+
+  test("only http(s); an override wins", () => {
+    expect(openerFor("file:///etc/passwd", where("darwin", ["/usr/bin/open"]))).toBeUndefined()
+    expect(openerFor("javascript:alert(1)", where("darwin", ["/usr/bin/open"]))).toBeUndefined()
+    expect(
+      openerFor("https://a.dev", { ...where("darwin", ["/usr/bin/open"]), override: "/tmp/stub" }),
+    ).toEqual({
+      command: "/tmp/stub",
+      args: ["https://a.dev"],
+    })
+  })
+})
+
+describe("/link  [note]", () => {
+  test("the note is the title; with none, a PR's own name, else the link", () => {
+    expect(linkArgs("https://github.com/a/b/pull/4  the checkout fix")).toEqual({
+      title: "the checkout fix",
+      url: "https://github.com/a/b/pull/4",
+    })
+    expect(linkArgs("https://github.com/a/b/pull/4")).toEqual({
+      title: "a/b#4",
+      url: "https://github.com/a/b/pull/4",
+    })
+    expect(linkArgs("https://acme.dev/runbook/")).toEqual({
+      title: "acme.dev/runbook",
+      url: "https://acme.dev/runbook/",
+    })
+    expect(linkArgs("   ")).toBeUndefined()
+  })
+})
diff --git a/packages/trail/test/server.test.ts b/packages/trail/test/server.test.ts
new file mode 100644
index 00000000..c8d61f7a
--- /dev/null
+++ b/packages/trail/test/server.test.ts
@@ -0,0 +1,188 @@
+import { afterAll, beforeEach, describe, expect, test } from "bun:test"
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+import type { ToolContext, ToolDefinition } from "@opencode-ai/plugin"
+import { silentLog } from "@opencode-cockpit/client/log"
+import type { ServerHost, ServerParts } from "@opencode-cockpit/client/server"
+import { createTrailServer, SEEN_FOR_MS } from "../src/agent/plugin.ts"
+import { GUIDANCE } from "../src/core/text.ts"
+
+/**
+ * The agent half as a model meets it: a fake OpenCode holding a conversation with a subagent, the
+ * tools called as the model calls them, and the lines each request is given. The trail file is real,
+ * in a folder of its own.
+ */
+
+const ROOT = "ses_root"
+const CHILD = "ses_child"
+const sessions: Record = {
+  [ROOT]: { title: "Fix the checkout" },
+  [CHILD]: { parentID: ROOT, title: "Open the PR (@general subagent)" },
+  ses_other: { title: "Another conversation" },
+}
+
+const home = mkdtempSync(join(tmpdir(), "trail-server-"))
+const saved = { COCKPIT_HOME: process.env.COCKPIT_HOME, XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME }
+process.env.COCKPIT_HOME = join(home, "data")
+/** No settings file but the test's own: the person running the tests keeps theirs. */
+process.env.XDG_CONFIG_HOME = join(home, "config")
+afterAll(() => {
+  for (const [key, value] of Object.entries(saved))
+    if (value === undefined) delete process.env[key]
+    else process.env[key] = value
+  rmSync(home, { recursive: true, force: true })
+})
+
+let project = ""
+beforeEach(() => {
+  project = mkdtempSync(join(home, "project-"))
+})
+
+function host(directory = project): ServerHost {
+  return {
+    version: 1,
+    directory,
+    scope: {},
+    log: silentLog,
+    readFile: async () => undefined,
+    session: {
+      get: async (id) => sessions[id],
+      notify: async () => {},
+    },
+  }
+}
+
+const context = (sessionID: string, agent = "build") => ({ sessionID, agent }) as unknown as ToolContext
+const call = async (parts: ServerParts, name: string, args: unknown, sessionID = ROOT, agent?: string) => {
+  const def = parts.tools?.[name]
+  if (!def) throw new Error(`no tool ${name}`)
+  return String(await (def as ToolDefinition).execute(args as never, context(sessionID, agent)))
+}
+const start = async (options?: unknown, at = host()) => createTrailServer({ source: "test" })(at, options)
+
+describe("the tools", () => {
+  test("trail_add records, and trail_list reads it back the same", async () => {
+    const parts = await start()
+    const added = await call(parts, "trail_add", {
+      title: "Fix the checkout",
+      url: "https://github.com/acme/web/pull/40",
+      for: "COM-1",
+    })
+    expect(added).toContain('Recorded PR #40 "Fix the checkout" (GitHub) — created.')
+    const listed = await call(parts, "trail_list", {})
+    expect(listed).toContain('PR #40 "Fix the checkout"')
+    expect(listed).toContain("https://github.com/acme/web/pull/40")
+  })
+
+  test("a refusal says what to send, and nothing is written", async () => {
+    const parts = await start()
+    expect(await call(parts, "trail_add", { title: "No link" })).toContain("Nothing was recorded.")
+    expect(await call(parts, "trail_list", {})).toContain("Nothing is in this conversation's trail yet.")
+  })
+
+  test("a subagent's record belongs to the conversation, and remembers the subagent", async () => {
+    const parts = await start()
+    await call(parts, "trail_add", { title: "Docs", url: "https://acme.dev/docs/1" }, CHILD, "general")
+    const listed = await call(parts, "trail_list", {})
+    expect(listed).toContain('"Docs"')
+    expect(listed).toContain("by subagent general")
+    const all = await call(parts, "trail_list", { all: true }, "ses_other")
+    expect(all).toContain('in "Fix the checkout" (ses_root)')
+  })
+
+  test("another window's records are read from the same file", async () => {
+    const one = await start(undefined, host())
+    const two = createTrailServer({ source: "test-2" })
+    const other = await two(host(project), undefined)
+    await call(one, "trail_add", { title: "From one", url: "https://acme.dev/one" })
+    expect(await call(other, "trail_list", {})).toContain('"From one"')
+  })
+})
+
+describe("every request", () => {
+  test("the guidance, always — subagents and unknown sessions too", async () => {
+    const parts = await start()
+    expect(await parts.system?.(undefined)).toEqual([GUIDANCE])
+    expect(await parts.system?.(CHILD)).toEqual([GUIDANCE])
+  })
+
+  test("what the conversation produced, rebuilt from the file — in the subagent's requests too", async () => {
+    const parts = await start()
+    await call(parts, "trail_add", { title: "Fix the checkout", url: "https://github.com/acme/web/pull/40" })
+    const lines = (await parts.system?.(CHILD)) ?? []
+    expect(lines[1]).toBe('Trail — this conversation produced: PR #40 "Fix the checkout" (created).')
+    /** A fresh start, as after a restart or a compaction: the same line, from the file alone. */
+    const again = await start()
+    expect((await again.system?.(ROOT))?.[1]).toBe(lines[1])
+  })
+
+  test("a PR link printed by a command is put to the agent as a choice, until recorded", async () => {
+    const parts = await start()
+    await parts.toolAfter?.({
+      sessionID: CHILD,
+      tool: "bash",
+      callID: "c1",
+      args: {},
+      output: "Creating pull request for feat into main\n\nhttps://github.com/acme/web/pull/41\n",
+    })
+    const seen = (await parts.system?.(ROOT))?.at(-1)
+    expect(seen).toBe(
+      "Seen in output — record it with tools.trail_add if you created or changed it: https://github.com/acme/web/pull/41",
+    )
+    await call(parts, "trail_add", { title: "Checkout", url: "https://github.com/acme/web/pull/41" })
+    expect((await parts.system?.(ROOT))?.join("\n")).not.toContain("Seen in output")
+  })
+
+  test("MCP output counts; a file read or a page fetched does not", async () => {
+    const parts = await start()
+    const after = (tool: string, output: string) =>
+      parts.toolAfter?.({ sessionID: ROOT, tool, callID: tool, args: {}, output })
+    await after("read", "see https://github.com/acme/web/pull/1")
+    await after("webfetch", "https://github.com/acme/web/issues/2")
+    await after("task", "the subagent said https://github.com/acme/web/pull/3")
+    await after("github_create_issue", "Created https://github.com/acme/web/issues/4")
+    const seen = (await parts.system?.(ROOT))?.at(-1) ?? ""
+    expect(seen).toContain("/issues/4")
+    for (const n of ["/pull/1", "/issues/2", "/pull/3"]) expect(seen).not.toContain(n)
+  })
+
+  test("a link seen long ago is no longer put to the agent", async () => {
+    const parts = await start()
+    const real = Date.now
+    Date.now = () => real() - SEEN_FOR_MS - 1000
+    try {
+      await parts.toolAfter?.({
+        sessionID: ROOT,
+        tool: "bash",
+        callID: "x",
+        args: {},
+        output: "https://github.com/a/b/pull/9",
+      })
+    } finally {
+      Date.now = real
+    }
+    expect((await parts.system?.(ROOT))?.join("\n")).not.toContain("Seen in output")
+  })
+})
+
+describe("settings", () => {
+  test("enabled: false in the project's file: no tools, no guidance", async () => {
+    writeFileSync(join(project, ".cockpit.json"), '{ "trail": { "enabled": false } }')
+    expect(await start()).toEqual({})
+  })
+
+  test("features.trail: false in a file does the same", async () => {
+    writeFileSync(join(project, ".cockpit.json"), '{ "features": { "trail": false } }')
+    expect(await start()).toEqual({})
+  })
+})
+
+describe("a deleted conversation", () => {
+  test("keeps its records, marked", async () => {
+    const parts = await start()
+    await call(parts, "trail_add", { title: "Kept", url: "https://acme.dev/kept" })
+    await parts.sessionDeleted?.(ROOT)
+    expect(await call(parts, "trail_list", { all: true }, "ses_other")).toContain("(ses_root, deleted)")
+  })
+})
diff --git a/packages/trail/test/view.test.ts b/packages/trail/test/view.test.ts
index fdce44a9..709e5038 100644
--- a/packages/trail/test/view.test.ts
+++ b/packages/trail/test/view.test.ts
@@ -1,4 +1,5 @@
 import { describe, expect, test } from "bun:test"
+import { emptyBlock } from "@opencode-cockpit/client/design"
 import { arrange, conversationThings } from "../src/core/model.ts"
 import { SAMPLE_NOW, SAMPLES, type Sample } from "../src/core/sample.ts"
 import { emptyState } from "../src/core/store.ts"
@@ -58,6 +59,47 @@ describe("the sidebar block", () => {
     expect(one[0]).toMatch(/^Trail +1$/)
   })
 
+  test("empty is the client's block: the same rows every bay draws", () => {
+    const ours = sidebarOf("empty", 30).rows
+    const theirs = emptyBlock("Trail", 30).map((runs) => runs.map((run) => run.text).join(""))
+    expect(ours.map(rowText)).toEqual(theirs)
+  })
+
+  test("a settings notice: `!`, wrapped to the column under the heading — even hidden when empty", () => {
+    const notice = 'settings: "trail.sidebarRows" should be a number; the default is used'
+    const { state, session } = sample("one")
+    const one = sidebarRows({
+      width: 30,
+      arranged: arrange(conversationThings(state, session)),
+      now: SAMPLE_NOW,
+      limit: 5,
+      notices: [notice],
+    })
+    const rows = texts(one.rows)
+    expect(rows[0]).toMatch(/^Trail +1$/)
+    expect(rows[2]).toMatch(/^! settings:/)
+    expect(rows.slice(2, 5).join(" ")).toContain("the default")
+    for (const row of one.rows) expect(widthOf(rowText(row))).toBe(30)
+    expect(one.rows[2]?.[0]).toMatchObject({ text: "! ", tone: "warning" })
+    /** The click on the record still lands on the record, below the notice. */
+    const hit = one.hits[0]
+    expect(hit && rows[hit.y]).toContain("PR #33")
+
+    const hidden = texts(
+      sidebarRows({
+        width: 30,
+        arranged: arrange([]),
+        now: SAMPLE_NOW,
+        limit: 5,
+        hideWhenEmpty: true,
+        notices: [notice],
+      }).rows,
+    )
+    expect(hidden[0]).toBe("Trail")
+    expect(hidden[2]).toMatch(/^! settings:/)
+    expect(hidden).not.toContain(EMPTY_TEXT)
+  })
+
   test("hideWhenEmpty draws nothing, and only while empty", () => {
     const empty = sample("empty")
     expect(
diff --git a/packages/trail/tsconfig.json b/packages/trail/tsconfig.json
index f0a5fd2e..2ab5eb48 100644
--- a/packages/trail/tsconfig.json
+++ b/packages/trail/tsconfig.json
@@ -3,6 +3,8 @@
   "compilerOptions": {
     "rootDir": "src",
     "outDir": "types",
+    "jsx": "preserve",
+    "jsxImportSource": "@opentui/solid",
     "lib": ["ESNext", "DOM"]
   },
   "include": ["src"],
diff --git a/packages/trail/tui.js b/packages/trail/tui.js
new file mode 100644
index 00000000..43232b00
--- /dev/null
+++ b/packages/trail/tui.js
@@ -0,0 +1,6 @@
+/**
+ * OpenCode 2 finds a plugin configured by *path* by the files at its root — `/tui`,
+ * `/server` — rather than through `exports` (docs/opencode/v2.md). A package installed by
+ * name resolves through `exports` as before; this file is only the door for the path case.
+ */
+export { default } from "./dist/tui/index.js"

From 74cc3c050371d1375be96a5c44ab366dc681f586 Mon Sep 17 00:00:00 2001
From: Codestz 
Date: Sat, 3 Oct 2026 10:02:03 -0500
Subject: [PATCH 21/90] Bundle, doctor, release and pack-check: Trail is a bay

Co-Authored-By: Claude Opus 5.5 
---
 .github/workflows/release.yml            |  2 +-
 packages/opencode/README.md              |  2 ++
 packages/opencode/package.json           |  3 ++-
 packages/opencode/src/features.ts        |  3 ++-
 packages/opencode/src/server.ts          |  3 +++
 packages/opencode/src/tui.ts             |  3 +++
 packages/opencode/test/bundle.test.ts    |  6 +++++
 packages/opencode/test/duplicate.test.ts |  3 ++-
 packages/opencode/tsconfig.json          |  3 +++
 packages/updater/src/doctor/checks.ts    |  4 ++--
 scripts/pack-check.ts                    | 30 ++++++++++++++++++++++++
 11 files changed, 56 insertions(+), 6 deletions(-)

diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 22848a0a..47212ae2 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -90,7 +90,7 @@ jobs:
           # Dependency order; already-published versions are skipped so reruns are safe.
           # Dependency order matters: the bundle depends on every bay, so a bay missing from
           # this list publishes a bundle whose dependency does not exist (the 0.1.0 incident).
-          for dir in protocol daemon client shell status review updater subagents trust opencode; do
+          for dir in protocol daemon client shell status review updater subagents trail trust opencode; do
             name=$(jq -r .name "packages/$dir/package.json")
             version=$(jq -r .version "packages/$dir/package.json")
             if npm view "$name@$version" version >/dev/null 2>&1; then
diff --git a/packages/opencode/README.md b/packages/opencode/README.md
index 7804c7c9..b63544bb 100644
--- a/packages/opencode/README.md
+++ b/packages/opencode/README.md
@@ -29,6 +29,7 @@ ships.*
 | **Statusline** | — (for you: what the session is costing, under the prompt or in the sidebar) | [`@opencode-cockpit/status`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/status) |
 | **Updater** | — (for you: every plugin, what it really runs, and the update) | [`@opencode-cockpit/updater`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/updater) |
 | **Subagents** | Every subagent's run in the sidebar and a pane; follow-ups continue the subagent that did the work; message, stop or background one | [`@opencode-cockpit/subagents`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/subagents) |
+| **Trail** | `trail_add` / `trail_list`: it records the PRs, tickets and pages it creates or changes, and can say which conversation made one (for you: the sidebar, one click from the page, and `/trail`) | [`@opencode-cockpit/trail`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/trail) |
 | **Trust** | — (for you: approve the exact same command three times and it is answered for you; `/trust` shows what it learned and answered) | [`@opencode-cockpit/trust`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/trust) |
 
 ## Shell, by example
@@ -219,6 +220,7 @@ Each shell's output feeds three views at once: a normalized **log** for the agen
 | [`@opencode-cockpit/review`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/review) | Review feature |
 | [`@opencode-cockpit/updater`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/updater) | Updater feature |
 | [`@opencode-cockpit/subagents`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/subagents) | Subagents feature |
+| [`@opencode-cockpit/trail`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/trail) | Trail feature |
 | [`@opencode-cockpit/trust`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/trust) | Trust feature |
 | [`@opencode-cockpit/daemon`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/daemon) | `cockpitd`, the shared process host |
 | [`@opencode-cockpit/client`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/client) | Typed, auto-spawning client and plugin helpers |
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index 9766507b..146b6d69 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -1,7 +1,7 @@
 {
   "name": "opencode-cockpit",
   "version": "0.8.0",
-  "description": "OpenCode superpowers, all in one: installs every opencode-cockpit feature (Shell, Statusline, Review, Updater, Subagents, Trust), each can be switched off",
+  "description": "OpenCode superpowers, all in one: installs every opencode-cockpit feature (Shell, Statusline, Review, Updater, Subagents, Trail, Trust), each can be switched off",
   "type": "module",
   "license": "MIT",
   "author": "Codestz",
@@ -58,6 +58,7 @@
     "@opencode-cockpit/shell": "workspace:*",
     "@opencode-cockpit/status": "workspace:*",
     "@opencode-cockpit/subagents": "workspace:*",
+    "@opencode-cockpit/trail": "workspace:*",
     "@opencode-cockpit/trust": "workspace:*",
     "@opencode-cockpit/updater": "workspace:*"
   },
diff --git a/packages/opencode/src/features.ts b/packages/opencode/src/features.ts
index d9df69f4..77cbe761 100644
--- a/packages/opencode/src/features.ts
+++ b/packages/opencode/src/features.ts
@@ -1,5 +1,5 @@
 /** Every feature the bundle can load. Adding one: list it here and wire it in server.ts / tui.ts. */
-export const FEATURES = ["shell", "status", "review", "updater", "subagents", "trust"] as const
+export const FEATURES = ["shell", "status", "review", "updater", "subagents", "trail", "trust"] as const
 export type Feature = (typeof FEATURES)[number]
 
 export interface CockpitOptions {
@@ -11,6 +11,7 @@ export interface CockpitOptions {
   review?: Record
   updater?: Record
   subagents?: Record
+  trail?: Record
   trust?: Record
   [key: string]: unknown
 }
diff --git a/packages/opencode/src/server.ts b/packages/opencode/src/server.ts
index 6010479f..f359fe04 100644
--- a/packages/opencode/src/server.ts
+++ b/packages/opencode/src/server.ts
@@ -2,11 +2,13 @@ import { composeParts, dualServer } from "@opencode-cockpit/client/server"
 import { createReviewServer } from "@opencode-cockpit/review/server"
 import { createShellServer } from "@opencode-cockpit/shell/server"
 import { createSubagentsServer } from "@opencode-cockpit/subagents/server"
+import { createTrailServer } from "@opencode-cockpit/trail/server"
 import { BUNDLE, type CockpitOptions, featureOptions, isEnabled } from "./features.ts"
 
 const shell = createShellServer({ source: BUNDLE })
 const review = createReviewServer({ source: BUNDLE })
 const subagents = createSubagentsServer({ source: BUNDLE })
+const trail = createTrailServer({ source: BUNDLE })
 
 export default dualServer(BUNDLE, async (host, rawOptions) => {
   const options = rawOptions as CockpitOptions | undefined
@@ -14,5 +16,6 @@ export default dualServer(BUNDLE, async (host, rawOptions) => {
   if (isEnabled(options, "shell")) parts.push(await shell(host, featureOptions(options, "shell")))
   if (isEnabled(options, "review")) parts.push(await review(host, featureOptions(options, "review")))
   if (isEnabled(options, "subagents")) parts.push(await subagents(host, featureOptions(options, "subagents")))
+  if (isEnabled(options, "trail")) parts.push(await trail(host, featureOptions(options, "trail")))
   return composeParts(parts)
 })
diff --git a/packages/opencode/src/tui.ts b/packages/opencode/src/tui.ts
index 8ac40675..438dd881 100644
--- a/packages/opencode/src/tui.ts
+++ b/packages/opencode/src/tui.ts
@@ -4,6 +4,7 @@ import { createReviewTui } from "@opencode-cockpit/review/tui"
 import { createShellTui } from "@opencode-cockpit/shell/tui"
 import { createStatusTui } from "@opencode-cockpit/status/tui"
 import { createSubagentsTui } from "@opencode-cockpit/subagents/tui"
+import { createTrailTui } from "@opencode-cockpit/trail/tui"
 import { createTrustTui } from "@opencode-cockpit/trust/tui"
 import { createUpdaterTui } from "@opencode-cockpit/updater/tui"
 import { BUNDLE, type CockpitOptions, featureOptions, isEnabled } from "./features.ts"
@@ -13,6 +14,7 @@ const status = createStatusTui({ source: BUNDLE })
 const review = createReviewTui({ source: BUNDLE })
 const updater = createUpdaterTui({ source: BUNDLE })
 const subagents = createSubagentsTui({ source: BUNDLE })
+const trail = createTrailTui({ source: BUNDLE })
 const trust = createTrustTui({ source: BUNDLE })
 
 /** One entry for both OpenCodes (docs/opencode/v2.md): each bay starts on the same Host. */
@@ -25,6 +27,7 @@ export default dualTui(BUNDLE, async (outer, rawOptions) => {
   if (isEnabled(options, "review")) await review(host, featureOptions(options, "review"))
   if (isEnabled(options, "updater")) await updater(host, featureOptions(options, "updater"))
   if (isEnabled(options, "subagents")) await subagents(host, featureOptions(options, "subagents"))
+  if (isEnabled(options, "trail")) await trail(host, featureOptions(options, "trail"))
   if (isEnabled(options, "trust")) await trust(host, featureOptions(options, "trust"))
   flush()
 })
diff --git a/packages/opencode/test/bundle.test.ts b/packages/opencode/test/bundle.test.ts
index ee4a0e03..7151e9ad 100644
--- a/packages/opencode/test/bundle.test.ts
+++ b/packages/opencode/test/bundle.test.ts
@@ -14,6 +14,12 @@ describe("feature options", () => {
     expect(featureOptions({ trust: { threshold: 5 } }, "trust")).toEqual({ threshold: 5 })
   })
 
+  test("Trail is in the bundle, on by default, and switched off like the rest", () => {
+    expect(isEnabled(undefined, "trail")).toBe(true)
+    expect(isEnabled({ features: { trail: false } }, "trail")).toBe(false)
+    expect(featureOptions({ trail: { sidebarRows: 3 } }, "trail")).toEqual({ sidebarRows: 3 })
+  })
+
   test("shell options come from their own key, with 0.1.x top-level options as fallback", () => {
     expect(featureOptions({ dockHeight: 10, shell: { dockHeight: 20, dockOpen: true } }, "shell")).toEqual({
       dockHeight: 20,
diff --git a/packages/opencode/test/duplicate.test.ts b/packages/opencode/test/duplicate.test.ts
index 1c2c7df6..3c793a1c 100644
--- a/packages/opencode/test/duplicate.test.ts
+++ b/packages/opencode/test/duplicate.test.ts
@@ -53,7 +53,7 @@ describe("configured twice in one OpenCode instance", () => {
 
   test("features switched off load nothing; separate instances each get Shell", async () => {
     const off = await bundle.server(fakeInput(), {
-      features: { shell: false, review: false, subagents: false },
+      features: { shell: false, review: false, subagents: false, trail: false },
     })
     expect(off.tool).toBeUndefined()
     /** One bay off leaves the others alone, which is the whole point of the switches. */
@@ -61,6 +61,7 @@ describe("configured twice in one OpenCode instance", () => {
     expect(Object.keys(shellOff.tool ?? {}).filter((name) => name.startsWith("shell_"))).toEqual([])
     expect(Object.keys(shellOff.tool ?? {})).toContain("review_list")
     expect(Object.keys(shellOff.tool ?? {})).toContain("subagents_list")
+    expect(Object.keys(shellOff.tool ?? {})).toContain("trail_add")
     const a = await bundle.server(fakeInput())
     const b = await bundle.server(fakeInput())
     expect(a.tool?.shell_start).toBeDefined()
diff --git a/packages/opencode/tsconfig.json b/packages/opencode/tsconfig.json
index bfddfbaf..393d555f 100644
--- a/packages/opencode/tsconfig.json
+++ b/packages/opencode/tsconfig.json
@@ -24,6 +24,9 @@
     {
       "path": "../subagents"
     },
+    {
+      "path": "../trail"
+    },
     {
       "path": "../trust"
     }
diff --git a/packages/updater/src/doctor/checks.ts b/packages/updater/src/doctor/checks.ts
index d9f35cd1..2adfe736 100644
--- a/packages/updater/src/doctor/checks.ts
+++ b/packages/updater/src/doctor/checks.ts
@@ -23,10 +23,10 @@ export interface Check {
 
 /** The packages Cockpit ships, and which halves each has. */
 export const BUNDLE = "opencode-cockpit"
-export const BAYS = ["shell", "review", "status", "updater", "subagents", "trust"] as const
+export const BAYS = ["shell", "review", "status", "updater", "subagents", "trail", "trust"] as const
 export type Bay = (typeof BAYS)[number]
 /** Bays with an agent half — the rest are interface only. */
-const SERVER_BAYS: readonly string[] = ["shell", "review", "subagents"]
+const SERVER_BAYS: readonly string[] = ["shell", "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/scripts/pack-check.ts b/scripts/pack-check.ts
index d614b6f4..47a2ba83 100644
--- a/scripts/pack-check.ts
+++ b/scripts/pack-check.ts
@@ -276,6 +276,36 @@ try {
       }
     }
 
+    /**
+     * Trail alone: both halves, from what was installed. Its agent half is the one that matters most
+     * — `trail_add` is how anything gets into the trail — and no other bay's check imports it; its
+     * preview bin is named twice, with a shebang, like Status's.
+     */
+    if (bays.includes("@opencode-cockpit/trail")) {
+      const { default: server } = await import(Bun.resolveSync("@opencode-cockpit/trail/server", dir))
+      if (
+        server?.id !== "opencode-cockpit.trail" ||
+        typeof server.server !== "function" ||
+        typeof server.setup !== "function"
+      )
+        throw new Error(
+          `${install.name}: @opencode-cockpit/trail/server must export the plugin for both OpenCodes`,
+        )
+      if (install.packages.includes("@opencode-cockpit/trail")) {
+        const root = join(dir, "node_modules", "@opencode-cockpit", "trail")
+        const bins = (await Bun.file(join(root, "package.json")).json()).bin as Record
+        if (!bins.trail) throw new Error(`${install.name}: trail must declare a "trail" bin, for bunx`)
+        for (const [name, file] of Object.entries(bins)) {
+          const first = (await Bun.file(join(root, file)).text()).split("\n", 1)[0] ?? ""
+          if (!first.startsWith("#!")) throw new Error(`${install.name}: the ${name} bin has no shebang`)
+        }
+        for (const door of ["server.js", "tui.js"])
+          if (!existsSync(join(root, door)))
+            throw new Error(`${install.name}: trail ships no ${door} for OpenCode 2`)
+      }
+      console.log(`  ${install.name}: trail's agent half loads, its bin and OpenCode 2's doors are there`)
+    }
+
     // The statusline bay has no server half and no daemon: there is nothing further to run.
     if (!bays.includes("@opencode-cockpit/shell")) {
       console.log(`  ${install.name}: loads, compiled interface entry, authoring subpaths resolve`)

From 9714d82fada75d2c519873d5293953650cf7a4fd Mon Sep 17 00:00:00 2001
From: Codestz 
Date: Sat, 3 Oct 2026 10:15:04 -0500
Subject: [PATCH 22/90] =?UTF-8?q?Trail:=20the=20measurement=20=E2=80=94=20?=
 =?UTF-8?q?a=20fake=20gh=20pr=20create,=20and=20a=20real=20turn=20that=20m?=
 =?UTF-8?q?ust=20end=20in=20trail=5Fadd?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Co-Authored-By: Claude Opus 5.5 
---
 packages/trail/measure/agent.ts | 200 ++++++++++++++++++++++++++++++++
 packages/trail/measure/bin/gh   |  36 ++++++
 2 files changed, 236 insertions(+)
 create mode 100644 packages/trail/measure/agent.ts
 create mode 100755 packages/trail/measure/bin/gh

diff --git a/packages/trail/measure/agent.ts b/packages/trail/measure/agent.ts
new file mode 100644
index 00000000..0d0fc360
--- /dev/null
+++ b/packages/trail/measure/agent.ts
@@ -0,0 +1,200 @@
+#!/usr/bin/env bun
+/**
+ * Trail's fourth layer, the measurement (docs/roadmap/v0.9/trail.md, "Making sure the agent
+ * records"): a real agent turn opens a pull request with a fake `gh` that prints its link, and the
+ * turn must end with the agent having called `trail_add` for it — without the prompt saying a word
+ * about the trail. Only the guidance, the tool's description and the "seen in output" line can make
+ * it happen, so a wording change that stops it working cannot ship silently.
+ *
+ *   bun packages/trail/measure/agent.ts                         OpenCode on PATH, this checkout
+ *   OPENCODE=~/.opencode/bin/opencodeold bun packages/trail/measure/agent.ts
+ *   bun packages/trail/measure/agent.ts --plugin  --runs 3 --keep
+ *
+ * `--plugin` is the server half to load: a package directory (an install's
+ * `node_modules/@opencode-cockpit/trail`, or the bundle's) — by default this checkout's
+ * `packages/trail`, built (`bun run build`). Everything runs in a folder of its own: OpenCode's
+ * config, state, data and cache, Cockpit's home, the project (a git repository on a branch). Needs
+ * the network and a free OpenCode Zen model, so it stays out of CI — like `AGENT=1 smoke:tui`.
+ *
+ * Exits 0 when every run recorded the PR, 1 otherwise, and prints what each run did.
+ */
+
+import { mkdtempSync, realpathSync, rmSync } from "node:fs"
+import { join, resolve } from "node:path"
+import { trailPaths } from "../src/core/paths.ts"
+import { parseLines } from "../src/core/store.ts"
+
+const args = process.argv.slice(2)
+const value = (flag: string) => {
+  const at = args.indexOf(flag)
+  return at >= 0 ? args[at + 1] : undefined
+}
+const opencode = process.env.OPENCODE ?? Bun.which("opencode")
+if (!opencode) {
+  console.error("opencode binary not found: set OPENCODE")
+  process.exit(2)
+}
+const plugin = resolve(value("--plugin") ?? join(import.meta.dir, ".."))
+const runs = Number(value("--runs")) || 1
+const model = value("--model") ?? "opencode/space-bunny-free"
+const keep = args.includes("--keep")
+const PR = "https://github.com/acme/web/pull/417"
+/**
+ * Nothing about the trail: the agent has to get there on its own. Direct about the PR, because what
+ * is measured is what happens *after* a link is printed — a careful model that checks the branch
+ * first, or asks before opening anything, measures its caution, not Trail.
+ */
+export const PROMPT =
+  "Open the pull request for this branch now: run `gh pr create --fill` straight away (the branch is pushed and gh is logged in; nothing needs checking first), then reply with the PR's link."
+
+const version = Bun.spawnSync([opencode, "--version"]).stdout.toString().trim()
+const v2 = version.replace(/^opencode\s+v?/, "").startsWith("2")
+
+const run = (cmd: string[], cwd: string) => {
+  const result = Bun.spawnSync(cmd, { cwd, stdout: "pipe", stderr: "pipe" })
+  if (result.exitCode !== 0) throw new Error(`$ ${cmd.join(" ")}\n${result.stderr}`)
+}
+
+interface Outcome {
+  ok: boolean
+  ghRan: boolean
+  /** `trail_add` calls that completed, with what was sent. */
+  calls: unknown[]
+  recorded: boolean
+  said: string
+}
+
+/** Every tool call of a `--format json` run, Code Mode's inner calls included (v2 lists them on `execute`). */
+function toolCalls(stdout: string): { tool: string; status?: string; input?: unknown }[] {
+  const out: { tool: string; status?: string; input?: unknown }[] = []
+  for (const line of stdout.split("\n")) {
+    if (!line.startsWith("{")) continue
+    const event = JSON.parse(line) as { type?: string; part?: Record }
+    if (event.type !== "tool_use" || !event.part) continue
+    const part = event.part as {
+      tool: string
+      state: {
+        status?: string
+        input?: unknown
+        metadata?: { toolCalls?: unknown[]; metadata?: { toolCalls?: unknown[] } }
+      }
+    }
+    out.push({ tool: part.tool, status: part.state.status, input: part.state.input })
+    const inner = part.state.metadata?.toolCalls ?? part.state.metadata?.metadata?.toolCalls ?? []
+    for (const call of inner as { tool: string; status?: string; input?: unknown }[]) out.push(call)
+  }
+  return out
+}
+
+async function once(index: number): Promise {
+  const work = mkdtempSync(`/tmp/acme-web-${index}-`)
+  try {
+    const project = join(work, "project")
+    const log = join(work, "gh.log")
+    /**
+     * A branch with one commit, pushed — as far as git can tell without a network: the remote-tracking
+     * refs are written by hand. A careful model checks before it opens a PR, and stops at an unpushed
+     * branch or a missing remote.
+     */
+    await Bun.write(join(project, "README.md"), "# web\n")
+    const branch = "feat/checkout-retry"
+    for (const cmd of [
+      ["git", "init", "-q", "-b", "main"],
+      ["git", "config", "user.email", "measure@example.com"],
+      ["git", "config", "user.name", "Measure"],
+      ["git", "add", "-A"],
+      ["git", "commit", "-qm", "init"],
+      ["git", "remote", "add", "origin", "https://github.com/acme/web.git"],
+      ["git", "update-ref", "refs/remotes/origin/main", "HEAD"],
+      ["git", "checkout", "-qb", branch],
+    ])
+      run(cmd, project)
+    await Bun.write(join(project, "checkout.ts"), "export const retries = 3\n")
+    for (const cmd of [
+      ["git", "add", "-A"],
+      ["git", "commit", "-qm", "Retry the checkout request after a dropped connection"],
+      ["git", "update-ref", `refs/remotes/origin/${branch}`, "HEAD"],
+      ["git", "config", `branch.${branch}.remote`, "origin"],
+      ["git", "config", `branch.${branch}.merge`, `refs/heads/${branch}`],
+    ])
+      run(cmd, project)
+    /**
+     * The fake `gh`, copied without its comments: a model that looks at what `gh` is (some do,
+     * `which gh` and `cat`) and finds a script calling itself fake stops and says so.
+     */
+    const bin = join(work, "bin")
+    const shim = await Bun.file(join(import.meta.dir, "bin", "gh")).text()
+    await Bun.write(
+      join(bin, "gh"),
+      shim
+        .split("\n")
+        .filter((line, at) => at === 0 || !line.startsWith("#"))
+        .join("\n"),
+    )
+    run(["chmod", "+x", join(bin, "gh")], work)
+    await Bun.write(
+      join(work, "config", "opencode", "opencode.json"),
+      JSON.stringify({ [v2 ? "plugins" : "plugin"]: [plugin], model }),
+    )
+    const env = {
+      ...process.env,
+      XDG_CONFIG_HOME: join(work, "config"),
+      XDG_STATE_HOME: join(work, "state"),
+      XDG_DATA_HOME: join(work, "data"),
+      XDG_CACHE_HOME: join(work, "cache"),
+      COCKPIT_HOME: join(work, "cockpit"),
+      TRAIL_MEASURE_LOG: log,
+      TRAIL_MEASURE_PR: PR,
+      PATH: `${bin}:${process.env.PATH ?? ""}`,
+      /** v2 places the session in $PWD's directory, not the spawn's cwd (trail-interface.md). */
+      PWD: project,
+    }
+    const cmd = [opencode as string, "run", ...(v2 ? ["--standalone", "--auto"] : []), "-m", model]
+    /** stdin must not be an open pipe, or `opencode run` waits forever (trail-server.md). */
+    const result = Bun.spawnSync([...cmd, "--format", "json", PROMPT], {
+      cwd: project,
+      env,
+      stdin: "ignore",
+      stdout: "pipe",
+      stderr: "pipe",
+      timeout: 300_000,
+    })
+    const stdout = result.stdout.toString()
+    const calls = toolCalls(stdout).filter((call) => call.tool === "trail_add" && call.status === "completed")
+    const said = stdout
+      .split("\n")
+      .filter((line) => line.startsWith('{"type":"text"'))
+      .map((line) => (JSON.parse(line) as { part: { text: string } }).part.text)
+      .join("\n")
+    /** OpenCode names the project by its real path: on macOS /tmp is /private/tmp. */
+    const file = Bun.file(trailPaths(realpathSync(project), { COCKPIT_HOME: join(work, "cockpit") }).events)
+    const events = (await file.exists()) ? parseLines(await file.text()).events : []
+    const recorded = events.some((event) => event.type === "recorded" && event.url === PR)
+    const ghRan = (await Bun.file(log).exists()) && (await Bun.file(log).text()).includes("pr create")
+    if (process.env.MEASURE_DEBUG) console.log(stdout.slice(-4000), result.stderr.toString().slice(-2000))
+    return {
+      ok: ghRan && calls.length > 0 && recorded,
+      ghRan,
+      calls: calls.map((c) => c.input),
+      recorded,
+      said,
+    }
+  } finally {
+    if (!keep) rmSync(work, { recursive: true, force: true })
+    else console.log(`kept ${work}`)
+  }
+}
+
+console.log(`Trail measurement: ${version} (${opencode}), plugin ${plugin}, ${model}, ${runs} run(s)`)
+let passed = 0
+for (let i = 1; i <= runs; i++) {
+  const 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 ${runs} recorded the PR`)
+process.exit(passed === runs ? 0 : 1)
diff --git a/packages/trail/measure/bin/gh b/packages/trail/measure/bin/gh
new file mode 100755
index 00000000..dd3b9da2
--- /dev/null
+++ b/packages/trail/measure/bin/gh
@@ -0,0 +1,36 @@
+#!/bin/sh
+# A fake `gh` for Trail's measurement (measure/agent.ts): `gh pr create` prints a pull request's
+# link the way the real one does, and nothing is sent anywhere. It answers the few things a careful
+# agent asks first (auth, version, an existing PR) as the real one would — a model that meets an
+# obvious stub stops and says so. Every call is logged, so the measurement can tell it really ran.
+LOG="${TRAIL_MEASURE_LOG:-/dev/null}"
+PR="${TRAIL_MEASURE_PR:-https://github.com/acme/web/pull/417}"
+printf '%s\n' "gh $*" >> "$LOG"
+BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo main)
+case "$1 $2" in
+  "pr create")
+    : > "$LOG.created"
+    printf '\nCreating pull request for %s into main in acme/web\n\n' "$BRANCH"
+    printf '%s\n' "$PR"
+    ;;
+  "pr view" | "pr status")
+    if [ -f "$LOG.created" ]; then
+      printf '{"number":%s,"title":"%s","url":"%s","state":"OPEN"}\n' "${PR##*/}" "$(git log -1 --format=%s)" "$PR"
+    else
+      printf 'no pull requests found for branch "%s"\n' "$BRANCH" >&2
+      exit 1
+    fi
+    ;;
+  "pr list") [ -f "$LOG.created" ] && printf '%s\t%s\t%s\tOPEN\n' "${PR##*/}" "$(git log -1 --format=%s)" "$BRANCH" ;;
+  "auth status")
+    printf 'github.com\n  Logged in to github.com account acme-dev (keyring)\n  - Active account: true\n  - Git operations protocol: https\n'
+    ;;
+  "repo view") printf 'name:\tacme/web\ndescription:\tThe web app\n' ;;
+  "--version "*) printf 'gh version 2.62.0 (2024-11-14)\nhttps://github.com/cli/cli/releases/tag/v2.62.0\n' ;;
+  *)
+    case "$*" in
+      *--help* | help*) printf 'Work with GitHub pull requests.\n\nUSAGE\n  gh pr  [flags]\n\nGENERAL COMMANDS\n  create:     Create a pull request\n  list:       List pull requests in a repository\n  view:       View a pull request\n' ;;
+      *) printf 'unknown command "%s" for "gh"\n' "$1" >&2; exit 1 ;;
+    esac
+    ;;
+esac

From 529d9dd3d7ac5e79c28bb94e06d75c8fb0cf7e05 Mon Sep 17 00:00:00 2001
From: Codestz 
Date: Sat, 3 Oct 2026 10:15:34 -0500
Subject: [PATCH 23/90] Doctor: Trail's halves, and modules under old names are
 only notices

Co-Authored-By: Claude Opus 5.5 
---
 packages/updater/test/doctor.test.ts | 36 ++++++++++++++++++++++++++++
 1 file changed, 36 insertions(+)

diff --git a/packages/updater/test/doctor.test.ts b/packages/updater/test/doctor.test.ts
index 7393b3dd..392794ba 100644
--- a/packages/updater/test/doctor.test.ts
+++ b/packages/updater/test/doctor.test.ts
@@ -146,6 +146,27 @@ describe("the config", () => {
     expect(found.Config?.fix?.join()).toContain("also inside opencode-cockpit")
   })
 
+  test("Trail is a bay with both halves: inside the bundle, and missing its tools on OpenCode 1", async () => {
+    const twice = await checks({
+      opencode: "2.0.18",
+      latest: { "opencode-cockpit": "0.9.0", "@opencode-cockpit/trail": "0.9.0" },
+      files: {
+        [`${CONFIG}/opencode.json`]: json({
+          plugins: ["opencode-cockpit@0.9.0", "@opencode-cockpit/trail@0.9.0"],
+        }),
+      },
+    })
+    expect(twice.Config?.fix?.join()).toContain("@opencode-cockpit/trail@0.9.0 (")
+    const half = await checks({
+      opencode: "1.18.32",
+      latest: { "@opencode-cockpit/trail": "0.9.0" },
+      files: { [`${CONFIG}/tui.json`]: json({ plugin: ["@opencode-cockpit/trail@0.9.0"] }) },
+    })
+    expect(half.Config?.fix?.join()).toContain(
+      "@opencode-cockpit/trail is in tui.json but not opencode.json: the agent has none of its tools",
+    )
+  })
+
   /** v1 never reads cli.json: panels configured only there do not show. */
   test("on OpenCode 1, the interface configured only in cli.json is a missing half", async () => {
     const found = await checks({
@@ -335,6 +356,21 @@ describe("the rest", () => {
     expect(found.Settings?.state).toBe("warn")
     expect(found.Settings?.fix?.join()).toContain("gone.ts")
   })
+
+  /** Status reads modules from `status.modules` only; doctor agrees, and names the old places as such. */
+  test("modules under the old names are not checked: each is only the loader's notice", async () => {
+    const file = `${HOME}/.config/opencode-cockpit/config.json`
+    const found = await checks({
+      opencode: "2.0.15",
+      files: {
+        [file]: json({ statusline: { modules: ["~/old.ts"] }, modules: ["~/root.ts"] }),
+      },
+    })
+    const fix = found.Settings?.fix?.join("\n") ?? ""
+    expect(fix).not.toContain("does not exist")
+    expect(fix).toContain(`${file}: "statusline" is no longer read — run /cockpit-setup`)
+    expect(fix).toContain(`${file}: "modules" at the top level is not read: it belongs in "status"`)
+  })
 })
 
 /**

From f07909f47b856a9eb027669b2043ede8408ac511 Mon Sep 17 00:00:00 2001
From: Codestz 
Date: Sat, 3 Oct 2026 10:24:11 -0500
Subject: [PATCH 24/90] Trail: a link you add shows in the sidebar at once

Co-Authored-By: Claude Opus 5.5 
---
 packages/trail/src/tui/index.tsx | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/packages/trail/src/tui/index.tsx b/packages/trail/src/tui/index.tsx
index 19976312..2e5a4db3 100644
--- a/packages/trail/src/tui/index.tsx
+++ b/packages/trail/src/tui/index.tsx
@@ -237,6 +237,8 @@ export function createTrailTui({ source = TRAIL_PACKAGE }: { source?: string } =
         await journal.append(events)
         if (trouble?.startsWith("trail not saved")) trouble = undefined
         await sync()
+        /** `runAdd` folded the event in already, so the read finds nothing new to draw for: draw anyway. */
+        draw()
         return true
       } catch (error) {
         log.error("trail not saved", { file: paths.events, error })

From 0f589a6cc863dc5b965a677735aa681ab6989031 Mon Sep 17 00:00:00 2001
From: Codestz 
Date: Sat, 3 Oct 2026 10:24:48 -0500
Subject: [PATCH 25/90] =?UTF-8?q?Trail:=20its=20README=20=E2=80=94=20what?=
 =?UTF-8?q?=20it=20is,=20no=20setup,=20the=20sidebar,=20/trail,=20the=20to?=
 =?UTF-8?q?ols,=20settings,=20team=20conventions?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Co-Authored-By: Claude Opus 5.5 
---
 packages/trail/README.md | 127 +++++++++++++++++++++++++++++++++++++++
 1 file changed, 127 insertions(+)
 create mode 100644 packages/trail/README.md

diff --git a/packages/trail/README.md b/packages/trail/README.md
new file mode 100644
index 00000000..2c6540be
--- /dev/null
+++ b/packages/trail/README.md
@@ -0,0 +1,127 @@
+# @opencode-cockpit/trail
+
+**What a conversation made.** The pull requests, tickets, pages and deploys your agent created or
+changed — kept per conversation, grouped by the ticket they were for, and one click from the page.
+And the other way round: which conversation opened PR #33, and a jump back into it.
+
+Part of [opencode-cockpit](https://github.com/Codestz/opencode-cockpit). Install it on its own, or
+through the bundle, where it is on by default (`features.trail: false` turns it off). Works on
+OpenCode 1.18+ and 2.0.15+.
+
+```sh
+opencode plugin @opencode-cockpit/trail@0.9.0 --global --force     # OpenCode 1
+opencode plugin add @opencode-cockpit/trail@0.9.0                   # OpenCode 2
+```
+
+**Setup: none.** No account, no token, no list of tools to configure. The agent already knows what
+it just did, with whatever it uses — `gh`, an MCP server, a company CLI — so the agent writes the
+trail, and Trail keeps it. It has no GitHub or Jira client and stores no credentials.
+
+## How things get into the trail
+
+- **The agent records them** with `trail_add`, right after it creates or changes something outside
+  the repository's files. It is told so in its system prompt on every request, subagents included.
+- **A safety net, never automatic.** When the output of something the agent *ran* — a shell command,
+  an MCP call — holds a PR or issue link it has not recorded, its next request says so, as a choice:
+  *seen in output — record it if you created or changed it*. Links in files it read or pages it
+  fetched are ignored. Nothing is ever added without the agent or you.
+- **You add one** with `/link  [note]` (the palette's "Add a link to this conversation's
+  trail"), or with `a` on a link `/trail` found in what the conversation ran.
+
+The system comes from the link, not from a list: github.com is GitHub, `…atlassian.net/browse` is
+Jira, `…/wiki` Confluence, claude.ai Claude, linear.app Linear, anything else its domain. Query
+parameters that look like secrets (`token`, `sig`, signed-URL parameters…) are dropped before
+anything is stored, and only `http(s)` links are ever opened.
+
+## In the sidebar
+
+On by default, after Shells:
+
+```
+Trail                            4
+
+COM-1736  Bundle desync    Jira  2h ↗
+  PR #33  0.8: Trust, on… GitHub 1h ↗
+  PR #12  Landing: Trus… GitHub  2h ↗
+deploy    staging · web-portal  25m
++ 3 more · /trail
+```
+
+What this conversation made, grouped by what it was for, newest work first. A row is the thing's
+name, its title, its system, what this conversation last did and when — history, not a status: a
+PR's state belongs to GitHub, and a trail that said "open" for a merged PR would be worse than none.
+**Click a row with `↗` to open the page**; one without a page opens `/trail` on it; `+ N more` opens
+`/trail`. Empty, the block says `none yet`.
+
+## `/trail`
+
+`/trail`, `ctrl+x f` or the palette: **This conversation** and **All conversations** in this project
+(`tab`), grouped the same way, with every conversation that touched a thing listed under it.
+
+| key | |
+| --- | --- |
+| `enter` | open the page |
+| `g` | go to the conversation that made it (its root, naming the subagent that did) |
+| `c` | copy the link |
+| `a` | add a link the conversation printed but never recorded |
+| `x` | remove it from the trail |
+| `m` | copy the trail as a markdown list — for a PR description, a standup, a ticket |
+| `/` | search title, ref, kind and system (`jira`, `COM-1736`) |
+| `esc` | close |
+
+A conversation since deleted keeps its records, marked as such, under the title it had.
+
+## The agent's tools
+
+| tool | |
+| --- | --- |
+| `trail_add` | `title`, and `url` or `ref`; optional `kind`, `action`, `for`, `note`, all free text. The same link again updates the record — its actions become a history (`created → updated`) — never a second row. |
+| `trail_list` | This conversation's trail, or with `all` every conversation in the project and which one made each; `query` filters. The same facts and order as `/trail`. |
+
+What the conversation produced is rebuilt into its system prompt on every request from the trail
+itself, so the agent still knows after the conversation is compacted.
+
+## Settings
+
+In `~/.config/opencode-cockpit/config.json`, or a project's `.cockpit.json`:
+
+```jsonc
+{
+  "trail": {
+    "enabled": true,        // the off switch; features.trail: false works too
+    "sidebar": true,        // draw the block
+    "sidebarRows": 5,       // records before "+ N more"
+    "hideWhenEmpty": false, // no block at all until there is something to show
+    "keybinds": { "cockpit.trail.open": "f" }
+  }
+}
+```
+
+Where the block sits is the top-level `"sidebar"` list's to say
+(`["status", "subagents", "shell", "trail", "trust"]` by default). A setting Trail cannot use is
+drawn in the block as a `!` row with what to do.
+
+## Team conventions (optional)
+
+Trail works with nothing in your repository. If your team names things a certain way, a few lines in
+`AGENTS.md` shape the records — they do not make them happen:
+
+```md
+## Trail
+- Tickets are Jira keys like COM-1234: pass the PR's ticket as `for`.
+- Put the Confluence space in a page's title: "WEB · Release notes 0.8".
+- Record deploys with kind "deploy" and the environment in the title.
+```
+
+## Where it keeps things
+
+One append-only file per project, outside it:
+`~/.local/share/opencode-cockpit/trail/-/events.ndjson` (`$XDG_DATA_HOME`, or
+`$COCKPIT_HOME`). Every window and the agent append to it; nothing is ever rewritten.
+
+## See it without OpenCode
+
+```sh
+bunx @opencode-cockpit/trail preview            # the sidebar and /trail, from sample trails
+bunx @opencode-cockpit/trail preview --text     # and what the agent reads
+```

From 233feed415dfbf642aec93fe3329026466a646d3 Mon Sep 17 00:00:00 2001
From: Codestz 
Date: Sat, 3 Oct 2026 10:28:22 -0500
Subject: [PATCH 26/90] Site: Trail on the landing page and its docs page

Co-Authored-By: Claude Opus 5.5 
---
 media/trail.png                         | Bin 0 -> 398274 bytes
 site/astro.config.mjs                   |   4 +
 site/src/content/docs/trail/overview.md | 106 ++++++++++++++++++++++++
 site/src/data/landing.ts                |  42 +++++++++-
 site/src/pages/index.astro              |   3 +-
 5 files changed, 150 insertions(+), 5 deletions(-)
 create mode 100644 media/trail.png
 create mode 100644 site/src/content/docs/trail/overview.md

diff --git a/media/trail.png b/media/trail.png
new file mode 100644
index 0000000000000000000000000000000000000000..d129fdc4b9085dbba2172028e1fbab03881fabf3
GIT binary patch
literal 398274
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�LZT*j?+M-)RCNu-Zt-&C6;L#)`uEm}9}W{6A6Jgv2Hi4SwWlIEXI zAYJKQ*KNBHS!2tXvs2_m>QW)YefRO_x6~tQ#ais8|6DNoVi;Wid}nQuUq|#e1t3O# zH^|){jQjKZOSxXh8M|A{pWg)3+XpE|#rg)O3-UhiqzzK^kjZF|h?wkK9JfmLDaa^s zxA&|l${tGz=J1se$#4#|VWf(0$@1}^{8l;K->^oT-N&*cC=qz*-Vz3hPBW31sOZ{X z*QpV>n$+y4hViZvx9XQa6XgPRcOo9e z#790%qL+*%66IWfzVllo259-3nA8rm85fs6b9z-d-ENn+{-eqmH->0a{h;pBo6|u! z6+SW2Y?)0t%E!0Vd%%5VI*|F*Et6;E=0T*?XUXf;#u(I|Uo1E%BqJj(4(O7#QcLT* z+uY(orn`Rpm^s=Oz&NBJ^z@2;ynx49(P{4rlg8BfnEaoMdH{etSC7a2P>E@BdE9Y* z?QfbsxjlyXiLG)yBs1Mt%2Sbob#jdC~oNjsD8b{3nN=oR=sm9vOEY`0;}!Xs5C> zDiel` zZokIdfv$Yf-8#HY;^)IeGZnIGx4p!|$I(vxrN2d(Wt)hF*oDiN-HU14fBQ>jR?zq z4yRX^ZQBQY&z_0~uo8V7AVo^d;@UqmU$e|lyFRQ;t=^%VO^hBy;}isnUnN>Ij+_X8 z@Nii)8#&c%Io3xX=+r4QS%Gd*x*fCqj5rJu`x#PP=~ZCulRC@?h=?j`+^!$T37z<7 z=WW7y0@Lx?6Sv&r8L0tDgAnO?9VWRF>lVMl zY!@AV0_oEQ;vuc|C#u(eu9Dn)1)S2I-f+xzl5hB@Rh|li$w_fX_{+e9-ofH`8N)1Z z#7kE>o>`_UE*tqZ9>_YI${ko|nq9xiC8FaN_^_8QUYfqOSt4~& zZ+@;k-j;zw8c}(=aRIA^Tr#GxJNNaN0uOQ@3=yjMd&H2rQX%us#bHzy4Gj*}dd6Rg zO)Q^ZSn)2M+aBcCQ(0-l#|MEcF?@ve!P4!SSxPx@WSf+H|4pCt3>z3piMyMGg({c6 zywEeU5#0y(?OE3b>BP}YKK##Vvk((~_2QgLwB?~x>!OR#_ii_QDMB}$(YF?qY7=J7 zltLf{8EH7RNcZ&K{JXxUnXF&?)k3p*U0K`S)<*DMOW2)M0e(obdxZ>zMf#;L>XkTm zq?77`84gI>O2nDi9vF7*y|Fr*Ts}eKF1hq@4+=-rk;{5$IjCf1T~xnf%JIP_Gt=Wz za!rbISHR0p z`R|P|wyFKVvkmBkfA-e$PTR0XAsLR=%}~o3U0GRJV*3Xe0&ij6l1eB kTiDbFD= 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

e=fD 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

uJY|U_jJ<>tu9-m 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 z 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 literal 0 HcmV?d00001 diff --git a/site/astro.config.mjs b/site/astro.config.mjs index e82d4836..11ccfa83 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -75,6 +75,10 @@ export default defineConfig({ label: "Subagents", items: [{ label: "Overview", slug: "subagents/overview" }], }, + { + label: "Trail", + items: [{ label: "Overview", slug: "trail/overview" }], + }, { label: "Trust", items: [{ label: "Overview", slug: "trust/overview" }], diff --git a/site/src/content/docs/trail/overview.md b/site/src/content/docs/trail/overview.md new file mode 100644 index 00000000..3036d552 --- /dev/null +++ b/site/src/content/docs/trail/overview.md @@ -0,0 +1,106 @@ +--- +title: Trail +description: What a conversation made — the PRs, tickets and pages your agent created or changed, kept per conversation, one click from the page, and which conversation made each. +--- + +A conversation produces things that outlive it: pull requests, often in more than one repository, +tickets created or moved, a deploy, a page. Today the only record is the chat. Trail keeps it, per +conversation, and answers both ways: *what did this conversation make?* and *which conversation +made PR #33 — take me there.* + +```sh +opencode plugin @opencode-cockpit/trail@0.9.0 --global --force # OpenCode 1 +opencode plugin add @opencode-cockpit/trail@0.9.0 # OpenCode 2 +``` + +Or through the bundle, where it is on by default (`features.trail: false` turns it off). + +**There is nothing to set up.** People reach the same systems in different ways — `gh`, an MCP +server, a company CLI — and the agent always knows what it just did. So the agent writes the trail +and Cockpit keeps, orders and shows it. Trail has no GitHub or Jira client and stores no +credentials. + +![Trail in the sidebar, grouped by the tickets the work was for, and the /trail dialog with the same records and two links found in command output but never recorded](/opencode-cockpit/media/trail.png) + +## How things get into it + +- **The agent records them** with `trail_add`, right after it creates or changes something outside + the repository's files. Its system prompt says so on every request, subagents included. A + subagent's records belong to the conversation and name the subagent. +- **A safety net, never automatic.** A PR or issue link in the output of something the agent *ran* — + a shell command, an MCP call — that it has not recorded is put to it on its next request as a + choice: *seen in output — record it if you created or changed it*. Links in files it read or pages + it fetched never count. Nothing is added without the agent or you. +- **You add one** with `/link [note]`, or with `a` on a link `/trail` found in what the + conversation ran. + +The same link again updates its record — the actions become a history, `created → updated` — and a +better title replaces the old one. Which system a thing belongs to comes from its link: GitHub, +Jira, Confluence, Claude, Linear, or else its domain. Query parameters that look like secrets are +dropped before anything is stored, and only `http(s)` links are ever opened. + +## In the sidebar + +On by default, after Shells. What this conversation made, grouped by the ticket it was for, newest +work first: its name, its title, its system, what this conversation last did and when. There is no +status — where a PR stands now belongs to GitHub, one click away; a trail that said "open" for a +merged PR would be worse than none. + +- **A row with `↗` opens the page** in your browser. +- A row without a page opens `/trail` on it; `+ N more` opens `/trail`. +- Empty, the block says `none yet`; `hideWhenEmpty` hides it until there is something to show. + +## `/trail` + +`/trail`, `ctrl+x f`, or the palette ("cockpit trail"): **This conversation** and **All +conversations** in the project (`tab`), grouped the same way. Under a thing several conversations +touched, each one is listed — a deleted conversation keeps its records, marked. + +| key | | +| --- | --- | +| `enter` | open the page | +| `g` | go to the conversation that made it | +| `c` | copy the link | +| `a` | add a link the conversation printed but never recorded | +| `x` | remove it from the trail | +| `m` | copy the trail as a markdown list | +| `/` | search title, ref, kind and system | +| `esc` | close | + +## The agent's tools + +| tool | | +| --- | --- | +| `trail_add` | `title`, and `url` or `ref`; optional `kind`, `action`, `for`, `note` — all free text | +| `trail_list` | this conversation's trail, or with `all` the project's and which conversation made each; `query` filters | + +`trail_list` answers with the same facts in the same order as `/trail`. What the conversation +produced is also rebuilt into its system prompt on every request, from the trail itself, so the +agent still knows after a long conversation is compacted. + +## Settings + +```jsonc +// ~/.config/opencode-cockpit/config.json, or a project's .cockpit.json +{ + "trail": { "sidebar": true, "sidebarRows": 5, "hideWhenEmpty": false } +} +``` + +`enabled` and `keybinds` work as in every bay; the block's place is the top-level `"sidebar"` list's +to say. See [Configuration](/opencode-cockpit/configuration/). + +## Team conventions + +Optional. A few lines in your `AGENTS.md` shape what gets recorded — they do not make it happen: + +```md +## Trail +- Tickets are Jira keys like COM-1234: pass the PR's ticket as `for`. +- Put the Confluence space in a page's title: "WEB · Release notes 0.8". +``` + +## Where it keeps things + +One append-only file per project, outside it, under `~/.local/share/opencode-cockpit/trail/`. +Every window and the agent append to it; nothing is ever rewritten. diff --git a/site/src/data/landing.ts b/site/src/data/landing.ts index df9848d0..06e1ca50 100644 --- a/site/src/data/landing.ts +++ b/site/src/data/landing.ts @@ -11,6 +11,7 @@ export const nav = { { label: "Statusline", href: "#status" }, { label: "Updater", href: "#updater" }, { label: "Subagents", href: "#subagents" }, + { label: "Trail", href: "#trail" }, { label: "Trust", href: "#trust" }, { label: "Platform", href: "#platform" }, { label: "Install", href: "#install" }, @@ -60,7 +61,7 @@ export const compare = { } export const platform = { - number: "08", + number: "09", kicker: "The platform", title: "What every capability inherits.", intro: @@ -111,7 +112,7 @@ export const platform = { export const bays = { number: "01", kicker: "What is fitted", - title: "Six instruments today. One switch each.", + title: "Seven instruments today. One switch each.", intro: "Every bay is its own npm package with a switch in config. They share the daemon, the config " + "file and the keys, so the second costs nothing and moving between them changes nothing you " + @@ -281,6 +282,39 @@ export const bays = { ], }, }, + { + id: "trail", + name: "Trail", + tagline: "What a conversation made — and which conversation made it", + state: "live", + status: "Available", + gains: { + agent: "Records the PRs, tickets and pages it creates, and can say which conversation made one", + you: "Everything a conversation made, in the sidebar, one click from the page", + }, + blurb: + "A conversation opens pull requests in two repositories, moves a ticket, publishes a page — and " + + "the only record is the chat. Trail keeps it: the agent records what it creates or changes with " + + "whatever tools you use, and Cockpit orders it by the ticket it was for and puts it in the " + + "sidebar, a click from the page. Months later, `/trail` says which conversation opened PR #33 and " + + "takes you back into it.", + points: [ + "No setup: the agent writes the trail, with gh, an MCP server, any tool", + "Grouped by the work — a ticket heads its PRs", + "Click a row: the page opens in your browser", + "Which conversation made it, and a jump back into it", + "Links it printed and never recorded are offered, never added", + "Copy a conversation's trail as markdown, for the PR or the standup", + ], + foot: ["sidebar · /trail · /link", "trail_add · trail_list", "@opencode-cockpit/trail"], + docs: "/trail/overview/", + media: { + kind: "image" as const, + src: "/media/trail.png", + alt: "Trail in the sidebar — a conversation's PRs, tickets, pages and deploys grouped under the tickets they were for — and the /trail dialog with the same records, their systems, what was done and when, and two links found in command output but not recorded", + caption: "bunx @opencode-cockpit/trail preview · the rows OpenCode draws, from a sample conversation", + }, + }, { id: "trust", name: "Trust", @@ -319,7 +353,7 @@ export const bays = { /** What is coming, and the reason it is next. */ export const next = { - number: "09", + number: "10", kicker: "What is next", title: "One bay at a time, and only what can be built.", intro: @@ -339,7 +373,7 @@ export const next = { } export const install = { - number: "10", + number: "11", kicker: "Install", title: "Two minutes, then ask it to start something.", modes: [ diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro index 8047a595..ebce4c38 100644 --- a/site/src/pages/index.astro +++ b/site/src/pages/index.astro @@ -31,7 +31,8 @@ import Closing from "../components/landing/Closing.astro" - + + From 7d6abc783c37a0708d747af4b631e859c5690651 Mon Sep 17 00:00:00 2001 From: Codestz Date: Sat, 3 Oct 2026 10:34:34 -0500 Subject: [PATCH 27/90] Trail measurement: a branch whose change matches its message, so the turn is about the PR Co-Authored-By: Claude Opus 5.5 --- packages/trail/measure/agent.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/trail/measure/agent.ts b/packages/trail/measure/agent.ts index 0d0fc360..39116b50 100644 --- a/packages/trail/measure/agent.ts +++ b/packages/trail/measure/agent.ts @@ -109,7 +109,24 @@ async function once(index: number): Promise { ["git", "checkout", "-qb", branch], ]) run(cmd, project) - await Bun.write(join(project, "checkout.ts"), "export const retries = 3\n") + /** A change that matches its commit message: a model that finds a one-liner under a big message stops to ask. */ + await Bun.write( + join(project, "checkout.ts"), + [ + "/** Retry the checkout request after a dropped connection, up to three times, backing off. */", + "export async function checkout(send: () => Promise, retries = 3): Promise {", + " for (let attempt = 0; ; attempt++) {", + " try {", + " return await send()", + " } catch (error) {", + " if (attempt >= retries) throw error", + " await new Promise((done) => setTimeout(done, 200 * 2 ** attempt))", + " }", + " }", + "}", + "", + ].join("\n"), + ) for (const cmd of [ ["git", "add", "-A"], ["git", "commit", "-qm", "Retry the checkout request after a dropped connection"], From 772f8e3b1c6875ad0362017387a9133bfe956b0a Mon Sep 17 00:00:00 2001 From: Codestz Date: Sat, 3 Oct 2026 11:11:37 -0500 Subject: [PATCH 28/90] /cockpit-setup: one command for every bay, briefing the agent with the settings, the old names and OpenCode's own blocks Registered by the first Cockpit entry in a window (dualTui), listing the bays that claimed themselves there; defaults come from what each bay hands baySettings. From home on OpenCode 2 it opens a conversation for the brief; while the agent is busy it queues rather than steers. Co-Authored-By: Claude Opus 5.5 --- packages/client/package.json | 4 + packages/client/src/cli/preview.ts | 21 ++ packages/client/src/feature.ts | 8 + packages/client/src/host.ts | 24 +- packages/client/src/index.ts | 2 +- packages/client/src/settings.ts | 24 ++ packages/client/src/setup.ts | 555 +++++++++++++++++++++++++++++ packages/client/test/setup.test.ts | 319 +++++++++++++++++ 8 files changed, 954 insertions(+), 3 deletions(-) create mode 100644 packages/client/src/setup.ts create mode 100644 packages/client/test/setup.test.ts diff --git a/packages/client/package.json b/packages/client/package.json index 255b6219..4340c268 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -55,6 +55,10 @@ "./settings": { "types": "./types/settings.d.ts", "default": "./dist/settings.js" + }, + "./setup": { + "types": "./types/setup.d.ts", + "default": "./dist/setup.js" } }, "files": [ diff --git a/packages/client/src/cli/preview.ts b/packages/client/src/cli/preview.ts index ac9ea4b1..70dc47fc 100644 --- a/packages/client/src/cli/preview.ts +++ b/packages/client/src/cli/preview.ts @@ -7,11 +7,13 @@ * bun packages/client/src/cli/preview.ts * bun packages/client/src/cli/preview.ts --width 28 * bun packages/client/src/cli/preview.ts --config ./my.json notices for a config file of yours + * bun packages/client/src/cli/preview.ts --setup the brief /cockpit-setup sends */ import { readFileSync } from "node:fs" import { emptyBlock, GLYPH, type ToneRun } from "../design.ts" import { loadSettings, noticeText, type SettingsNotice } from "../settings.ts" +import { buildSetupReport, setupBrief } from "../setup.ts" const HEX: Record = { text: "#eeeeee", @@ -67,6 +69,25 @@ const settings = loadSettings({ : undefined, }) +/** `--setup`: the brief, for the sample (or your) config, with the bundle's bays loaded. */ +if (process.argv.includes("--setup")) { + const bays = ["status", "subagents", "shell", "trail", "review", "updater"] + const claims = new Map(bays.map((bay) => [bay, "opencode-cockpit"])) + /** `--opencode 1` for OpenCode 1's wording of its own blocks; your own OpenCode files are not read. */ + const opencode = arg("--opencode") === "1" ? 1 : 2 + const report = buildSetupReport({ + opencode, + directory: "/project", + claims, + settings, + env: {}, + home: "/nowhere", + read: () => undefined, + }) + console.log(setupBrief(report)) + process.exit(0) +} + for (const width of widths) { const ruler = `── ${width} columns `.padEnd(width, "─") console.log(ruler) diff --git a/packages/client/src/feature.ts b/packages/client/src/feature.ts index 09751951..11eeb43c 100644 --- a/packages/client/src/feature.ts +++ b/packages/client/src/feature.ts @@ -50,6 +50,14 @@ export function claimFeature(scope: object, feature: string, source: string): Fe } } +/** + * Every feature claimed in a scope, and the copy that owns it — `{ shell: "opencode-cockpit" }`. What + * is actually loaded in this window, bundle or standalone, read by `/cockpit-setup` when it runs. + */ +export function claimedFeatures(scope: object): ReadonlyMap { + return new Map(registry().get(scope) ?? []) +} + export function duplicateFeatureMessage(feature: string, owner: string, skipped: string): string { return `${feature} is configured twice (${owner} and ${skipped}). Using ${owner}; remove one of them from your OpenCode config.` } diff --git a/packages/client/src/host.ts b/packages/client/src/host.ts index cb102c2a..9d1d4c9b 100644 --- a/packages/client/src/host.ts +++ b/packages/client/src/host.ts @@ -16,6 +16,7 @@ import type { CliRenderer, KeyEvent } from "@opentui/core" import { createComponent, createRoot, getOwner, type JSX, type Owner, onCleanup } from "solid-js" import { textElement } from "./elements.tsx" import { cockpitVersion, createLog, type Log, silentLog } from "./log.ts" +import { registerSetup } from "./setup.ts" type V1Layer = Parameters[0] export type Layer = V1Layer @@ -286,7 +287,16 @@ export interface V2Context { readonly mcp?: { readonly server: { list(location?: unknown): unknown[] | undefined } } } readonly session: { - prompt?(input: unknown): Promise + /** + * A message to a conversation. `delivery` (2.0.18): `"steer"`, the default, hands it to the turn + * that is running; `"queue"` waits for that turn to end. + */ + prompt?(input: { sessionID: string; text: string; delivery?: "steer" | "queue" }): Promise + /** A new conversation, here unless `location` says otherwise; `request` settles once it exists. */ + create?(input: { location?: { directory: string }; title?: string }): { + id: string + request: Promise + } get?(sessionID: string): unknown status?(sessionID: string): unknown sync?(sessionID: string): Promise @@ -325,7 +335,11 @@ export interface V2Context { readonly toast: { show(options: { title?: string; message: string; variant?: string; duration?: number }): void } - readonly router: { current(): { type: string; sessionID?: string } } + readonly router: { + current(): { type: string; sessionID?: string } + /** Present on 2.0.18: `{ type: "session", sessionID }` or `{ type: "home" }`. */ + navigate?(route: { type: string; sessionID?: string }): void + } slot(claim: { render: (input: never) => JSX.Element } & Record): () => void } } @@ -710,6 +724,12 @@ export function dualTui(id: string, start: Start) { opencodeVersion: opencode, cockpit: cockpitVersion(), }) + /** `/cockpit-setup`: every entry offers it, the first in a window registers it (setup.ts). */ + try { + registerSetup(host, id) + } catch (error) { + host.log.warn("setup: not registered", { entry: id, error }) + } try { await start(host, options) } catch (error) { diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index e43e53b6..13935c9f 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -5,5 +5,5 @@ export { compareBuilds, type OutdatedDaemon, } from "./client.ts" -export { claimFeature, duplicateFeatureMessage, type FeatureClaim } from "./feature.ts" +export { claimedFeatures, claimFeature, duplicateFeatureMessage, type FeatureClaim } from "./feature.ts" export type { SpawnOptions } from "./spawn.ts" diff --git a/packages/client/src/settings.ts b/packages/client/src/settings.ts index c3eda786..9d128f20 100644 --- a/packages/client/src/settings.ts +++ b/packages/client/src/settings.ts @@ -680,6 +680,7 @@ export function baySettings>( const config = mergeSections(base, written) as SharedSettings & Widen config.sidebarRows = Math.max(0, Math.floor(config.sidebarRows)) if (settings.features[bay] === false) config.enabled = false + seenBays().set(bay, { defaults: base, notices: notices.filter((notice) => notice.file === OPTIONS_SOURCE) }) return { config, written, @@ -689,6 +690,29 @@ export function baySettings>( } } +/** + * What each bay read its settings with, as it last called `baySettings`: its defaults, shared keys + * included, and the notices its plugin options raised (the files' are in `loadSettings` already). So + * `/cockpit-setup` lists every key an installed bay reads, and its default, from the very object the + * bay merges over rather than from a copy that drifts. On `globalThis`, because the bundle and a + * standalone bay can each bring their own copy of this module. + */ +export interface BayRead { + defaults: Section + notices: SettingsNotice[] +} + +const BAYS_READ = Symbol.for("opencode-cockpit.settings.read") + +function seenBays(): Map { + const host = globalThis as { [BAYS_READ]?: Map } + host[BAYS_READ] ??= new Map() + return host[BAYS_READ] +} + +/** Every bay that has read its settings in this process, and what it read them with. */ +export const baysRead = (): ReadonlyMap => new Map(seenBays()) + /** * Plugin options as one bay's section: a whole cockpit config's section for it, else the options * themselves (a standalone entry carries its own keys). Only for options — a *file* without a section diff --git a/packages/client/src/setup.ts b/packages/client/src/setup.ts new file mode 100644 index 00000000..eb1d5269 --- /dev/null +++ b/packages/client/src/setup.ts @@ -0,0 +1,555 @@ +/** + * `/cockpit-setup`: the brief that sets Cockpit up, handed to the agent. + * + * Like `/status-setup` it draws nothing. Choosing which bays show, where and in what order is an + * editing job in a file the interface never names, so the useful thing is a message to the agent + * already in the session carrying what it cannot look up: which bays this window loaded, which files + * Cockpit reads and what is in them, every name it no longer reads, and every key with its default — + * then the questions to put to the person before anything is written. + * + * One command for every bay. Whichever Cockpit entry starts first in a window registers it + * (`registerSetup`, called from `dualTui`, so the bundle and any standalone bay alike carry it), and + * the bays it lists are the ones that claimed themselves in that window (`claimedFeatures`), read when + * it runs — the bundle, two standalone packages, or one. + * + * The brief is built from the loader's own names and defaults, and from the defaults each bay hands + * `baySettings`, never a copy of them, so it cannot describe a setting that is not read + * (docs/opencode/settings-and-commands.md, Part B). + */ + +import { readFileSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" +import { claimedFeatures, claimFeature } from "./feature.ts" +import type { Host } from "./host.ts" +import { parseJsonc } from "./jsonc.ts" +import { + BAYS, + type Bay, + type BayRead, + baysRead, + isSidebarBay, + loadSettings, + OLD_NAMES, + SETUP_COMMAND, + type Settings, + type SettingsNotice, + SHARED_DEFAULTS, + type SharedSettings, + SIDEBAR_BAYS, +} from "./settings.ts" + +/** The slash name, without its `/`. */ +export const SETUP_SLASH = SETUP_COMMAND.slice(1) + +/** What each shared key does. Typed by the interface, so a key added there and not here fails to build. */ +const SHARED_ABOUT: Readonly> = { + enabled: "the bay's off switch, both halves; `false` turns it off (so does `features.: false`)", + keybinds: 'keys for its commands, `{ "": "" }`; leave alone unless asked', + sidebar: + "draw its sidebar block, true or false. For Status, false puts its line at the bottom, under the prompt", + sidebarRows: "rows its block lists before the rest fold into `+ N more`", + hideWhenEmpty: + "true: no block at all while there is nothing to list. false: the heading and `none yet`, so it is plain the bay is installed", +} + +/** Keys that only mean something for a bay with a sidebar block. */ +const BLOCK_KEYS: readonly (keyof SharedSettings)[] = ["sidebar", "sidebarRows", "hideWhenEmpty"] + +/** What a bay is, in the words the brief uses to ask about it. */ +const BAY_ABOUT: Readonly> = { + status: "the statusline: context, tokens, cost, git; in the sidebar by default, or under the prompt", + subagents: "the subagents a conversation launched, live", + shell: "background shells the agent started, with a dock and a console", + trail: "what a conversation created or changed: PRs, branches, links", + trust: "what Trust answered for you instead of asking; its block is off by default", + review: "the pane for reviewing changes; no sidebar block", + updater: "checks for plugin updates; no sidebar block", +} + +// ── OpenCode's own sidebar blocks ────────────────────────────────────────────────────────────── +// +// Status's table draws a Context section, so with OpenCode's own Context block on, "Context" shows +// twice. Turning that block off is OpenCode's setting, in OpenCode's file — so the brief says what it +// is set to now and how to change it, and the agent asks before touching a file that is not Cockpit's. +// Measured on 1.18.32 and 2.0.18 (docs/opencode/settings-and-commands.md, "/cockpit-setup spikes"). + +/** OpenCode's own sidebar blocks the brief talks about, and their plugin ids on each version. */ +type HostBlock = "context" | "lsp" | "todo" + +/** + * 1.18.32 names its blocks `internal:sidebar-*` and turns one off in `tui.json` with + * `"plugin_enabled": { "": false }`. 2.0.18 names them `opencode.sidebar.*`, turns one off with + * `"-"` in `cli.json`'s `plugins` list, and has no LSP or Todo block at all. + */ +export const HOST_BLOCKS: Readonly>>> = { + 1: { context: "internal:sidebar-context", lsp: "internal:sidebar-lsp", todo: "internal:sidebar-todo" }, + 2: { context: "opencode.sidebar.context" }, +} + +/** One of OpenCode's interface config files, and which of the blocks it switches. */ +export interface HostFile { + path: string + found: boolean + error?: string + /** Block id → on or off, as this file writes it. */ + blocks: Record +} + +/** The files OpenCode reads its interface settings from: the global one, then the project's. */ +export function hostFilePaths( + opencode: 1 | 2, + directory: string, + env: Readonly> = process.env, + home: string = homedir(), +): string[] { + const name = opencode === 1 ? "tui" : "cli" + const global = join(env.XDG_CONFIG_HOME || join(home, ".config"), "opencode") + return [global, directory, join(directory, ".opencode")].flatMap((dir) => [ + join(dir, `${name}.json`), + join(dir, `${name}.jsonc`), + ]) +} + +/** What one file says about the blocks. Never throws: a file that will not parse says so. */ +export function readHostFile(opencode: 1 | 2, path: string, text: string | undefined): HostFile { + if (text === undefined) return { path, found: false, blocks: {} } + const parsed = parseJsonc(text) + const value = parsed.ok ? parsed.value : undefined + if (typeof value !== "object" || value === null) + return { path, found: true, error: parsed.ok ? "not a JSON object" : parsed.message, blocks: {} } + const ids = Object.values(HOST_BLOCKS[opencode]) + const blocks: Record = {} + if (opencode === 1) { + const enabled = (value as { plugin_enabled?: unknown }).plugin_enabled + if (typeof enabled === "object" && enabled !== null) + for (const [id, on] of Object.entries(enabled)) + if (ids.includes(id) && typeof on === "boolean") blocks[id] = on + } else { + const plugins = (value as { plugins?: unknown }).plugins + for (const entry of Array.isArray(plugins) ? plugins : []) { + const name = typeof entry === "string" ? entry : (entry as { package?: unknown })?.package + if (typeof name !== "string") continue + const off = name.startsWith("-") + const id = off ? name.slice(1) : name + if (ids.includes(id)) blocks[id] = !off + } + } + return { path, found: true, blocks } +} + +function readText(path: string): string | undefined { + try { + return readFileSync(path, "utf8") + } catch { + return undefined + } +} + +export interface SetupReport { + /** Which OpenCode this window is. */ + opencode: 1 | 2 + directory: string + /** The bays this window loaded, in the default order, and the copy that loaded each. */ + loaded: { bay: Bay; source: string }[] + settings: Settings + /** Every key each bay reads and its default: the bay's own object where it has read its settings. */ + defaults: Record> + /** Every notice: the files' (from `loadSettings`), then each loaded bay's plugin options'. */ + notices: SettingsNotice[] + /** OpenCode's own interface config files, global first: what they say about its sidebar blocks. */ + host: HostFile[] +} + +export interface ReportInput { + opencode: 1 | 2 + directory: string + /** Feature claims in this window: `{ shell: "opencode-cockpit", setup: … }`. Non-bays are ignored. */ + claims: ReadonlyMap + /** As `baysRead()` returns them. */ + bays?: ReadonlyMap + settings?: Settings + env?: Readonly> + home?: string + /** A file's text, or undefined when there is none — for both Cockpit's files and OpenCode's. */ + read?: (path: string) => string | undefined +} + +export function buildSetupReport(input: ReportInput): SetupReport { + const settings = + input.settings ?? + loadSettings({ + directory: input.directory, + ...(input.env ? { env: input.env } : {}), + ...(input.home ? { home: input.home } : {}), + ...(input.read ? { read: input.read } : {}), + }) + const loaded = BAYS.filter((bay) => input.claims.has(bay)).map((bay) => ({ + bay, + source: input.claims.get(bay) as string, + })) + const defaults = {} as SetupReport["defaults"] + for (const bay of BAYS) defaults[bay] = { ...SHARED_DEFAULTS[bay], ...input.bays?.get(bay)?.defaults } + const fromOptions = loaded.flatMap(({ bay }) => input.bays?.get(bay)?.notices ?? []) + return { + opencode: input.opencode, + directory: input.directory, + loaded, + settings, + defaults, + notices: [...settings.notices, ...fromOptions], + host: hostFilePaths(input.opencode, input.directory, input.env, input.home).map((path) => + readHostFile(input.opencode, path, (input.read ?? readText)(path)), + ), + } +} + +// ── The brief ────────────────────────────────────────────────────────────────────────────────── + +const SHARED_KEYS = Object.keys(SHARED_ABOUT) as (keyof SharedSettings)[] + +const json = (value: unknown) => JSON.stringify(value) + +/** A default as the brief writes it; an empty one is "unset", which is what it means to the bay. */ +function shownDefault(value: unknown): string { + if (value === undefined || value === "") return "unset" + if (Array.isArray(value) && value.length === 0) return "unset" + if (typeof value === "object" && value !== null && Object.keys(value).length === 0) return "unset" + return json(value) +} + +function fileLine(file: Settings["files"][number]): string { + const state = file.error + ? `unreadable: ${file.error}; the whole file is ignored until it parses` + : file.found + ? "exists" + : "does not exist yet" + return ` - ${file.scope}: \`${file.path}\` (${state})` +} + +/** + * One notice as a line to act on. An old name says what to write instead in its own words — its + * sentence ends "run /cockpit-setup", which is the command that sent this. + */ +function noticeLine(notice: SettingsNotice): string { + const where = `\`${notice.file}\`` + if (notice.kind !== "old" || !notice.old || !notice.new) return `- ${where}: ${notice.text}.` + /** A number for one bay's place has no new name: the order is the list now. */ + 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. Write it as \`${notice.new}\`.` +} + +/** The settings as written, both files merged: only what someone wrote. */ +function written(settings: Settings): Record { + const sections = Object.fromEntries( + Object.entries(settings.sections).filter(([, section]) => Object.keys(section).length > 0), + ) + return { + ...(settings.sidebarList ? { sidebar: settings.sidebarList } : {}), + ...(Object.keys(settings.features).length > 0 ? { features: settings.features } : {}), + ...sections, + } +} + +/** Whether Status draws its table in the sidebar, as written (the default is the sidebar). */ +function statusInSidebar(settings: Settings): boolean { + const status = settings.sections.status + return status.sidebar !== false && status.surface !== "bottom" +} + +/** OpenCode's own blocks: what each is set to now, where, and what to suggest. */ +function hostSection(report: SetupReport, loaded: ReadonlySet): string[] { + const ids = HOST_BLOCKS[report.opencode] + const found = report.host.filter((file) => file.found) + /** Global, then project: the last file that sets a block is the one in force. */ + const state = (id: string) => { + const file = [...found].reverse().find((each) => id in each.blocks) + return { on: file ? file.blocks[id] !== false : true, file: file?.path } + } + const said = (id: string) => { + const { on, file } = state(id) + return `${on ? "on" : "off"}${file ? ` (set in \`${file}\`)` : " (the default)"}` + } + const global = report.host[0]?.path ?? "" + const how = + report.opencode === 1 + ? (id: string, on: boolean) => + `\`"plugin_enabled": { "${id}": ${on} }\` in \`tui.json\` (global: \`${global}\`)` + : (id: string, on: boolean) => + on + ? `remove \`"-${id}"\` from the \`"plugins"\` list in the \`cli.json\` that has it` + : `add \`"-${id}"\` to the \`"plugins"\` list in \`cli.json\` (global: \`${global}\`)` + const lines = [ + "## OpenCode's own sidebar blocks", + "", + "These are OpenCode's settings, in OpenCode's files, not Cockpit's: change one only after I say yes", + "to that change, keep everything else in the file as it is, and they too apply after a restart.", + "", + `- Files read: ${ + found.length === 0 + ? "none exist yet" + : found + .map((file) => `\`${file.path}\`${file.error ? ` (unreadable: ${file.error})` : ""}`) + .join(", ") + }.`, + ] + if (ids.context) { + const on = state(ids.context).on + lines.push(`- Context (\`${ids.context}\`): ${said(ids.context)}.`) + if (on && loaded.has("status") && statusInSidebar(report.settings)) + lines.push( + ` Suggest turning it off: Status's table in the sidebar replaces it, and with both on "Context" shows twice. To turn it off: ${how(ids.context, false)}.`, + ) + } + if (ids.lsp) + lines.push( + `- LSP (\`${ids.lsp}\`): ${said(ids.lsp)}. You may offer to turn it off, as an option, without recommending it either way: ${how(ids.lsp, false)}.`, + ) + if (ids.todo) { + const on = state(ids.todo).on + lines.push( + on + ? `- Todo (\`${ids.todo}\`): on. Never suggest turning it off: Cockpit has nothing that replaces the todo list.` + : `- Todo (\`${ids.todo}\`): ${said(ids.todo)}. Tell me it is off and offer to turn it back on (Cockpit has nothing that replaces it): ${how(ids.todo, true)}.`, + ) + } + if (report.opencode === 2) lines.push("- OpenCode 2 draws no LSP or Todo block in the sidebar.") + return [...lines, ""] +} + +/** The decisions to put to the person, in the order they build on each other. */ +function questions(loaded: ReadonlySet): string[] { + const blocks = [...loaded].filter(isSidebarBay) + return [ + "Which of the loaded bays I want at all (`enabled: false` in a bay's section turns it off).", + ...(loaded.has("status") + ? [ + "Status in the sidebar or at the bottom under the prompt (`status.sidebar`). What its line shows is `/status-setup`'s job, afterwards: do not design it here.", + ] + : []), + ...(blocks.length > 1 + ? ["The order of the sidebar blocks, top to bottom (the top-level `sidebar` list)."] + : []), + ...(blocks.length > 0 + ? [ + "For each block: shown with `none yet` while empty, or hidden until it has something (`hideWhenEmpty`); and how many rows before it folds (`sidebarRows`).", + ] + : []), + ...(loaded.has("trust") ? ["Whether Trust gets a sidebar block (`trust.sidebar`, off by default)."] : []), + "Which file: the global one (every project) or this project's.", + "OpenCode's own blocks, as the section above says: only what it says to suggest or offer, and only with my yes.", + ] +} + +export function setupBrief(report: SetupReport): string { + const { settings } = report + const loaded = new Set(report.loaded.map((each) => each.bay)) + const missing = BAYS.filter((bay) => !loaded.has(bay)) + const sources = [...new Set(report.loaded.map((each) => each.source))] + const now = written(settings) + const old = report.notices.filter((notice) => notice.kind === "old") + const rest = report.notices.filter((notice) => notice.kind !== "old") + + const blockKeys = SHARED_KEYS.filter((key) => BLOCK_KEYS.includes(key)) + const table = [ + `| bay | ${blockKeys.join(" | ")} |`, + `| --- |${blockKeys.map(() => " --- |").join("")}`, + ...SIDEBAR_BAYS.map( + (bay) => `| ${bay} | ${blockKeys.map((key) => shownDefault(report.defaults[bay][key])).join(" | ")} |`, + ), + ] + const ownKeys = report.loaded.flatMap(({ bay }) => { + const own = Object.entries(report.defaults[bay]).filter(([key]) => !(key in SHARED_ABOUT)) + if (own.length === 0) return [] + return [`- \`${bay}\`: ${own.map(([key, value]) => `\`${key}\` ${shownDefault(value)}`).join(", ")}`] + }) + + return [ + "Help me set up opencode-cockpit: which of its bays show, where, and in what order.", + "", + "## Where it stands", + "", + `- OpenCode ${report.opencode}, project \`${report.directory}\`.`, + `- Loaded in this window${sources.length > 0 ? ` (from ${sources.join(", ")})` : ""}:`, + ...(report.loaded.length === 0 + ? [" - none"] + : report.loaded.map(({ bay }) => ` - \`${bay}\`: ${BAY_ABOUT[bay]}`)), + ...(missing.length > 0 + ? [`- Not installed: ${missing.join(", ")}. Their settings do nothing; do not ask about them.`] + : []), + `- Sidebar order now: ${settings.sidebar.filter((bay) => loaded.has(bay)).join(", ") || "no blocks"}${settings.sidebarList ? "" : " (the default)"}.`, + "- Cockpit's settings files, JSONC (comments and trailing commas are fine). The project's file", + " wins over the global one key by key, and its `sidebar` list replaces the global list:", + ...settings.files.map(fileLine), + "", + Object.keys(now).length === 0 + ? "Nothing is written in either file: every bay is on its defaults." + : [ + "Written now, both files merged (defaults left out):", + "", + "```json", + JSON.stringify(now, null, 2), + "```", + ].join("\n"), + "", + ...(old.length > 0 + ? [ + "## Fix these first", + "", + "Names from before 0.9. They are not read at all (each one is a `!` row in its bay), so the value", + "under each is doing nothing. In the same file, move the value to the new name and remove the old:", + "", + ...old.map(noticeLine), + "", + ] + : []), + ...(rest.length > 0 + ? [ + "## Also not read", + "", + "Each is ignored, and says so with a `!` row:", + "", + ...rest.map(noticeLine), + "", + ] + : []), + "## The settings", + "", + `- \`sidebar\` (top level): the order of the sidebar blocks, top to bottom, a list of ${SIDEBAR_BAYS.map((bay) => `"${bay}"`).join(", ")}. Default ${json([...SIDEBAR_BAYS])}. A bay left out follows the named ones in default order. Only an order: it shows nothing that is off.`, + `- \`features\` (top level): \`{ "": false }\` turns a bay off. Bays: ${BAYS.join(", ")}.`, + `- One section per bay, \`"": { … }\`, merged key by key. Every section takes the same keys:`, + ...SHARED_KEYS.map((key) => ` - \`${key}\`: ${SHARED_ABOUT[key]}.`), + "", + `Defaults of the block keys (${BAYS.filter((bay) => !isSidebarBay(bay)).join(" and ")} have no block):`, + "", + ...table, + "", + ...(ownKeys.length > 0 + ? [ + "Each loaded bay's own keys and defaults. Change these only if I ask; the bay's README says what each does:", + "", + ...ownKeys, + "", + ] + : []), + `Old names, not read anywhere (a plugin entry's options included): ${OLD_NAMES.map((name) => `\`${name.old}\` → \`${name.new}\``).join(", ")}.`, + "", + ...hostSection(report, loaded), + "## Ask me first, one question at a time", + "", + ...questions(loaded).map((question, at) => `${at + 1}. ${question}`), + "", + "Skip a question when I have already answered it. Keys and anything else only if I bring them up.", + "", + "## Then", + "", + "- Edit only the Cockpit file I picked, as JSONC: keep its comments, write only what differs from the defaults, and create the file (and its folder) if it does not exist.", + ...(old.length > 0 ? ['- In the same edit, fix every old name under "Fix these first".'] : []), + "- Tell me what changed, key by key. Cockpit reads its settings when OpenCode starts: they apply after I restart OpenCode.", + "", + "Ask me what I want before you edit anything.", + ].join("\n") +} + +// ── The command ──────────────────────────────────────────────────────────────────────────────── + +/** The session on screen, if there is one. */ +function sessionOnScreen(host: Host): string | undefined { + const route = host.route.current + return route.name === "session" + ? (route.params as { sessionID?: string } | undefined)?.sessionID + : undefined +} + +/** + * Hands `text` to the agent from wherever the person is, with a toast saying it did — the effect can + * land somewhere the screen is not showing yet. Measured on 1.18.32 and 2.0.18: + * + * - in a conversation, idle: sent, and the turn starts; + * - the agent busy: queued behind the running turn, shown as queued in the conversation; + * - home, no conversation: v1's prompt starts one when submitted; v2 gets one made and opened here. + * + * On the next tick, because running a slash command clears the prompt it was typed into: anything + * written during the command itself is wiped a moment later. + */ +export function briefAgent(host: Host, text: string, title: string): void { + setTimeout(() => { + const failed = (error?: unknown) => { + host.log.warn("setup: could not reach the agent", { error }) + host.ui.toast({ variant: "error", title, message: "Could not reach the agent." }) + } + const sent = () => host.ui.toast({ variant: "info", title, message: "Briefed the agent." }) + if (host.v1) { + const tui = host.v1.client.tui + void tui + .appendPrompt({ text }) + .then(() => tui.submitPrompt()) + .then(sent) + .catch(failed) + return + } + void toConversation(host, text).then( + (where) => (where ? sent() : host.ui.toast({ title, message: "Open a conversation first." })), + failed, + ) + }, 0) +} + +/** OpenCode 2: the conversation on screen, or a new one opened for it from home. */ +async function toConversation(host: Host, text: string): Promise { + const session = host.v2?.data.session + let id = sessionOnScreen(host) + if (!id) { + const created = session?.create?.({}) + if (!created) return undefined + await created.request + host.v2?.ui.router.navigate?.({ type: "session", sessionID: created.id }) + id = created.id + } + /** + * Busy, it waits its turn. v2's default hands a message to the turn that is running ("steer"), + * which cut a reply off mid-sentence to start on this; queued, the reply finishes and the brief + * shows as `1 queued` under the conversation — what v1 does with a prompt submitted while busy. + */ + const busy = session?.status?.(id) === "running" + await session?.prompt?.({ sessionID: id, text, ...(busy ? { delivery: "queue" as const } : {}) }) + return id +} + +/** + * `/cockpit-setup`, once per window. Every Cockpit entry calls this as it starts; the first claims + * the command, and the list of bays is read from the window's claims when it runs, so it names + * whatever loaded — including bays that start after it. + */ +export function registerSetup(host: Host, source: string): void { + const claim = claimFeature(host.renderer, "setup", source) + if (!claim.active) return + host.lifecycle.onDispose(() => claim.release()) + const send = () => { + const report = buildSetupReport({ + opencode: host.version, + directory: host.state.path.directory, + claims: claimedFeatures(host.renderer), + bays: baysRead(), + }) + host.log.info("setup: brief", { + loaded: report.loaded.map((each) => each.bay), + notices: report.notices.length, + }) + briefAgent(host, setupBrief(report), "Cockpit setup") + } + host.keymap.registerLayer({ + commands: [ + { + name: "cockpit.setup", + title: "Ask the agent to set up Cockpit", + desc: "which bays show, where, in what order", + category: "Cockpit", + namespace: "palette", + slashName: SETUP_SLASH, + run: send, + }, + ], + } as never) +} diff --git a/packages/client/test/setup.test.ts b/packages/client/test/setup.test.ts new file mode 100644 index 00000000..cf3740e0 --- /dev/null +++ b/packages/client/test/setup.test.ts @@ -0,0 +1,319 @@ +import { describe, expect, test } from "bun:test" +import { claimedFeatures, claimFeature } from "../src/feature.ts" +import type { Host } from "../src/host.ts" +import { baySettings, baysRead, SHARED_DEFAULTS, SIDEBAR_BAYS } from "../src/settings.ts" +import { + briefAgent, + buildSetupReport, + HOST_BLOCKS, + type ReportInput, + readHostFile, + registerSetup, + setupBrief, +} from "../src/setup.ts" + +/** + * `/cockpit-setup` sends this to the agent. What is tested is its structure and the facts in it — + * which bays, which files, which names to fix, which defaults — not its prose. + */ + +const GLOBAL = "/home/me/.config/opencode-cockpit/config.json" +const PROJECT = "/work/app/.cockpit.json" + +const bundle = (bays: string[]) => new Map(bays.map((bay) => [bay, "opencode-cockpit"])) + +function brief(files: Record, over: Partial = {}) { + const report = buildSetupReport({ + opencode: 2, + directory: "/work/app", + claims: bundle(["status", "subagents", "shell", "trail", "review", "updater", "trust"]), + env: {}, + home: "/home/me", + read: (path) => { + const file = files[path] + return file === undefined ? undefined : typeof file === "string" ? file : JSON.stringify(file) + }, + ...over, + }) + return { report, text: setupBrief(report) } +} + +const headings = (text: string) => text.split("\n").filter((line) => line.startsWith("## ")) + +describe("the brief's shape", () => { + test("its sections, in order, with nothing to fix", () => { + expect(headings(brief({}).text)).toEqual([ + "## Where it stands", + "## The settings", + "## OpenCode's own sidebar blocks", + "## Ask me first, one question at a time", + "## Then", + ]) + }) + + test("old names get a section of their own, first after where it stands", () => { + const { text } = brief({ [PROJECT]: { statusline: { preset: "minimal" }, sidebar: ["shells"] } }) + expect(headings(text).slice(0, 4)).toEqual([ + "## Where it stands", + "## Fix these first", + "## Also not read", + "## The settings", + ]) + }) + + test("it ends by asking before anything is edited", () => { + expect(brief({}).text.trimEnd().endsWith("Ask me what I want before you edit anything.")).toBe(true) + }) + + test("the questions are numbered without gaps, whichever bays are loaded", () => { + const { text } = brief({}, { claims: bundle(["shell"]) }) + const numbers = text.match(/^\d+\. /gm)?.map((n) => Number.parseInt(n, 10)) + expect(numbers).toEqual(numbers?.map((_, at) => at + 1)) + expect(text).not.toContain("trust.sidebar") + expect(text).not.toContain("(`status.sidebar`)") + }) +}) + +describe("where it stands", () => { + test("names the bays this window loaded and where from, and the ones it did not", () => { + const { text } = brief( + {}, + { + claims: new Map([ + ["shell", "@opencode-cockpit/shell"], + ["setup", "x"], + ]), + }, + ) + expect(text).toContain("Loaded in this window (from @opencode-cockpit/shell):") + expect(text).toContain(" - `shell`:") + expect(text).toMatch(/Not installed: status, subagents, trail, trust, review, updater\./) + expect(text).not.toContain("`setup`") + }) + + test("both files, and whether each exists", () => { + const { text } = brief({ [PROJECT]: "{}" }) + expect(text).toContain(` - global: \`${GLOBAL}\` (does not exist yet)`) + expect(text).toContain(` - project: \`${PROJECT}\` (exists)`) + }) + + test("a file that will not parse says so", () => { + expect(brief({ [GLOBAL]: "{ nope" }).text).toMatch( + /global: .* \(unreadable: .*; the whole file is ignored/, + ) + }) + + test("what is written, merged, as JSON — or that nothing is", () => { + expect(brief({}).text).toContain("Nothing is written in either file") + const { text } = brief({ + [GLOBAL]: { shell: { dockHeight: 20 } }, + [PROJECT]: { shell: { colors: false } }, + }) + expect(text).toContain('"dockHeight": 20') + expect(text).toContain('"colors": false') + }) +}) + +describe("what to fix", () => { + test("every old name, with the name to write instead", () => { + const { text } = brief({ [PROJECT]: { statusline: {}, subagents: { hideNestedAfter: 5 } } }) + expect(text).toContain("`statusline` is no longer read. Write it as `status`.") + expect(text).toContain( + "`subagents.hideNestedAfter` is no longer read. Write it as `subagents.hideNestedAfterSeconds`.", + ) + expect(text).toContain('fix every old name under "Fix these first"') + }) + + test("a bay's old number for its place points at the list, not at a new name to copy it to", () => { + expect(brief({ [PROJECT]: { shell: { sidebarOrder: 170 } } }).text).toContain( + "`shell.sidebarOrder` is no longer read. Remove it; the order is the top-level `sidebar` list.", + ) + }) + + test("notices from a bay's plugin options are in it too", () => { + baySettings("review", {}, { options: { sidebarOrder: 3 }, settings: brief({}).report.settings }) + const { text } = brief({}, { bays: baysRead() }) + expect(text).toContain("`plugin options`: `sidebarOrder` is no longer read.") + }) +}) + +describe("the settings, from the loader's own defaults", () => { + test("every sidebar bay's block defaults, from SHARED_DEFAULTS", () => { + const { text } = brief({}) + for (const bay of SIDEBAR_BAYS) { + const d = SHARED_DEFAULTS[bay] + expect(text).toContain(`| ${bay} | ${d.sidebar} | ${d.sidebarRows} | ${d.hideWhenEmpty} |`) + } + }) + + test("a loaded bay's own keys come from the defaults it handed baySettings", () => { + baySettings( + "shell", + { dockHeight: 14, defaultView: "screen", watch: {} }, + { settings: brief({}).report.settings }, + ) + const { text } = brief({}, { bays: baysRead() }) + expect(text).toMatch(/- `shell`: .*`dockHeight` 14, `defaultView` "screen", `watch` unset/) + }) + + test("a bay that is not loaded has no own-keys line", () => { + baySettings("trust", { threshold: 3 }, { settings: brief({}).report.settings }) + expect(brief({}, { claims: bundle(["shell"]), bays: baysRead() }).text).not.toContain("- `trust`:") + }) +}) + +describe("OpenCode's own blocks", () => { + const V1 = "/home/me/.config/opencode/tui.json" + const V2 = "/home/me/.config/opencode/cli.json" + + test("reads the switches each version writes", () => { + expect( + readHostFile( + 1, + V1, + JSON.stringify({ plugin_enabled: { "internal:sidebar-todo": false, other: false } }), + ).blocks, + ).toEqual({ "internal:sidebar-todo": false }) + expect(readHostFile(2, V2, '{ "plugins": ["x", "-opencode.sidebar.context"] }').blocks).toEqual({ + "opencode.sidebar.context": false, + }) + expect(readHostFile(2, V2, "{ nope").error).toBeTruthy() + }) + + test("suggests turning Context off when Status draws in the sidebar, in each version's syntax", () => { + expect(brief({}, { opencode: 1 }).text).toContain( + `\`"plugin_enabled": { "${HOST_BLOCKS[1].context}": false }\``, + ) + expect(brief({}).text).toContain(`add \`"-${HOST_BLOCKS[2].context}"\` to the \`"plugins"\` list`) + }) + + test("no suggestion once it is off, or when Status is at the bottom", () => { + const off = brief({ [V2]: { plugins: ["-opencode.sidebar.context"] } }).text + expect(off).toContain(`Context (\`opencode.sidebar.context\`): off (set in \`${V2}\`)`) + expect(off).not.toContain("Suggest turning it off") + expect(brief({ [PROJECT]: { status: { sidebar: false } } }).text).not.toContain("Suggest turning it off") + }) + + test("Todo is never offered off; when it is off, turning it back on is offered", () => { + const on = brief({}, { opencode: 1 }).text + expect(on).toContain("Never suggest turning it off") + const off = brief({ [V1]: { plugin_enabled: { "internal:sidebar-todo": false } } }, { opencode: 1 }).text + expect(off).toContain("offer to turn it back on") + expect(off).toContain('`"plugin_enabled": { "internal:sidebar-todo": true }`') + }) + + test("OpenCode 2 has no LSP or Todo block to talk about", () => { + const { text } = brief({}) + expect(text).not.toContain("internal:sidebar-lsp") + expect(text).toContain("OpenCode 2 draws no LSP or Todo block") + }) +}) + +/* ─── the command ───────────────────────────────────────────────────────────────────────────── */ + +interface Fake { + host: Host + layers: unknown[] + toasts: string[] + calls: string[] +} + +function fakeHost(over: { version?: 1 | 2; route?: Host["route"]["current"]; status?: string } = {}): Fake { + const layers: unknown[] = [] + const toasts: string[] = [] + const calls: string[] = [] + const host = { + version: over.version ?? 2, + renderer: {}, + state: { path: { directory: "/work/app", worktree: "/work/app" } }, + route: { current: over.route ?? { name: "home" } }, + ui: { toast: (toast: { message: string }) => toasts.push(toast.message) }, + keymap: { + registerLayer: (layer: unknown) => { + layers.push(layer) + return () => {} + }, + }, + lifecycle: { onDispose: () => {} }, + log: { info: () => {}, warn: () => {} }, + ...(over.version === 1 + ? { + v1: { + client: { + tui: { + appendPrompt: async ({ text }: { text: string }) => + void calls.push(`append ${text.length > 0}`), + submitPrompt: async () => void calls.push("submit"), + }, + }, + }, + } + : { + v2: { + ui: { + router: { + navigate: (route: { sessionID: string }) => calls.push(`navigate ${route.sessionID}`), + }, + }, + data: { + session: { + status: () => over.status ?? "idle", + create: () => { + calls.push("create") + return { id: "ses_new", request: Promise.resolve() } + }, + prompt: async (input: { sessionID: string; delivery?: string }) => + void calls.push(`prompt ${input.sessionID} ${input.delivery ?? "default"}`), + }, + }, + }, + }), + } as unknown as Host + return { host, layers, toasts, calls } +} + +const settle = () => new Promise((done) => setTimeout(done, 5)) + +describe("registerSetup", () => { + test("the first entry in a window registers the command; the rest do not", () => { + const a = fakeHost() + registerSetup(a.host, "opencode-cockpit") + registerSetup({ ...a.host } as Host, "@opencode-cockpit/shell") + expect(a.layers).toHaveLength(1) + const command = ( + a.layers[0] as { commands: { slashName: string; category: string; namespace: string }[] } + ).commands[0] + expect(command).toMatchObject({ slashName: "cockpit-setup", category: "Cockpit", namespace: "palette" }) + }) + + test("the bays it lists are the window's claims, read when it runs", () => { + const { host } = fakeHost() + claimFeature(host.renderer, "shell", "@opencode-cockpit/shell") + expect([...claimedFeatures(host.renderer).keys()]).toEqual(["shell"]) + }) +}) + +describe("briefAgent, from every state", () => { + test("OpenCode 2 at home: a conversation is made, opened, and briefed", async () => { + const fake = fakeHost() + briefAgent(fake.host, "brief", "Cockpit setup") + await settle() + expect(fake.calls).toEqual(["create", "navigate ses_new", "prompt ses_new default"]) + expect(fake.toasts).toEqual(["Briefed the agent."]) + }) + + test("OpenCode 2, agent busy: queued behind the running turn, not steered into it", async () => { + const fake = fakeHost({ route: { name: "session", params: { sessionID: "ses_1" } }, status: "running" }) + briefAgent(fake.host, "brief", "Cockpit setup") + await settle() + expect(fake.calls).toEqual(["prompt ses_1 queue"]) + }) + + test("OpenCode 1: into the prompt and submitted, which starts or queues a turn itself", async () => { + const fake = fakeHost({ version: 1 }) + briefAgent(fake.host, "brief", "Cockpit setup") + await settle() + expect(fake.calls).toEqual(["append true", "submit"]) + expect(fake.toasts).toEqual(["Briefed the agent."]) + }) +}) From 5f6a9d33004e98f093735bdd8460bd9a8fb85c1d Mon Sep 17 00:00:00 2001 From: Codestz Date: Sat, 3 Oct 2026 11:13:28 -0500 Subject: [PATCH 29/90] Review: settings notices as a ! row in the pane, in place of the header's rule Co-Authored-By: Claude Opus 5.5 --- packages/review/src/cli/preview.ts | 9 +++++ packages/review/src/core/view/chrome.ts | 18 ++++++++- packages/review/src/core/view/layout.ts | 6 ++- packages/review/src/core/view/state.ts | 6 +++ packages/review/src/tui/index.tsx | 5 ++- packages/review/src/tui/panel/paint.ts | 3 ++ packages/review/test/view/settings.test.ts | 45 ++++++++++++++++++++++ 7 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 packages/review/test/view/settings.test.ts diff --git a/packages/review/src/cli/preview.ts b/packages/review/src/cli/preview.ts index af129d64..9f2e2698 100755 --- a/packages/review/src/cli/preview.ts +++ b/packages/review/src/cli/preview.ts @@ -32,6 +32,7 @@ if (args.includes("--help")) { --height rows (default: this terminal) --diff the cursor in the diff pane rather than the file list --keys the [?] Keys screen + --settings n settings notices: the ! row under the header (1 or 2) `) process.exit(0) } @@ -133,6 +134,14 @@ const rows = layout( waiting, ...(fixture.looks ? { looks: fixture.looks } : {}), ...(args.includes("--keys") ? { keys: true } : {}), + ...(args.includes("--settings") + ? { + settings: [ + 'settings: "review.sidebarOrder" is no longer read — run /cockpit-setup', + 'settings: "review.source" should be a string; the default is used', + ].slice(0, Number(flag("settings")) || 1), + } + : {}), ...(args.includes("--diff") ? { pane: "diff" as const } : {}), }, { width, height }, diff --git a/packages/review/src/core/view/chrome.ts b/packages/review/src/core/view/chrome.ts index bf33fa3a..516f0b27 100644 --- a/packages/review/src/core/view/chrome.ts +++ b/packages/review/src/core/view/chrome.ts @@ -6,7 +6,7 @@ * trouble, the performance numbers, a selection — *replaces* what is there rather than adding to it. */ -import { closeHint, fitHints, GLYPH, HINT_GAP, type Hint } from "@opencode-cockpit/client/design" +import { closeHint, fitHints, GLYPH, HINT_GAP, type Hint, warnRows } from "@opencode-cockpit/client/design" import { type ChangeSet, progress, type Review } from "../model/review.ts" import type { Columns } from "./geometry.ts" import { clipRuns, type Fill, type Row, type Run, rowWidth } from "./rows.ts" @@ -70,6 +70,22 @@ export function headerRows(changes: ChangeSet, review: Review, width: number, la ] } +/** + * The settings notices as the one row under the header, in place of its rule — the header stays two + * rows, so nothing below it moves. The shared `!` row (`warnRows`) cut to one line; with several, + * how many comes first, so a narrow pane still says there is more than the one it shows. + */ +export function settingsRow(notices: readonly string[], width: number): Row | undefined { + const [first] = notices + if (first === undefined) return undefined + const text = + notices.length === 1 + ? first + : `settings: ${notices.length} to fix, run /cockpit-setup — ${first.replace(/^settings: /, "")}` + const [row = []] = warnRows(text, width, 1) + return { runs: row.map((run) => ({ text: run.text, ...(run.tone ? { tone: run.tone } : {}) })) } +} + /** * The way to every key the row had no room for. Ranked just under the way out, so a narrow row keeps * it and gives up `[w] Width` first: one key that leads to all of them beats one more of them. diff --git a/packages/review/src/core/view/layout.ts b/packages/review/src/core/view/layout.ts index 94152393..6c214ae2 100644 --- a/packages/review/src/core/view/layout.ts +++ b/packages/review/src/core/view/layout.ts @@ -14,7 +14,7 @@ import type { ChangeSet, Review } from "../model/review.ts" import { metrics } from "../perf.ts" -import { footerRows, headerRows } from "./chrome.ts" +import { footerRows, headerRows, settingsRow } from "./chrome.ts" import { type Columns, FOOTER_ROWS, GUTTER, HEADER_ROWS, inset, splitColumns, window } from "./geometry.ts" import { keyRows } from "./keys.ts" import { fileRows, listScroll, listWidth } from "./list.ts" @@ -56,8 +56,10 @@ function compose(changes: ChangeSet, review: Review, state: ViewState, viewport: /** The rule spans the pane, edge to edge; everything with words in it sits inside the gutter. */ const rule: Row = { runs: [{ text: "─".repeat(inner), tone: "border" }] } + /** A setting Review does not read takes the rule's place under the header, so nothing moves. */ + const warning = settingsRow(state.settings ?? [], content) const rows: Row[] = headerRows(changes, review, content, state.label).map((row, index) => - index === HEADER_ROWS - 1 ? rule : inset(row, inner), + index === HEADER_ROWS - 1 ? (warning ? inset(warning, inner) : rule) : inset(row, inner), ) const body = Math.max(1, viewport.height - HEADER_ROWS - FOOTER_ROWS) diff --git a/packages/review/src/core/view/state.ts b/packages/review/src/core/view/state.ts index d3ba366b..c03b954f 100644 --- a/packages/review/src/core/view/state.ts +++ b/packages/review/src/core/view/state.ts @@ -83,6 +83,12 @@ export interface ViewState { * use than a row of keys you can get back with `?`. */ notice?: string + /** + * Settings Review does not read — an old name, a value of the wrong kind — each a sentence + * (`noticeText`). Review has no sidebar block to say them in, so the pane does: one `!` row in + * place of the header's rule, for as long as the file is wrong, not a toast gone in ten seconds. + */ + settings?: readonly string[] /** The numbers, when you have asked to see them. Same place, same reasoning. */ stats?: readonly Run[] /** diff --git a/packages/review/src/tui/index.tsx b/packages/review/src/tui/index.tsx index 41ebecf6..d8f7866a 100644 --- a/packages/review/src/tui/index.tsx +++ b/packages/review/src/tui/index.tsx @@ -75,8 +75,8 @@ export function createReviewTui({ source = REVIEW_PACKAGE }: { source?: string } return } /** - * Review draws no sidebar block for a `!` row, so its settings notices are said once, as the - * session starts, and kept in the log. + * Review draws no sidebar block, so its settings notices are a `!` row in the pane (in place of + * the header's rule, `chrome.ts`), said once more as the session starts, and kept in the log. */ for (const notice of notices) api.log.warn("review: settings", { file: notice.file, notice: notice.text }) if (notices.length > 0) @@ -164,6 +164,7 @@ export function createReviewTui({ source = REVIEW_PACKAGE }: { source?: string } notice, boxes: () => ({ backdrop, panel, pool }), looks: () => looks.current(), + settings: notices.map(noticeText), }) /** `o`: the old side out of git, uncapped — a viewer needs the whole file, not its header. */ diff --git a/packages/review/src/tui/panel/paint.ts b/packages/review/src/tui/panel/paint.ts index c563d606..b43a2fd0 100644 --- a/packages/review/src/tui/panel/paint.ts +++ b/packages/review/src/tui/panel/paint.ts @@ -40,6 +40,8 @@ export interface PaintDeps { boxes: () => Boxes /** What is known about the images under review, beyond their headers. */ looks: () => ReadonlyMap + /** Settings Review does not read, as sentences: the `!` row under the header. */ + settings?: readonly string[] } /** How long something the review said stays in the footer. */ @@ -121,6 +123,7 @@ export function createPainter(deps: PaintDeps): Painter { ...(away.length > 0 ? { elsewhere: away } : {}), looks: deps.looks(), canvas: packed(behind), + ...(deps.settings?.length ? { settings: deps.settings } : {}), /** Trouble outranks the numbers; both outrank the keys, and the footer stays two rows. */ ...(trouble ? { notice: trouble } diff --git a/packages/review/test/view/settings.test.ts b/packages/review/test/view/settings.test.ts new file mode 100644 index 00000000..d6aaac1d --- /dev/null +++ b/packages/review/test/view/settings.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test" +import { emptyReview } from "../../src/core/model/review.ts" +import { settingsRow } from "../../src/core/view/chrome.ts" +import { HEADER_ROWS } from "../../src/core/view/geometry.ts" +import { layout } from "../../src/core/view/layout.ts" +import type { Row } from "../../src/core/view/rows.ts" + +/** + * Review has no sidebar block, so a setting it does not read was a toast gone in ten seconds. It is + * now the shared `!` row in the pane, in place of the header's rule — and the header stays two rows, + * so a click still lands on the row drawn under it. + */ + +const changes = { + source: "worktree" as const, + files: [{ path: "src/a.ts", before: "a\n", after: "a\nb\n", additions: 1, deletions: 0 }], +} +const OLD = 'settings: "review.sidebarOrder" is no longer read — run /cockpit-setup' +const text = (row: Row | undefined) => row?.runs.map((run) => run.text).join("") ?? "" +const draw = (settings?: string[]) => + layout( + changes, + emptyReview(), + { context: 3, ...(settings ? { settings } : {}) }, + { width: 120, height: 20 }, + ) + +describe("settings notices in the pane", () => { + test("one notice: `!` in the warning tone, then its sentence, under the header", () => { + const row = draw([OLD])[HEADER_ROWS - 1] + expect(text(row).trim()).toBe(`! ${OLD}`) + expect(row?.runs.find((run) => run.text.includes("!"))?.tone).toBe("warning") + }) + + test("none: the rule is back, and the pane is the same height either way", () => { + expect(text(draw()[HEADER_ROWS - 1])).toMatch(/^─+$/) + expect(draw([OLD])).toHaveLength(draw().length) + }) + + test("several: how many comes first, so a narrow pane still says there is more", () => { + const row = settingsRow([OLD, 'settings: "review.source" should be a string'], 50) + expect(text(row)).toStartWith("! settings: 2 to fix, run /cockpit-setup") + expect(text(row)).toHaveLength(50) + }) +}) From de3de32b960701cf65c7a892673383d75e8a878b Mon Sep 17 00:00:00 2001 From: Codestz Date: Sat, 3 Oct 2026 11:18:59 -0500 Subject: [PATCH 30/90] =?UTF-8?q?Status:=20/status-setup=20briefs=20throug?= =?UTF-8?q?h=20client's=20briefAgent=20=E2=80=94=20from=20home=20it=20open?= =?UTF-8?q?s=20a=20conversation,=20behind=20a=20reply=20it=20queues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- packages/status/src/tui/index.tsx | 31 ++++++------------------------- 1 file changed, 6 insertions(+), 25 deletions(-) diff --git a/packages/status/src/tui/index.tsx b/packages/status/src/tui/index.tsx index bb08eed1..1223cb29 100644 --- a/packages/status/src/tui/index.tsx +++ b/packages/status/src/tui/index.tsx @@ -2,6 +2,7 @@ import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client/feature" import { dualTui, type Host } from "@opencode-cockpit/client/host" +import { briefAgent } from "@opencode-cockpit/client/setup" import type { BoxRenderable } from "@opentui/core" import { createMemo } from "solid-js" import pkg from "../../package.json" with { type: "json" } @@ -13,7 +14,7 @@ import { fit, fitColumn } from "../core/render.ts" import { buildReport } from "../core/report.ts" import { buildSegments, type SegmentDef } from "../core/segments.ts" import { StatusLine } from "./components/statusline.tsx" -import { buildContext, currentSession } from "./state/snapshot.ts" +import { buildContext } from "./state/snapshot.ts" import { createStatusStore } from "./state/store.ts" const STATUS_PACKAGE = "@opencode-cockpit/status" @@ -170,31 +171,11 @@ export function createStatusTui({ source = STATUS_PACKAGE }: { source?: string } * text the user has to scroll past to type their own sentence, and it ends by asking what they * want anyway — so the agent is the right place for it to land. * - * On the next tick, because running a slash command clears the prompt it was typed into: - * writing during the command itself is wiped a moment later, which looks exactly like a - * command that did nothing. + * `briefAgent` sends it on the next tick (a slash command clears the prompt it was typed into), + * queues it behind a reply in progress, and from home opens a conversation for it — the same + * path as /cockpit-setup. */ - setTimeout(() => { - const failed = () => { - api.log.warn("status: could not reach the prompt") - api.ui.toast({ variant: "error", title: "Status", message: "could not reach the prompt" }) - } - if (api.v1) { - const tui = api.v1.client.tui - void tui - .appendPrompt({ text }) - .then(() => tui.submitPrompt()) - .catch(failed) - return - } - /** OpenCode 2: straight to the conversation on screen, there being no prompt to fill. */ - const session = currentSession(api) - if (!session) { - api.ui.toast({ title: "Status", message: "Open a conversation first." }) - return - } - void api.promptSession(session, text).catch(failed) - }, 0) + briefAgent(api, text, "Status") } api.keymap.registerLayer({ From 9169d3806e74d854b97b96c95cb5e7e08cb31ba3 Mon Sep 17 00:00:00 2001 From: Codestz Date: Sat, 3 Oct 2026 11:19:25 -0500 Subject: [PATCH 31/90] Status docs: OpenCode 2 names its Context block opencode.sidebar.context; never suggest turning Todo off Co-Authored-By: Claude Opus 5.5 --- packages/status/README.md | 17 ++++++++++++----- .../status/skills/statusline-design/SKILL.md | 10 +++++++--- packages/status/src/core/config.ts | 3 ++- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/status/README.md b/packages/status/README.md index c8f0e1d6..7f6c75bf 100644 --- a/packages/status/README.md +++ b/packages/status/README.md @@ -236,20 +236,27 @@ behind a proxy — see [Proxies](#proxies-litellm-and-friends). ## Replacing OpenCode's own sidebar blocks -Each block of OpenCode's sidebar is an internal plugin, and `tui.json` can switch one off: +Each block of OpenCode's sidebar is an internal plugin that its config can switch off. The names +differ by version: ```jsonc -// ~/.config/opencode/tui.json +// OpenCode 1 — ~/.config/opencode/tui.json { "plugin": ["@opencode-cockpit/status"], "plugin_enabled": { "internal:sidebar-context": false } } ``` +```jsonc +// OpenCode 2 — ~/.config/opencode/cli.json +{ "plugins": ["@opencode-cockpit/status", "-opencode.sidebar.context"] } +``` + That removes the host's own `Context / tokens / % used / spent` block, leaving the space to the -table — the honest way to avoid reading the same figure twice. The same works -for `internal:sidebar-files`, `-todo`, `-lsp`, `-mcp`, `-footer`, and the home screen's -`internal:home-footer` and `internal:home-tips`. +table — the honest way to avoid reading the same figure twice. `/cockpit-setup` offers it when Status +draws in the sidebar. On OpenCode 1 the same works for `internal:sidebar-files`, `-lsp`, `-mcp`, +`-footer`, and the home screen's `internal:home-footer` and `internal:home-tips`; OpenCode 2's sidebar +has no LSP or Todo block. Leave the Todo block on: nothing in Cockpit replaces it. ## What you can draw diff --git a/packages/status/skills/statusline-design/SKILL.md b/packages/status/skills/statusline-design/SKILL.md index c66d0fbd..add6d1f1 100644 --- a/packages/status/skills/statusline-design/SKILL.md +++ b/packages/status/skills/statusline-design/SKILL.md @@ -110,14 +110,18 @@ truncated; a track *was* being drawn and `panel` was the panel's own colour. ## Turning OpenCode's own blocks off -Each block of the host's sidebar is an internal plugin, and `tui.json` disables any of them: +Each block of the host's sidebar is an internal plugin that its config disables. OpenCode 1, in +`tui.json`: ```jsonc { "plugin": ["opencode-cockpit"], "plugin_enabled": { "internal:sidebar-context": false } } ``` -`internal:sidebar-{context,files,todo,lsp,mcp,footer}`, `internal:home-{footer,tips}`, -`internal:notifications`. **A sidebar meant to replace the Context block must carry what that block +OpenCode 2, in `cli.json`: `{ "plugins": ["opencode-cockpit", "-opencode.sidebar.context"] }` +(`-internal:sidebar-context` does nothing there, and it has no LSP or Todo block). + +OpenCode 1 names: `internal:sidebar-{context,files,todo,lsp,mcp,footer}`, `internal:home-{footer,tips}`, +`internal:notifications`. Never suggest turning the Todo block off: nothing in Cockpit replaces it. **A sidebar meant to replace the Context block must carry what that block carried** — percentage, token total, spend — or the user ends up with less than before. The footer under the prompt is core UI: it cannot be hidden. Design around it. diff --git a/packages/status/src/core/config.ts b/packages/status/src/core/config.ts index 5ca0722b..fdf6814c 100644 --- a/packages/status/src/core/config.ts +++ b/packages/status/src/core/config.ts @@ -291,7 +291,8 @@ export const DEFAULT_SEPARATOR = " │ " * 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 OpenCode's `tui.json`. + * `{ "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", From e8d19f7a992c52613b67d854dd5e1cca7015ae2a Mon Sep 17 00:00:00 2001 From: Codestz Date: Sat, 3 Oct 2026 12:15:23 -0500 Subject: [PATCH 32/90] Smoke: Trail installed and checked, /cockpit-setup and /status-setup, none yet in every block, Subagents' keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Trail packed and loaded; the palette finds "cockpit trail" and /trail opens empty from home. - Subagent rows match a shortened agent name (exp…); the pane's [?] Keys and its KEYS screen. - The flake "enter never opened the selected item": k waits on an item, enter on the cursor. - Status reads the status section; the global file's old statusline is a ! row; /statusline and /status-setup run in the conversation (AGENT=1), and both slash names are offered. - /cockpit-setup: in the palette and the popup; Review's old sidebarOrder is a ! row in the pane; AGENT=1 runs a second OpenCode in a fresh project where /cockpit-setup from home opens a conversation and briefs it, queues while the agent answers, and the sidebar shows the Status table with its tokens and old-name row, then Subagents, Shells and Trail with none yet. - AGENT=1 also runs Trail's measurement against the installed server half. Co-Authored-By: Claude Opus 5.5 --- scripts/tui-smoke.ts | 282 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 254 insertions(+), 28 deletions(-) diff --git a/scripts/tui-smoke.ts b/scripts/tui-smoke.ts index 4179cc62..b868b550 100644 --- a/scripts/tui-smoke.ts +++ b/scripts/tui-smoke.ts @@ -105,7 +105,8 @@ function agentTurn(env: Record) { const cols = Number(process.env.SMOKE_COLS) || 150 const rows = 40 -const term = new Terminal({ cols, rows, allowProposedApi: true }) +/** One per OpenCode started: the second run (AGENT=1) draws on a clean screen of its own. */ +let term = new Terminal({ cols, rows, allowProposedApi: true }) const screen = async () => { await new Promise((done) => term.write("", done)) const buffer = term.buffer.active @@ -114,6 +115,49 @@ const screen = async () => { (_, y) => buffer.getLine(buffer.baseY + y)?.translateToString(true) ?? "", ).join("\n") } +/** The sidebar's half of a screen. */ +const rightHalf = (text: string) => + text + .split("\n") + .map((line) => line.slice(Math.floor(cols / 2))) + .join("\n") +/** 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 — shortened to `explo…` + * or `exp…` when the title wants the room. + */ +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 last line of `/cockpit-setup`'s brief: once it is on screen, the whole brief arrived. */ +const SETUP_BRIEF_END = "Ask me what I want before you edit anything." +/** `/status-setup` sent its brief: its toast, or the brief's last line in the conversation. */ +const STATUS_BRIEFED = /Briefed the agent\.|Ask me what I want it to show/ +/** A prompt sent while the agent answers, waiting its turn: OpenCode 1's tag, OpenCode 2's line. */ +const QUEUED = /QUEUED|1 queued · Help me set up opencode-cockpit/ try { run(["bun", "run", "build"], root) @@ -136,6 +180,7 @@ try { "@opencode-cockpit/updater": file("opencode-cockpit-updater-"), "@opencode-cockpit/subagents": file("opencode-cockpit-subagents-"), "@opencode-cockpit/trust": file("opencode-cockpit-trust-"), + "@opencode-cockpit/trail": file("opencode-cockpit-trail-"), }, overrides: { "@opencode-cockpit/protocol": file("opencode-cockpit-protocol-"), @@ -161,8 +206,16 @@ try { // The plugins must live under node_modules: that is what disables OpenCode's Solid transform. const bay = (name: string) => join(install, "node_modules", "@opencode-cockpit", name) - const tuiBays = [bay("shell"), bay("status"), bay("review"), bay("updater"), bay("subagents"), bay("trust")] - const serverBays = [bay("shell"), bay("review"), bay("subagents")] + const tuiBays = [ + bay("shell"), + bay("status"), + bay("review"), + bay("updater"), + bay("subagents"), + bay("trail"), + bay("trust"), + ] + const serverBays = [bay("shell"), bay("review"), bay("subagents"), bay("trail")] /** * v1 reads `plugin` from opencode.json and tui.json; v2 reads `plugins` from opencode.json and * cli.json (docs/opencode/v2.md). The same packages go in either way. @@ -185,6 +238,13 @@ try { [key]: plugins, /** A model that needs no key, for the turns AGENT=1 runs inside the interface. */ ...(process.env.AGENT && name === "opencode.json" ? { model: "opencode/space-bunny-free" } : {}), + /** + * The setup briefs send the agent to read Cockpit's config, outside the project: a prompt it + * would wait on forever here. OpenCode 2 is started with `--auto` for the same reason. + */ + ...(process.env.AGENT && !v2 && name === "opencode.json" + ? { permission: { external_directory: "allow" } } + : {}), }), ) } @@ -192,11 +252,18 @@ try { * A statusline whose value has to come from somewhere the plugin cannot fake: a literal marker * proves the line drew at all, and a command segment proves the whole pipeline -- spawn, parse, * repaint -- works from a published build. + * + * The global file carries the section's name from before 0.9, `statusline`: it is no longer read, + * and Status has to say so in a `!` row instead of drawing as if nothing had been written. */ + await Bun.write( + join(config, "opencode-cockpit", "config.json"), + JSON.stringify({ statusline: { preset: "minimal" } }), + ) await Bun.write( join(project, ".cockpit.json"), JSON.stringify({ - statusline: { + status: { surface: "bottom", segments: [ { type: "text", value: "STATUSLINE-DREW" }, @@ -204,6 +271,8 @@ try { ], commands: { smoke: { run: "printf 'COMMAND-RAN'", intervalMs: 250 } }, }, + /** An old name in Review's section: the pane has to say so in a `!` row. */ + review: { sidebarOrder: 3 }, }), ) @@ -216,17 +285,22 @@ try { TERM: "xterm-256color", } /** v2 would attach to the user's background service; a private server keeps the run to itself. */ - const proc = Bun.spawn(v2 ? [opencode, "--standalone"] : [opencode], { - cwd: project, - env, - terminal: { - cols, - rows, - data: (_t: unknown, chunk: Uint8Array) => term.write(chunk.slice()), - }, - } as Parameters[1]) as ReturnType & { - terminal: { write(data: string): void } + const launch = (cwd: string, args: string[] = []) => { + const screenOf = new Terminal({ cols, rows, allowProposedApi: true }) + term = screenOf + return Bun.spawn([opencode, ...(v2 ? ["--standalone"] : []), ...args], { + cwd, + env, + terminal: { + cols, + rows, + data: (_t: unknown, chunk: Uint8Array) => screenOf.write(chunk.slice()), + }, + } as Parameters[1]) as ReturnType & { + terminal: { write(data: string): void } + } } + let proc = launch(project) const type = async (keys: string, waitMs: number) => { proc.terminal.write(keys) await Bun.sleep(waitMs) @@ -320,11 +394,13 @@ try { const found: [string, string][] = [] for (const [name, title] of [ ["shell", "Start a background shell"], - ["status", "Ask the agent to customise the statusline"], + ["status", "Ask the agent to set up the status bay"], ["review", "Open or close the changes"], ["updater", "Update plugins"], ["subagents", "Open the subagents"], + ["trail", "Show what this conversation made"], ["trust", "Show what Trust answers for you"], + ["setup", "Ask the agent to set up Cockpit"], ] as const) { await type("\x10", 1000) await type(`cockpit ${name}`, 1500) @@ -345,6 +421,9 @@ try { await type("\x1b", 1200) const ranUpdater = await palette("Update plugins", 4000) await type("\x1b", 1200) + /** From home there is no conversation, so Trail opens on the project's records: none, and says so. */ + const ranTrail = await palette("Show what this conversation made", 2500) + await type("\x1b", 1200) /** * The updater's dialog, opened by its slash name and by the old one it replaced — two slash names @@ -363,7 +442,7 @@ try { ) await type("\r", 1000) let sidebar = "" - for (let i = 0; i < 45 && !/[�○⠋⠙⠹⠸⠼⠴⠦⠧⠇�] explore /.test(sidebar); i++) { + for (let i = 0; i < 45 && !EXPLORE_ROW.test(sidebar); i++) { await Bun.sleep(2000) sidebar = await screen() } @@ -371,17 +450,28 @@ try { /** Found again before every click: the blocks above it (the statusline's) grow as the turn runs. */ const clickSubagent = async () => { const lines = (await screen()).split("\n") - const y = lines.findIndex((line) => /[�○⠋⠙⠹⠸⠼⠴⠦⠧⠇�] explore /.test(line.slice(Math.floor(cols / 2)))) + const right = Math.floor(cols / 2) + const y = lines.findIndex((line) => EXPLORE_ROW.test(line.slice(right))) if (y < 0) throw new Error(`the sidebar never showed the subagent:\n${lines.join("\n")}`) - const x = (lines[y] as string).lastIndexOf(" explore ") + 3 + const x = right + ((lines[y] as string).slice(right).search(EXPLORE_ROW) as number) + 3 proc.terminal.write(`\x1b[<0;${x + 1};${y + 1}M`) await Bun.sleep(80) await type(`\x1b[<0;${x + 1};${y + 1}m`, 2500) } await clickSubagent() const full = await screen() - /** The cursor onto the last item and open it, then a message typed into the pane, not a dialog. */ - await type("k", 600) + /** `?` swaps the run for every key the pane takes, and `?` again brings the run back. */ + await type("?", 1200) + const keys = await screen() + await type("?", 1000) + /** + * The cursor onto the last item and open it, then a message typed into the pane, not a dialog. + * Each key waits on what it needs on screen rather than a fixed time: the run's first item can + * take a while to arrive on a slow turn, and `enter` with no cursor yet opens nothing. + */ + await until(30_000, (text) => /[›⌄◇◆] /.test(rightHalf(text))) + await type("k", 300) + await until(5000, (text) => text.includes("▌ ")) await type("\r", 1200) const toggled = await screen() await type("m", 600) @@ -418,19 +508,94 @@ try { await type("x", 1500) const removed = await screen() await type("q", 1000) - return { sidebar, full, toggled, typing, slashed, finished, pasted, relayed, conversation, removed } + return { sidebar, full, keys, toggled, typing, slashed, finished, pasted, relayed, conversation, removed } } - const slash = async (name: string) => { - await type(`/${name}`, 1200) - await type("\r", 4000) - const drawn = await screen() + /** `ready`: read as soon as it shows, for what does not stay — a toast the next one replaces. */ + const slash = async (name: string, ready?: (text: string) => boolean) => { + await type(`/${name}`, 300) + /** `enter` once the popup offers the name: with the agent busy it can take longer to list. */ + await until(4000, (text) => new RegExp(`/${name}\\s{2,}\\S`).test(text)) + await type("\r", ready ? 0 : 4000) + const drawn = ready ? await until(4000, ready) : await screen() await type("\x1b", 1000) return drawn } const updater = await slash("plugins-update") const subagents = process.env.AGENT ? await subagentsInTheInterface() : undefined + /** + * The setup commands' slash names, offered in the popup as they are typed. Only listed here, not + * run: running one hands a model the brief, 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 new name and its old one, kept for a release as a command of + * its own that says the new name. In the conversation the subagent run left open, last, because + * both hand the agent a brief. The second goes to an agent still busy with the first, so it is + * known by the toast that says it was sent, or the brief's last line, whichever shows. + */ + 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) + const old = await slash("statusline", (text) => text.includes("is now /status-setup")) + await Bun.sleep(3000) + const setup = await slash("status-setup", (text) => STATUS_BRIEFED.test(text)) + return { old, setup } + })() + : 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 brief the agent there (OpenCode 2 used to answer "Open a conversation + * first."), and 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 brief 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 type("/cockpit-setup", 1200) + await type("\r", 0) + const toast = await until(4000, (text) => /Briefed the agent\.|Open a conversation first/.test(text)) + const briefed = await until(20_000, (text) => text.includes(SETUP_BRIEF_END)) + /** Again, while the agent is still answering the first. */ + await type("/cockpit-setup", 1200) + await type("\r", 0) + const queued = await until(6000, (text) => QUEUED.test(text)) + const drawn = await until(180_000, (text) => TOKENS_ROW.test(rightHalf(text))) + await Bun.sleep(3000) + const settled = await screen() + proc.kill("SIGKILL") + return { toast, briefed, queued, drawn: TOKENS_ROW.test(rightHalf(settled)) ? settled : drawn } + } + const fresh = process.env.AGENT ? await presence() : undefined // `SMOKE_SHOW=1 bun run smoke:tui` prints the updater's frame: a marker proves it drew, not how. if (process.env.SMOKE_SHOW) console.log(updater) @@ -446,10 +611,27 @@ try { ] as const) { if (!second.includes(marker)) throw new Error(`${what}:\n${second}`) } + if (!second.includes(`! settings: "statusline" is no longer read`)) + throw new Error(`Status never named the old "statusline" section:\n${second}`) + for (const [name, listed] of Object.entries(popups)) { + if (!listed.includes(name)) throw new Error(`the slash popup never offered ${name}:\n${listed}`) + } + if (status) { + if (!status.old.includes("is now /status-setup")) + throw new Error(`/statusline never said its new name:\n${status.old}`) + if (!STATUS_BRIEFED.test(status.setup)) + throw new Error(`/status-setup never briefed the agent:\n${status.setup}`) + } + + for (const marker of ["Nothing recorded in this project yet.", "[esc] Close"]) { + if (!ranTrail.includes(marker)) + throw new Error(`the palette's Trail never drew "${marker}":\n${ranTrail}`) + } for (const [what, marker] of [ ["the review panel never drew", "review"], ["the review panel drew no diff", "SMOKE-REVIEW"], + ["the review panel never named its old setting", '! settings: "review.sidebarOrder"'], ] as const) { if (!review.includes(marker)) throw new Error(`${what}:\n${review}`) } @@ -484,6 +666,9 @@ try { ["the sidebar never showed the Subagents block", subagents.sidebar, "Subagents"], ["a click on the subagent never opened its full screen", subagents.full, "EXPLORE"], ["the full screen never drew its keys", subagents.full, "[m] Message"], + ["the full screen never offered every key", subagents.full, "[?] Keys"], + ["? never showed every key", subagents.keys, "KEYS"], + ["the keys screen never said how back", subagents.keys, "[esc] Hide Keys"], ["enter never opened the selected item", subagents.toggled, "▌ "], ["m never opened the message input in the pane", subagents.typing, "┃ hello there"], ["/subagents never opened the full screen", subagents.slashed, "[m] Message"], @@ -502,13 +687,24 @@ try { throw new Error(`the main conversation never showed the relayed exchange:\n${subagents.conversation}`) } else console.log("relay not checked: the subagent had not finished when the message was sent") const asked = subagents.removed.includes("Press x again") - const listed = /[�○⠋⠙⠹⠸⠼⠴⠦⠧⠇�] explore /.test(subagents.removed) + const listed = EXPLORE_ROW.test(subagents.removed) if (!asked && listed) throw new Error(`x neither removed the subagent nor asked to stop it:\n${subagents.removed}`) } /** A plugin OpenCode could not load says so in the footer, whichever half it was. */ - for (const text of [first, second, consoleScreen, fullScreen, review, updater, ...Object.values(ran)]) { + for (const text of [ + first, + second, + consoleScreen, + fullScreen, + review, + updater, + ranTrail, + ...Object.values(ran), + ...Object.values(popups), + ...(fresh ? [fresh.drawn] : []), + ]) { if (/plugins? failed/.test(text)) throw new Error(`OpenCode could not load a plugin:\n${text}`) } @@ -521,9 +717,39 @@ try { `the panel froze: still at tick ${firstMax} after 4s (published JSX not Solid-compiled?)\n${second}`, ) } + if (fresh) { + const { toast, briefed, queued, drawn } = fresh + if (process.env.SMOKE_SHOW) console.log(drawn) + if (!toast.includes("Briefed the agent.") || toast.includes("Open a conversation first")) + throw new Error(`/cockpit-setup from home never said it briefed the agent:\n${toast}`) + if (!briefed.includes(SETUP_BRIEF_END)) + throw new Error(`/cockpit-setup's brief never reached the conversation:\n${briefed}`) + 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: Trail's measurement against the installed server half — a turn that opens a PR (with a + * fake `gh`) must end with the agent having recorded it, without being told to. + */ + if (process.env.AGENT) { + const measured = Bun.spawnSync( + ["bun", join(root, "packages/trail/measure/agent.ts"), "--plugin", bay("trail"), "--runs", "1"], + { cwd: root, env: { ...process.env, OPENCODE: opencode }, stdout: "pipe", stderr: "pipe" }, + ) + if (measured.exitCode !== 0) + throw new Error(`Trail's measurement failed:\n${measured.stdout}\n${measured.stderr}`.slice(-3000)) + } console.log( - `tui smoke passed: panel live, tick ${firstMax} → ${secondMax}; console and its keys drew; full screen drew; statusline drew; review drew its diff; updater answered its slash name; every bay found under "cockpit" in the palette, and its commands ran from there${subagents ? "; a subagent showed in the sidebar and opened full screen, by click and by /subagents" : ""}${process.env.AGENT ? "; an agent called both bays' tools and was told about them" : ""}`, + `tui smoke passed: panel live, tick ${firstMax} → ${secondMax}; console and its keys drew; full screen drew; statusline drew and named its old section; review drew its diff and named its old setting; updater answered its slash name; trail opened empty; every bay and /cockpit-setup found under "cockpit" in the palette, and the commands ran from there; /cockpit-setup and /status-setup offered as they were typed${subagents ? "; a subagent showed in the sidebar and opened full screen, by click and by /subagents, with its keys; /statusline and /status-setup briefed the agent" : ""}${fresh ? "; /cockpit-setup from home opened a conversation and briefed it, and queued behind the reply; an empty sidebar said none yet in every block, under the Status table" : ""}${process.env.AGENT ? "; an agent called the bays' tools and was told about them; Trail's measurement recorded the PR" : ""}`, ) } finally { /** KEEP=1 leaves the install and project behind, to inspect what a run actually loaded. */ From 54af472f01a2d42e1b06a0dcf20bfad1f30fb433 Mon Sep 17 00:00:00 2001 From: Codestz Date: Sat, 3 Oct 2026 11:33:16 -0500 Subject: [PATCH 33/90] =?UTF-8?q?CHANGELOG:=200.9.0=20under=20Unreleased?= =?UTF-8?q?=20=E2=80=94=20Trail,=20/cockpit-setup,=20one=20settings=20shap?= =?UTF-8?q?e,=20Status's=20table=20by=20default,=20Review=20images?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 110 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index df5c9fea..fd371d2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,116 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +### Added + +- **Trail — a new bay: what a conversation made.** `@opencode-cockpit/trail`, also in the bundle + (`features.trail: false` to switch it off), on OpenCode 1 and 2 alike. The pull requests, tickets, + pages and deploys the agent created or changed, kept per conversation, grouped by the ticket they + were for, and one click from the page. + - **No setup.** No account, no token, no list of tools. The agent already knows what it just did, + with whatever it uses — `gh`, an MCP server, a company CLI — so it records it with `trail_add` + (`title`, a `url` or a `ref`, and free-text `kind`, `action`, `for`, `note`); the same link again + updates the record (`created → updated`) instead of adding a row. It is told so on every request, + subagents included, and what the conversation produced is rebuilt into its system prompt each + time, so it still knows after a compaction. + - **A safety net, never automatic:** when something the agent *ran* printed a PR or issue link it + never recorded, its next request says so, as a choice. Links in files it read or pages it fetched + are ignored, and nothing is added without the agent or you. + - **In the sidebar,** on by default after Shells: each thing's name, title, system and what this + conversation last did — history, not a status, because a PR's state belongs to GitHub. Click a + row with `↗` to open the page; `+ N more · /trail` opens the rest. + - **`/trail`** (`ctrl+x f`): this conversation, or every conversation in the project (`tab`), with + the conversations that touched each thing under it. `enter` opens the page, `g` goes back to the + conversation that made it, `c` copies the link, `m` the whole trail as markdown, `x` removes, + `/` searches, `a` adds a link the conversation printed but never recorded. + - **`/link [note]`** adds one yourself; a link you add shows in the sidebar at once. + - The system comes from the link (GitHub, Jira, Confluence, Linear, Claude, else the domain); + query parameters that look like secrets are dropped before anything is stored, and only + `http(s)` links are opened. A deleted conversation keeps its records, under the title it had. + - `trail_list` gives the agent the same facts and order as `/trail`. Kept append-only in + `~/.local/share/opencode-cockpit/trail/`, shared by every window on the project. +- **`/cockpit-setup` — the agent sets Cockpit up with you.** It draws nothing: it briefs the agent in + your conversation with the bays this window loaded, the files Cockpit reads and what is in them, + every key with its default, every name from before 0.9 it found, and OpenCode's own sidebar blocks — + then asks which bays you want, sidebar or bottom, in what order, quiet or present when empty, before + it edits anything. From the home screen it opens a conversation for the brief; while the agent is + answering, it waits its turn (`1 queued`) instead of cutting the reply off. One command, whichever + Cockpit packages you installed. +- **Review shows images.** A changed binary is read as bytes instead of being skipped or shown as + `U+FFFD`. An image says what changed — `PNG 2880×1800 · 807 KB → 789 KB` (PNG, APNG, JPEG, GIF, + WebP, BMP; any other binary its sizes) — and PNG and GIF of the same size get a pixel diff (how much + changed, and where) and a preview, before and after side by side in half-block characters with the + changes lit. `o` opens both versions in your system viewer. Decoding runs in slices off the draw + path, so a pair of screenshots never freezes the window. +- **`[?] Keys` in Review and Subagents**, like Trust's: every key the pane takes, and `esc` back. +- **Every sidebar block says it is there.** Shells, Subagents and Trail draw their heading and + `none yet` before anything has run, in the row the first item will take, so a new user can tell + they are installed and the blocks below do not jump. `"hideWhenEmpty": true` brings the silence + back, per bay. +- **Status's sidebar table is built in.** `title`, `in`, `out`, `cache`, `write`, `sep`, `spend`, + `avail` and `git` are built-in segments now, with `context` style `solid` and `tokens` style + `row`, so the table needs no module. `spend` and `avail` read a proxy's budget file and draw nothing + without one; a hairline draws only between two rows. + +### Changed + +- **Settings: one shape, one loader, one file for both halves.** "Configure them in one file, read by + both halves of the plugin and by every project" was true for Shell only; now it is true for every + bay. Every bay reads `~/.config/opencode-cockpit/config.json` and a project's `.cockpit.json` + through the same loader, one section per bay — `status`, `subagents`, `shell`, `trail`, `trust`, + `review`, `updater` — with the same shared keys in each: `enabled`, `keybinds`, `sidebar`, + `sidebarRows`, `hideWhenEmpty`. Time keys carry their unit. + [Configuration](https://codestz.github.io/opencode-cockpit/configuration/) is the whole reference. + - **Subagents and Review read the files.** Before, their settings existed only on the plugin + entry — `tui.json` for the interface and `opencode.json` for the agent, two places for one bay. + - **Shell's keys moved into a `shell` section**, and `ui.*` with them: `ui.historyMinutes` is + `shell.hideFinishedAfterMinutes`. **Status's section is `status`**, not `statusline`. + Subagents' `hideFinishedAfter` / `hideNestedAfter` are `hideFinishedAfterMinutes` / + `hideNestedAfterSeconds`. The Updater reads `updater.updateCheck`, not `ui.updateCheck`. + - **The old names are not read.** Each one a file still carries is a `!` row in its bay's block — + `! settings: "statusline" is no longer read — run /cockpit-setup` — a line in `doctor`, and the + first thing `/cockpit-setup` fixes. Detection only, removed in 0.10. + - **One order:** the top-level `"sidebar"` list, default `["status", "subagents", "shell", + "trail", "trust"]`, is the only one; each bay's `sidebarOrder` is gone (Trust now sits below + Shells by default). An entry that is not a bay asks whether you meant the closest one + (`"shells"` → `"shell"`). On OpenCode 2 the bundle applies the list; separately installed + packages draw in the order `cli.json` lists them. + - Status's keys at a file's root are no longer read as Status's (a root `"enabled": false` meant + for something else used to turn the statusline off). +- **Status lives in the sidebar by default, as a table.** The `sidebar` preset is now the budget + table — `Context`, the window as one solid bar, the tokens in named rows with their share, a + proxy's spend and what is left, the branch's diff against where it forked — and it is what you get + with no configuration. `{ "status": { "sidebar": false } }` (or `"surface": "bottom"`) puts the + line under the prompt again. A preset that does not exist, or a config pointing at a removed + example, gets a `!` row naming the presets there are, never a blank column. +- **`/statusline` is `/status-setup`.** The old name works for one release, says the new one, and + still briefs the agent; from the home screen `/status-setup` opens a conversation for the brief, and + behind a reply it waits its turn. +- **Every subagent says what it is.** Each row names its agent, muted — `general` too — and the agent + shortens before the title does; a subagent with no title is named by its task's first words. +- **Settings notices are drawn, not only logged.** Status, Shell, Subagents, Trail and Trust draw + theirs as `!` rows in their blocks, wrapped to the sidebar's width so the fix is not cut off; + Review draws its in the pane. Status also draws the ones that belong to no bay: a file that is not + valid JSON, a top-level name nothing reads. +- **`ctrl+p` works while Review is open**: the review steps aside for the host's palette. + +### Fixed + +- **A config file with a comment or a trailing comma was dropped whole, in silence**, by Shell, + Status, Trust and the sidebar order — while doctor called it fine. Every bay reads JSONC now, and a + file that truly cannot be read is a `!` row and a doctor line, and the defaults. +- **Review drew two edits more than 2,000 lines apart as a rewrite** of everything between them. A + long stretch is now split on lines unique to both sides and each piece aligned on its own. +- **Shell offered "show fewer" with one shell left** after you had expanded the list. The toggle + shows only while folding hides something, and an expansion the list outgrew folds itself back. +- **An empty Subagents block took a row of sidebar space** with `hideWhenEmpty` on OpenCode 1. + +### Removed + +- The `examples/sidebar.ts`, `sidebar-full.ts` and `sidebar-budget.ts` Status modules: the `sidebar` + preset is the table they drew. +- The old `sidebar` preset, and each bay's `sidebarOrder`. + ## [0.8.0] - 2026-10-02 ### Added From 4ccb4a33b36fb12f711efa45a3650286795a6f5f Mon Sep 17 00:00:00 2001 From: Codestz Date: Sat, 3 Oct 2026 11:33:17 -0500 Subject: [PATCH 34/90] README: Trail, the Configuration reference, /cockpit-setup, /status-setup, /trail and /link One file now configures every bay, both halves: the whole shape, the shared keys, the sidebar order, each bay's keys and defaults, the names from before 0.9, OpenCode's own Context block on both versions, plugin-entry options once as advanced, and the v2 standalone order note. Co-Authored-By: Claude Opus 5.5 --- README.md | 253 ++++++++++++++++++++++++++++++++---- packages/opencode/README.md | 35 +++-- 2 files changed, 247 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 08b392a5..071f1398 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,14 @@ [![npm](https://img.shields.io/npm/v/@opencode-cockpit/review?color=%23cb3837&label=%40opencode-cockpit%2Freview)](https://www.npmjs.com/package/@opencode-cockpit/review) [![npm](https://img.shields.io/npm/v/@opencode-cockpit/updater?color=%23cb3837&label=%40opencode-cockpit%2Fupdater)](https://www.npmjs.com/package/@opencode-cockpit/updater) [![npm](https://img.shields.io/npm/v/@opencode-cockpit/subagents?color=%23cb3837&label=%40opencode-cockpit%2Fsubagents)](https://www.npmjs.com/package/@opencode-cockpit/subagents) +[![npm](https://img.shields.io/npm/v/@opencode-cockpit/trail?color=%23cb3837&label=%40opencode-cockpit%2Ftrail)](https://www.npmjs.com/package/@opencode-cockpit/trail) [![npm](https://img.shields.io/npm/v/@opencode-cockpit/trust?color=%23cb3837&label=%40opencode-cockpit%2Ftrust)](https://www.npmjs.com/package/@opencode-cockpit/trust) [![Docs](https://img.shields.io/badge/docs-codestz.github.io-9d7cd8)](https://codestz.github.io/opencode-cockpit/) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) **Give [OpenCode](https://opencode.ai) the instruments it does not ship with.** -**[Documentation →](https://codestz.github.io/opencode-cockpit/)** · [Install](https://codestz.github.io/opencode-cockpit/start/install/) · [Shell](https://codestz.github.io/opencode-cockpit/shell/overview/) · [Review](https://codestz.github.io/opencode-cockpit/review/overview/) · [Statusline](https://codestz.github.io/opencode-cockpit/status/overview/) · [Updater](https://codestz.github.io/opencode-cockpit/updater/overview/) · [Subagents](https://codestz.github.io/opencode-cockpit/subagents/overview/) · [Trust](https://codestz.github.io/opencode-cockpit/trust/overview/) · [Changelog](CHANGELOG.md) +**[Documentation →](https://codestz.github.io/opencode-cockpit/)** · [Install](https://codestz.github.io/opencode-cockpit/start/install/) · [Shell](https://codestz.github.io/opencode-cockpit/shell/overview/) · [Review](https://codestz.github.io/opencode-cockpit/review/overview/) · [Statusline](https://codestz.github.io/opencode-cockpit/status/overview/) · [Updater](https://codestz.github.io/opencode-cockpit/updater/overview/) · [Subagents](https://codestz.github.io/opencode-cockpit/subagents/overview/) · [Trail](https://codestz.github.io/opencode-cockpit/trail/overview/) · [Trust](https://codestz.github.io/opencode-cockpit/trust/overview/) · [Configuration](#configuration) · [Changelog](CHANGELOG.md) A tool call has to finish. A dev server does not, and neither does the context window filling up behind you. Cockpit is the instrument panel: things your agent can use, and things that tell you @@ -63,13 +64,29 @@ stays open for you. How full is the context? Where did the tokens go? What has changed? OpenCode answers the first in a corner and the rest not at all. **Statusline** answers them where you are already looking. +With no configuration it is a table at the top of the sidebar — the window as one bar, the tokens +broken into named rows, a proxy's budget when one writes it, and the branch's diff: + +``` +Context +████████████████ +tokens 85.2k · 43% +in 265 · 0% +out 60 · 0% +cache 84.9k · 100% +────────────── +git 5f +312 -48 vs main +``` + +Or a line under the prompt, `{ "status": { "sidebar": false } }`: + ![The statusline under an OpenCode conversation: a context bar at 40%, the token total with its cache, input and output parts, what is uncommitted, elapsed time and todo progress](media/statusline.png) -*The default line — no configuration written at all. Every part is a segment you can reshape, -recolour or remove, or write yourself in TypeScript. Your Claude Code statusline script runs here -unchanged, colours and all.* +*Every part is a segment you can reshape, recolour or remove, or write yourself in TypeScript. Your +Claude Code statusline script runs here unchanged, colours and all. `/status-setup` briefs the agent +in your conversation to change it with you.* -**14 segments · 2 surfaces · [docs](https://codestz.github.io/opencode-cockpit/status/overview/) · [`@opencode-cockpit/status`](packages/status)** +**23 segments · 2 surfaces · [docs](https://codestz.github.io/opencode-cockpit/status/overview/) · [`@opencode-cockpit/status`](packages/status)** --- @@ -111,6 +128,34 @@ can read any of them in full with `subagents_read`, and wait on the ones in the --- +### 🧭 Trail — what a conversation made + +A conversation opens a pull request, comments on a ticket, publishes a page — and a day later the +links are somewhere in a scrolled-away chat. **Trail** keeps them: the agent records what it creates +or changes outside the repository with `trail_add`, and Trail lists it in the sidebar, grouped by +the ticket it was for, one click from the page. And the other way round: `/trail` across every +conversation in the project says which one opened PR #33, and `g` goes back into it. + +``` +Trail 4 + +COM-1736 Bundle desync Jira 2h ↗ + PR #33 0.8: Trust, on… GitHub 1h ↗ + PR #12 Landing: Trus… GitHub 2h ↗ +deploy staging · web-portal 25m ++ 3 more · /trail +``` + +No setup: no account, no token, no list of tools. The agent already knows what it just did with +whatever it uses — `gh`, an MCP server, a company CLI — so the agent writes the trail and Trail keeps +it. When something it ran printed a PR link it never recorded, its next request says so, as a +choice; nothing is added without the agent or you. `/link ` adds one yourself, and `m` copies +the trail as markdown for a PR description or a standup. + +**Sidebar + `/trail` · 2 agent tools · no setup · OpenCode 1 and 2 · [docs](https://codestz.github.io/opencode-cockpit/trail/overview/) · [`@opencode-cockpit/trail`](packages/trail)** + +--- + ### � Trust — permissions that learn `"bash": "ask"` means approving `git status` for the hundredth time; OpenCode's own "Always" means @@ -204,8 +249,12 @@ keeping line numbers and highlighting matches — and output keeps the colours t | `/shell-new` | Start a shell yourself | | `/shells-clear` | Remove finished shells | | `/plugins-update` | Every plugin you have installed: what runs, what is published, and an update checked against disk | +| `/cockpit-setup` | Briefs the agent to set Cockpit up with you: which bays show, in the sidebar or at the bottom, in what order, quiet or present when empty — and fixes any setting from before 0.9 | +| `/status-setup` | Briefs the agent to set up the Status bay with you (`/statusline` until 0.9; the old name still works for one release and says the new one) | +| `ctrl+x f` · `/trail` | What this conversation made, or every conversation in the project (`tab`) | +| `/link [note]` | Add a link to this conversation's trail yourself | -Status reads the same everywhere — `RUN` (with a spinner), `FAIL`, `STOP`, `DONE` — running shells +A shell's status reads the same everywhere — `RUN` (with a spinner), `FAIL`, `STOP`, `DONE` — running shells and recent failures stay in view, the rest folds behind `▸ N more`. In the console: `i` types straight into the program (`ctrl+]` to stop), `c` sends ctrl+c, `r` restarts, `x` stops, `tab` switches between the live screen and the scrollback, `?` shows details. @@ -253,34 +302,191 @@ An existing v1 `opencode.json` with `plugin` is read by OpenCode 2 as well. To u the version in that entry — `/plugins-update` and `npx opencode-cockpit update` edit OpenCode 1's files only. -**Turn features off** (in both `opencode.json` and `tui.json`): +**Configure them** in one file, read by both halves of every bay and by every project — see +[Configuration](#configuration), or type `/cockpit-setup` and let the agent write it with you. -```json +## Configuration + +Every bay reads the same two files, and nothing else needs touching: + +``` +~/.config/opencode-cockpit/config.json → /.cockpit.json +``` + +The global file applies everywhere (`$XDG_CONFIG_HOME` is honoured); a project's file wins over it, +section by section and key by key, so it can change one setting without restating the rest. A +list replaces the one before it. Both halves of a bay — the agent's tools and the interface — read +the same section, so a bay is configured in one place, not once in `opencode.json` and again in +`tui.json`. Comments and trailing commas are fine. Everything is optional: with no file at all you +get the defaults below. + +**The easy way: `/cockpit-setup`.** It draws nothing; it briefs the agent in your conversation with +which bays this window loaded, which files Cockpit reads and what is in them, every key with its +default, and anything from before 0.9 that is no longer read — then asks you what you want to see +(which bays, sidebar or bottom, in what order, quiet or present when empty) before it edits +anything. From the home screen it opens a conversation for the brief; while the agent is answering +it waits its turn. + +### The whole shape + +```jsonc +// ~/.config/opencode-cockpit/config.json — a project's .cockpit.json takes the same shape { - "plugin": [["opencode-cockpit", { "features": { "shell": true } }]] + "sidebar": ["status", "subagents", "shell", "trail", "trust"], // the order, top to bottom + "features": { "trust": false }, // switch a whole bay off + + "status": { "preset": "sidebar", "sidebarRows": 14 }, + "subagents": { "sidebarRows": 6, "hideWhenEmpty": false, "hideFinishedAfterMinutes": 60 }, + "shell": { "sidebarRows": 5, "dockHeight": 16, "lifecycle": { "onExit": "keep" } }, + "trail": { "sidebar": true, "sidebarRows": 5 }, + "trust": { "sidebar": true, "threshold": 3 }, + "review": { "variant": "right", "source": "worktree" }, + "updater": { "updateCheck": true } } ``` -**Configure them** in one file, read by both halves of the plugin and by every project: +One section per bay, and nothing at the top level but `sidebar` and `features`. + +### Keys every bay shares + +Spelled the same in every section: + +| Key | | Default | +| --- | --- | --- | +| `enabled` | The bay's off switch, both halves. `features.: false` does the same | `true` | +| `sidebar` | Draw the bay's sidebar block — a boolean here; the top-level `sidebar` is the order. For Status, `false` puts its line at the bottom, under the prompt | `true`; Trust `false` | +| `sidebarRows` | Rows the block lists before the rest fold into `+ N more` | Status 8 (its table 14), Subagents 6, Shell 5, Trail 5, Trust 3 | +| `hideWhenEmpty` | Subagents, Shell and Trail. `false`: with nothing to list the block still says it is there — its heading and `none yet`. `true`: no block at all until there is something | `false` | +| `keybinds` | Keys for the bay's commands, `{ "": "" }` | each bay's own | + +Time keys carry their unit: `hideFinishedAfterMinutes`, `hideNestedAfterSeconds`. + +### The sidebar order + +One list, at the top of either file, and nowhere else: +```json +{ "sidebar": ["status", "subagents", "shell", "trail", "trust"] } ``` -~/.config/opencode-cockpit/config.json → /.cockpit.json → plugin-entry options + +That is the default. A project's list replaces the global one (it is an order, not a set), and a bay +the list leaves out keeps its default place after the ones it names. On OpenCode 1 Cockpit's blocks +sit together under OpenCode's own Context block and above the rest. An entry that is not a bay — +`"shells"` — is not silently ignored: a `!` row asks whether you meant `"shell"`. + +On OpenCode 2 the bundle applies the list. **Installed as separate packages on OpenCode 2, the +blocks draw in the order the packages are listed in `cli.json`**, so list them in the order you want +them. + +### Each bay + +**`status`** — the Status bay ([all of it](packages/status#configuration)). + +| Key | | Default | +| --- | --- | --- | +| `preset` | A whole line by name: `sidebar` (the table), `minimal`, `default`, `detailed` (bottom lines). Anything written beside it wins | `sidebar` | +| `surface` | `sidebar` or `bottom`; `"sidebar": false` says the same | `sidebar` | +| `segments` | The line's parts, built-ins or your own | the preset's | +| `lines` | More than one line, each with its own `surface`, `segments`, `maxRows`… | one | +| `separator`, `stack`, `icons`, `debug`, `padding*` | How a line is laid out | per surface | +| `commands` | Shell commands usable as segments — your Claude Code statusline script, unchanged | none | +| `modules` | Your own segments in TypeScript; a project's add to the global ones | none | + +**`subagents`** — [Subagents](packages/subagents#settings). + +| Key | | Default | +| --- | --- | --- | +| `hideFinishedAfterMinutes` | Minutes a finished subagent stays in the sidebar | unset: the whole conversation | +| `hideNestedAfterSeconds` | Seconds a finished *nested* subagent stays; negative keeps them | `30` | +| `guidance` | Tell the agent about background subagents and follow-ups | `true` | + +**`shell`** — [Shell](packages/shell#configuration). + +| Key | | Default | +| --- | --- | --- | +| `kinds` | Your own shell categories, name → regex on the command | none | +| `watch` | `presets` (your own rules) and `auto` (attach one to every shell) | `auto: false` | +| `defaults` | Applied to every shell the agent starts: `watch`, `logFile`, `idleTimeoutSeconds`, `timeoutSeconds`, `notifyOnExit` | none | +| `lifecycle` | `onExit` (`stopMine` or `keep`), `orphanAfterMinutes`, `removeFinishedAfterMinutes` | `stopMine`, `60`, `30` | +| `notify` | What may interrupt the agent: `exit`, `watch`, `tailLines` | on | +| `guidance`, `listRunningShells` | The system-prompt paragraph, and how many running shells it names | `true`, `15` | +| `dockHeight`, `dockOpen`, `defaultView`, `colors` | The panel under the chat and the console | `14`, as last left, `screen`, `true` | +| `hideFinishedAfterMinutes` | How long a finished shell stays in the folded views | `30` | + +**`trail`** — [Trail](packages/trail#settings). Nothing beyond the shared keys; its block is on by +default, and `ctrl+x f` (`cockpit.trail.open`) opens `/trail`. + +**`trust`** — [Trust](packages/trust#settings). + +| Key | | Default | +| --- | --- | --- | +| `threshold` | Approvals in a row, by you, before Trust answers | `3` | +| `dangerExtra` | What a dangerous command costs on top | `5` | +| `expireDays` | Days unused before trust has to be earned again; `0` never | `30` | + +Its block is off by default (`"sidebar": true` shows it; the palette flips it for the session). + +**`review`** — no sidebar block. + +| Key | | Default | +| --- | --- | --- | +| `variant` | Where the panel opens: `right` or `full` | `right` | +| `source` | What it reviews on open: `worktree` (uncommitted) or `branch` | `worktree` | + +**`updater`** — [Updater](packages/updater#settings). + +| Key | | Default | +| --- | --- | --- | +| `updateCheck` | Check for plugin updates once a day and say so | `true` | + +### Names from before 0.9 + +0.9 gave every bay the same shape, so some names changed. **The old ones are not read.** Each one a +file still carries is drawn as a `!` row in its bay's block, printed by +`npx opencode-cockpit@latest doctor`, and fixed first by `/cockpit-setup`: + +``` +! settings: "statusline" is no longer read — run /cockpit-setup ``` -```json -{ - "kinds": { "e2e": "playwright|cypress" }, - "defaults": { "logFile": true, "timeoutSeconds": 900 }, - "ui": { "dockHeight": 16, "historyMinutes": 60 } -} +| Before 0.9 | Now | +| --- | --- | +| `statusline` | `status` | +| `status.maxRows` | `status.sidebarRows` | +| Shell's keys at the file's root (`kinds`, `watch`, `defaults`, `lifecycle`, `notify`, `guidance`, `listRunningShells`) | the same keys under `shell` | +| `ui.dockHeight`, `ui.dockOpen`, `ui.sidebarRows`, `ui.colors`, `ui.keybinds`… | the same keys under `shell` | +| `ui.historyMinutes` | `shell.hideFinishedAfterMinutes` | +| `ui.updateCheck` | `updater.updateCheck` | +| `ui.sidebarOrder`, `.sidebarOrder` | the top-level `sidebar` list | +| `subagents.hideFinishedAfter`, `subagents.hideNestedAfter` | `…Minutes`, `…Seconds` | +| Status's keys at the file's root (`preset`, `segments`, `enabled`…) | the same keys under `status` | + +A file that is not valid JSON, a top-level name nothing reads, or a value of the wrong kind gets a +`!` row too, and the defaults — never a silently blank sidebar. + +### OpenCode's own sidebar blocks + +Status's table carries what OpenCode's own Context block says. To keep only one, switch the host's +off — it is OpenCode's setting, in OpenCode's file, and the name differs by version: + +```jsonc +// OpenCode 1 — ~/.config/opencode/tui.json +{ "plugin_enabled": { "internal:sidebar-context": false } } ``` -Later sources win key by key, and an invalid file is ignored rather than fatal. You can categorize -your own commands, define watch rules, cap how long shells live, choose what may interrupt the -agent, and trade context tokens for accuracy. Each feature's README documents its own settings: -[Shell](packages/shell#configuration), [Statusline](packages/status#configuration), -[Updater](packages/updater#settings), [Subagents](packages/subagents#settings), -[Trust](packages/trust#settings). +```jsonc +// OpenCode 2 — ~/.config/opencode/cli.json +{ "plugins": ["opencode-cockpit@0.8.0", "-opencode.sidebar.context"] } +``` + +Leave OpenCode's Todo block on: nothing in Cockpit replaces it. + +### Advanced: options on the plugin entry + +The same keys can also go on the plugin entry — the bundle's `["opencode-cockpit", { "shell": { … } }]` +or a single bay's own `["@opencode-cockpit/shell", { … }]` — where they win over both files. It is +rarely worth it: on OpenCode 1 the interface's options belong in `tui.json` and the agent's in +`opencode.json`, so the same bay ends up configured in two places. The files are read by both. ## Troubleshooting @@ -330,6 +536,7 @@ Each shell's output feeds three views at once: a normalized **log** for the agen | [`@opencode-cockpit/updater`](packages/updater) | Bay 04 — every plugin, and an update checked against disk | [README](packages/updater/README.md) · [docs](https://codestz.github.io/opencode-cockpit/updater/overview/) | | [`@opencode-cockpit/subagents`](packages/subagents) | Bay 05 — every subagent visible, reachable and reused | [README](packages/subagents/README.md) · [docs](https://codestz.github.io/opencode-cockpit/subagents/overview/) | | [`@opencode-cockpit/trust`](packages/trust) | Bay 06 — permissions that learn, visibly | [README](packages/trust/README.md) · [docs](https://codestz.github.io/opencode-cockpit/trust/overview/) | +| [`@opencode-cockpit/trail`](packages/trail) | Bay 07 — what a conversation made, one click from the page | [README](packages/trail/README.md) · [docs](https://codestz.github.io/opencode-cockpit/trail/overview/) | | [`@opencode-cockpit/daemon`](packages/daemon) | `cockpitd`, the shared process host | [README](packages/daemon/README.md) | | [`@opencode-cockpit/client`](packages/client) | Typed, auto-spawning client | [README](packages/client/README.md) | | [`@opencode-cockpit/protocol`](packages/protocol) | Wire contracts and schemas | [README](packages/protocol/README.md) | diff --git a/packages/opencode/README.md b/packages/opencode/README.md index b63544bb..07060901 100644 --- a/packages/opencode/README.md +++ b/packages/opencode/README.md @@ -99,6 +99,9 @@ keeping line numbers and highlighting matches — and output keeps the colours t | `/shell-new` | Start a shell yourself | | `/shells-clear` | Remove finished shells | | `/plugins-update` | Every plugin you have installed: what runs, what is published, and an update checked against disk | +| `/cockpit-setup` | Briefs the agent to set Cockpit up with you: which bays show, where, in what order | +| `/status-setup` | Briefs the agent to set up the Status bay with you (was `/statusline`) | +| `ctrl+x f` · `/trail` | What this conversation made; `/link ` adds one yourself | Status reads the same everywhere — `RUN` (with a spinner), `FAIL`, `STOP`, `DONE` — running shells and recent failures stay in view, the rest folds behind `▸ N more`. In the console: `i` types @@ -148,32 +151,28 @@ An existing v1 `opencode.json` with `plugin` is read by OpenCode 2 as well. To u the version in that entry — `/plugins-update` and `npx opencode-cockpit update` edit OpenCode 1's files only. -**Turn features off** (in both `opencode.json` and `tui.json`): +**Configure them** in one file, read by both halves of every bay and by every project — or type +`/cockpit-setup` and the agent writes it with you: -```json -{ - "plugin": [["opencode-cockpit", { "features": { "shell": true } }]] -} ``` - -**Configure them** in one file, read by both halves of the plugin and by every project: - -``` -~/.config/opencode-cockpit/config.json → /.cockpit.json → plugin-entry options +~/.config/opencode-cockpit/config.json → /.cockpit.json ``` -```json +```jsonc { - "kinds": { "e2e": "playwright|cypress" }, - "defaults": { "logFile": true, "timeoutSeconds": 900 }, - "ui": { "dockHeight": 16, "historyMinutes": 60 } + "sidebar": ["status", "subagents", "shell", "trail", "trust"], // the blocks' order + "features": { "trust": false }, // switch a bay off + "shell": { "dockHeight": 16, "hideFinishedAfterMinutes": 60 }, + "trail": { "sidebarRows": 5 } } ``` -Later sources win key by key, and an invalid file is ignored rather than fatal. You can categorize -your own commands, define watch rules, cap how long shells live, choose what may interrupt the -agent, and trade context tokens for accuracy. Each feature's README documents its own settings: -[Shell](https://github.com/Codestz/opencode-cockpit/tree/main/packages/shell#configuration). +One section per bay — `status`, `subagents`, `shell`, `trail`, `trust`, `review`, `updater` — with +the same shared keys in each (`enabled`, `sidebar`, `sidebarRows`, `hideWhenEmpty`, `keybinds`). A +project's file wins key by key; comments and trailing commas are fine. Names from before 0.9 +(`statusline`, Shell's keys at the root, `ui.*`, `sidebarOrder`) are no longer read: each is a `!` +row in its bay's block, and `/cockpit-setup` fixes it. Every key, its default and the old names: +[Configuration](https://github.com/Codestz/opencode-cockpit#configuration). ## Troubleshooting From 1f5a71f4f0444c833d9bc9e796ebad538877b509 Mon Sep 17 00:00:00 2001 From: Codestz Date: Sat, 3 Oct 2026 11:33:17 -0500 Subject: [PATCH 35/90] Site: Configuration is the settings reference; status pages on the status section and the table preset Old keys and removed examples gone from every page, OpenCode 2's opencode.sidebar.context beside OpenCode 1's, Trail on the bays page, 17 agent tools and 23 segments on the landing page. Co-Authored-By: Claude Opus 5.5 --- site/src/content/docs/configuration.md | 266 +++++++++++++----- site/src/content/docs/help/troubleshooting.md | 2 +- site/src/content/docs/platform/bays.md | 13 +- site/src/content/docs/review/interface.md | 25 +- site/src/content/docs/shell/interface.mdx | 4 +- site/src/content/docs/start/first-session.mdx | 11 +- .../src/content/docs/start/what-cockpit-is.md | 7 +- site/src/content/docs/status/commands.md | 4 +- site/src/content/docs/status/configuration.md | 108 +++++-- site/src/content/docs/status/drawing.md | 7 +- site/src/content/docs/status/modules.md | 14 +- site/src/content/docs/status/overview.md | 55 +++- site/src/content/docs/status/proxies.md | 2 +- site/src/content/docs/subagents/overview.md | 40 ++- site/src/content/docs/trust/overview.md | 5 +- site/src/content/docs/updater/overview.mdx | 3 +- site/src/data/landing.ts | 14 +- site/src/pages/index.astro | 2 +- 18 files changed, 410 insertions(+), 172 deletions(-) diff --git a/site/src/content/docs/configuration.md b/site/src/content/docs/configuration.md index c0c496e7..bf4d6d14 100644 --- a/site/src/content/docs/configuration.md +++ b/site/src/content/docs/configuration.md @@ -1,110 +1,244 @@ --- title: Configuration -description: One file for both halves of the plugin — every section, with examples. +description: Every bay's settings in one file, read by both halves of the plugin — the whole shape, each bay's keys and defaults, and the names from before 0.9. --- -This page is Shell's configuration, and the sidebar order every bay shares. The other bays carry -their own, because they are their own packages: -[Statusline](/opencode-cockpit/status/configuration/), -[Review](/opencode-cockpit/review/interface/#settings), -[Subagents](/opencode-cockpit/subagents/overview/#settings) and -[Trust](/opencode-cockpit/trust/overview/#settings). - -Everything is optional. Settings are merged from three places, later winning key by key: +Every bay reads the same two files, and nothing else needs touching: ``` -~/.config/opencode-cockpit/config.json → /.cockpit.json → plugin-entry options +~/.config/opencode-cockpit/config.json → /.cockpit.json ``` -A project can override one setting without restating the rest, and an unreadable or invalid file is -**ignored rather than fatal** — a typo should never stop your shells from working. `XDG_CONFIG_HOME` -is honoured for the global path. +The global file applies everywhere (`XDG_CONFIG_HOME` is honoured); a project's file wins over it, +section by section and key by key, so it can change one setting without restating the rest. A list +replaces the one before it. Both halves of a bay — the agent's tools and the interface — read the +same section, so a bay is configured in one place, not once in `opencode.json` and again in +`tui.json`. Comments and trailing commas are fine. Everything is optional: with no file at all you +get the defaults below. + +:::tip[The easy way: /cockpit-setup] +`/cockpit-setup` draws nothing. It briefs the agent in your conversation with which bays this window +loaded, which files Cockpit reads and what is in them, every key with its default, and anything from +before 0.9 that is no longer read — then asks you what you want to see (which bays, sidebar or +bottom, in what order, quiet or present when empty) before it edits anything. From the home screen +it opens a conversation for the brief; while the agent is answering, it waits its turn. +::: + +## The whole shape -```json title="~/.config/opencode-cockpit/config.json" +```jsonc title="~/.config/opencode-cockpit/config.json" +// a project's .cockpit.json takes the same shape { - "kinds": { "e2e": "playwright|cypress", "infra": "^(terraform|pulumi)\\b" }, - "watch": { - "auto": false, - "presets": { "e2e": { "done": "\\d+ passed", "fail": "\\d+ failed" } } - }, - "defaults": { "logFile": true, "timeoutSeconds": 900 }, - "notify": { "exit": true, "watch": true, "tailLines": 15 }, - "guidance": true, - "listRunningShells": 15, - "ui": { "dockHeight": 16, "dockOpen": true, "defaultView": "screen" } + "sidebar": ["status", "subagents", "shell", "trail", "trust"], // the order, top to bottom + "features": { "trust": false }, // switch a whole bay off + + "status": { "preset": "sidebar", "sidebarRows": 14 }, + "subagents": { "sidebarRows": 6, "hideWhenEmpty": false, "hideFinishedAfterMinutes": 60 }, + "shell": { "sidebarRows": 5, "dockHeight": 16, "lifecycle": { "onExit": "keep" } }, + "trail": { "sidebar": true, "sidebarRows": 5 }, + "trust": { "sidebar": true, "threshold": 3 }, + "review": { "variant": "right", "source": "worktree" }, + "updater": { "updateCheck": true } } ``` -## kinds +One section per bay, and nothing at the top level but `sidebar` and `features`. -Extra shell categories, or overrides, as `name` → regular expression matched against the command. -They drive the badge in the panel and sidebar and the `kind` filter in `shell_list`, so you can -group your own stack instead of the built-ins (`server`, `tests`, `build`, `watcher`, `task`). +## Keys every bay shares -## watch +Spelled the same in every section: -`presets` are your own rules, keyed by name, and they reach the daemon as explicit rules — so a -preset you invent works immediately. `auto` attaches a matching preset to every new shell; **off by -default**. +| Key | | Default | +| --- | --- | --- | +| `enabled` | The bay's off switch, both halves. `features.: false` does the same | `true` | +| `sidebar` | Draw the bay's sidebar block — a boolean here; the top-level `sidebar` is the order. For Status, `false` puts its line at the bottom, under the prompt | `true`; Trust `false` | +| `sidebarRows` | Rows the block lists before the rest fold into `+ N more` | Status 8 (its table 14), Subagents 6, Shell 5, Trail 5, Trust 3 | +| `hideWhenEmpty` | Subagents, Shell and Trail. `false`: with nothing to list the block still says it is there — its heading and `none yet`. `true`: no block at all until there is something | `false` | +| `keybinds` | Keys for the bay's commands, `{ "": "" }` | each bay's own | + +Time keys carry their unit: `hideFinishedAfterMinutes`, `hideNestedAfterSeconds`. -## lifecycle +## The sidebar order -When shells end without being asked to. +One list, at the top of either file, and nowhere else: ```json -{ "lifecycle": { "onExit": "stopMine", "orphanAfterMinutes": 60 } } +{ "sidebar": ["status", "subagents", "shell", "trail", "trust"] } ``` -`onExit` decides what happens to the shells an OpenCode window started when that window closes: -`stopMine` (the default) stops them, `keep` leaves them running for the next window — which is how -a shell survives an OpenCode restart. +That is the default. A project's list replaces the global one (it is an order, not a set), and a bay +the list leaves out keeps its default place after the ones it names. On OpenCode 1 Cockpit's blocks +sit together under OpenCode's own Context block and above the rest. An entry that is not a bay — +`"shells"` — is not silently ignored: a `!` row asks whether you meant `"shell"`. + +:::note[OpenCode 2, separate packages] +On OpenCode 2 the bundle applies the list. **Installed as separate packages, the blocks draw in the +order the packages are listed in `cli.json`**, so list them in the order you want them. +::: + +## status + +The Status bay. The rest of it — segments, lines, modules — is under +[Segments and layout](/opencode-cockpit/status/configuration/). + +| Key | | Default | +| --- | --- | --- | +| `preset` | A whole line by name: `sidebar` (the table), `minimal`, `default`, `detailed` (bottom lines). Anything written beside it wins | `sidebar` | +| `surface` | `sidebar` or `bottom`; `"sidebar": false` says the same | `sidebar` | +| `segments` | The line's parts, built-ins or your own | the preset's | +| `lines` | More than one line, each with its own `surface`, `segments`, `maxRows`… | one | +| `separator`, `stack`, `icons`, `debug`, `padding*` | How a line is laid out | per surface | +| `commands` | Shell commands usable as segments — your Claude Code statusline script, unchanged | none | +| `modules` | Your own segments in TypeScript; a project's add to the global ones | none | + +## subagents + +See [Subagents](/opencode-cockpit/subagents/overview/#settings). + +| Key | | Default | +| --- | --- | --- | +| `hideFinishedAfterMinutes` | Minutes a finished subagent stays in the sidebar | unset: the whole conversation | +| `hideNestedAfterSeconds` | Seconds a finished *nested* subagent stays; negative keeps them | `30` | +| `guidance` | Tell the agent about background subagents and follow-ups | `true` | + +## shell + +| Key | | Default | +| --- | --- | --- | +| `kinds` | Your own shell categories, name → regex on the command | none | +| `watch` | `presets` (your own rules) and `auto` (attach one to every shell) | `auto: false` | +| `defaults` | Applied to every shell the agent starts: `watch`, `logFile`, `idleTimeoutSeconds`, `timeoutSeconds`, `notifyOnExit` | none | +| `lifecycle` | `onExit` (`stopMine` or `keep`), `orphanAfterMinutes`, `removeFinishedAfterMinutes` | `stopMine`, `60`, `30` | +| `notify` | What may interrupt the agent: `exit`, `watch`, `tailLines` | on | +| `guidance`, `listRunningShells` | The system-prompt paragraph, and how many running shells it names | `true`, `15` | +| `dockHeight`, `dockOpen`, `defaultView`, `colors` | The panel under the chat and the console | `14`, as last left, `screen`, `true` | +| `hideFinishedAfterMinutes` | How long a finished shell stays in the folded views | `30` | + +```jsonc +{ + "shell": { + "kinds": { "e2e": "playwright|cypress", "infra": "^(terraform|pulumi)\\b" }, + "watch": { + "auto": false, + "presets": { "e2e": { "done": "\\d+ passed", "fail": "\\d+ failed" } } + }, + "defaults": { "logFile": true, "timeoutSeconds": 900 }, + "notify": { "exit": true, "watch": true, "tailLines": 15 }, + "dockHeight": 16, + "dockOpen": true + } +} +``` + +**kinds** are extra shell categories, or overrides, as `name` → regular expression matched against +the command. They drive the badge in the panel and sidebar and the `kind` filter in `shell_list`, so +you can group your own stack instead of the built-ins (`server`, `tests`, `build`, `watcher`, +`task`). + +**watch.presets** are your own rules, keyed by name, and they reach the daemon as explicit rules — so +a preset you invent works immediately. `auto` attaches a matching preset to every new shell; **off +by default**. -`orphanAfterMinutes` stops a shell that no window of its own has been connected to for that long, -so nothing can quietly run for a week. `0` turns it off. +**lifecycle** says when shells end without being asked to. `onExit` decides what happens to the +shells an OpenCode window started when that window closes: `stopMine` (the default) stops them, +`keep` leaves them running for the next window — which is how a shell survives an OpenCode restart. +`orphanAfterMinutes` stops a shell that no window of its own has been connected to for that long, so +nothing can quietly run for a week; `0` turns it off. `removeFinishedAfterMinutes` removes a shell +the agent started that long after it exits cleanly; failed ones stay until `/shells-clear`. :::note[Two windows] A window closing only counts once **both halves** of the plugin have disconnected, so quitting one of two open OpenCodes never stops the other's shells. ::: -## defaults +**guidance and listRunningShells** are the context budget. `guidance` is the paragraph that teaches +the model when to reach for a shell (~120 tokens per request); `listRunningShells` is how many +running shells are named in the system prompt each turn (~20 tokens each, `0` disables). Turning both +off saves tokens at the cost of a model that uses shells less well. -Applied to every shell the agent starts unless the call says otherwise: `watch`, `logFile`, -`idleTimeoutSeconds`, `timeoutSeconds`, `notifyOnExit`. +**dockOpen** decides how the panel starts: set it and it always starts that way; leave it out and it +starts however you last left it. -## notify +## trail -What may interrupt the agent: `exit`, `watch`, and `tailLines` — how much output rides along with an -exit message. +Nothing beyond the shared keys. Its block is on by default, and `ctrl+x f` (`cockpit.trail.open`) +opens `/trail`. See [Trail](/opencode-cockpit/trail/overview/#settings). -## guidance and listRunningShells +## trust -The context budget. `guidance` is the paragraph that teaches the model when to reach for a shell -(~120 tokens per request); `listRunningShells` is how many running shells are named in the system -prompt each turn (~20 tokens each, `0` disables). Turning both off saves tokens at the cost of a -model that uses shells less well. +See [Trust](/opencode-cockpit/trust/overview/#settings). -## ui +| Key | | Default | +| --- | --- | --- | +| `threshold` | Approvals in a row, by you, before Trust answers | `3` | +| `dangerExtra` | What a dangerous command costs on top | `5` | +| `expireDays` | Days unused before trust has to be earned again; `0` never | `30` | -Interface only: `dockHeight`, `dockOpen`, `sidebarRows`, `sidebarOrder`, `historyMinutes`, `colors`, -`defaultView` (`screen` or `log`), `keybinds`, `updateCheck`. +Its block is off by default: `"sidebar": true` shows it, and the palette flips it for the session. -:::tip[Four bays share the sidebar] -The statusline (140), Subagents (150), Trust (160, when `trust.sidebar` shows it) and Shell (170) all -draw there, in that order; lower draws first, on both OpenCodes. One list orders them all — in `~/.config/opencode-cockpit/config.json`, or a project's `.cockpit.json`, -which wins: +## review -```json -{ "sidebar": ["status", "subagents", "trust", "shell"] } +No sidebar block. See [Panel and keys](/opencode-cockpit/review/interface/#settings). + +| Key | | Default | +| --- | --- | --- | +| `variant` | Where the panel opens: `right` or `full` | `right` | +| `source` | What it reviews on open: `worktree` (uncommitted) or `branch` | `worktree` | + +## updater + +See [Updater](/opencode-cockpit/updater/overview/). + +| Key | | Default | +| --- | --- | --- | +| `updateCheck` | Check for plugin updates once a day and say so | `true` | + +## Names from before 0.9 + +0.9 gave every bay the same shape, so some names changed. **The old ones are not read.** Each one a +file still carries is drawn as a `!` row in its bay's block, printed by +[doctor](/opencode-cockpit/help/doctor/), and fixed first by `/cockpit-setup`: + +``` +! settings: "statusline" is no longer read — run /cockpit-setup ``` -A bay the list leaves out keeps its place. A bay's own number (`ui.sidebarOrder` here, -`statusline.sidebarOrder`, Subagents' and Trust's `sidebarOrder`) still beats the list. -::: +| Before 0.9 | Now | +| --- | --- | +| `statusline` | `status` | +| `status.maxRows` | `status.sidebarRows` | +| Shell's keys at the file's root (`kinds`, `watch`, `defaults`, `lifecycle`, `notify`, `guidance`, `listRunningShells`) | the same keys under `shell` | +| `ui.dockHeight`, `ui.dockOpen`, `ui.sidebarRows`, `ui.colors`, `ui.keybinds`… | the same keys under `shell` | +| `ui.historyMinutes` | `shell.hideFinishedAfterMinutes` | +| `ui.updateCheck` | `updater.updateCheck` | +| `ui.sidebarOrder`, `.sidebarOrder` | the top-level `sidebar` list | +| `subagents.hideFinishedAfter`, `subagents.hideNestedAfter` | `…Minutes`, `…Seconds` | +| Status's keys at the file's root (`preset`, `segments`, `enabled`…) | the same keys under `status` | -:::tip[dockOpen decides how it starts] -Set it and the panel always starts that way. Leave it out and it starts however you last left it. -::: +A file that is not valid JSON, a top-level name nothing reads, or a value of the wrong kind gets a +`!` row too, and the defaults — never a silently blank sidebar. + +## OpenCode's own sidebar blocks + +Status's table carries what OpenCode's own Context block says. To keep only one, switch the host's +off — it is OpenCode's setting, in OpenCode's file, and the name differs by version: + +```jsonc title="OpenCode 1 — ~/.config/opencode/tui.json" +{ "plugin_enabled": { "internal:sidebar-context": false } } +``` + +```jsonc title="OpenCode 2 — ~/.config/opencode/cli.json" +{ "plugins": ["opencode-cockpit@0.8.0", "-opencode.sidebar.context"] } +``` + +Leave OpenCode's Todo block on: nothing in Cockpit replaces it. + +## Advanced: options on the plugin entry + +The same keys can also go on the plugin entry — the bundle's `["opencode-cockpit", { "shell": { … } }]` +or a single bay's own `["@opencode-cockpit/shell", { … }]` — where they win over both files. It is +rarely worth it: on OpenCode 1 the interface's options belong in `tui.json` and the agent's in +`opencode.json`, so the same bay ends up configured in two places. The files are read by both. ## Environment variables diff --git a/site/src/content/docs/help/troubleshooting.md b/site/src/content/docs/help/troubleshooting.md index 97f074e0..8cfab046 100644 --- a/site/src/content/docs/help/troubleshooting.md +++ b/site/src/content/docs/help/troubleshooting.md @@ -147,4 +147,4 @@ cat .cockpit.json | python3 -m json.tool ## Shells are gone after a restart They survive OpenCode restarts, not machine restarts: the daemon exits when idle and reaps what it -owned. Shells finished more than `ui.historyMinutes` ago are also hidden from the panel by default. +owned. Shells finished more than `shell.hideFinishedAfterMinutes` (30) ago are also hidden from the panel by default. diff --git a/site/src/content/docs/platform/bays.md b/site/src/content/docs/platform/bays.md index 69b92e7e..5e153a04 100644 --- a/site/src/content/docs/platform/bays.md +++ b/site/src/content/docs/platform/bays.md @@ -17,7 +17,7 @@ opencode plugin @opencode-cockpit/shell@0.8.0 --global --force ## Statusline — available -A line of live session state under the conversation, or a column of it in the sidebar. Fourteen +A table of live session state in the sidebar, or a line of it under the conversation. Twenty-three segments, two surfaces, and three ways to configure it — including running the statusline script you already wrote for Claude Code, colours and all. See [Statusline](/opencode-cockpit/status/overview/). @@ -59,6 +59,17 @@ instead of starting a new one, and the main agent can list, read and wait on its opencode plugin @opencode-cockpit/subagents@0.8.0 --global --force ``` +## Trail — available + +What a conversation made: the pull requests, tickets, pages and deploys the agent created or changed, +recorded by the agent with `trail_add`, kept per conversation, grouped by the ticket they were for, +and one click from the page. `/trail` says which conversation made what, across the project. No +setup. See [Trail](/opencode-cockpit/trail/overview/). + +```sh +opencode plugin @opencode-cockpit/trail@0.8.0 --global --force +``` + ## Trust — available Permissions that learn. Approve the exact same command three times in a row and Trust answers diff --git a/site/src/content/docs/review/interface.md b/site/src/content/docs/review/interface.md index 37db780a..fc522d6a 100644 --- a/site/src/content/docs/review/interface.md +++ b/site/src/content/docs/review/interface.md @@ -19,29 +19,22 @@ narrows rather than disappearing, because a review you cannot change file in is ## Settings -Three, all optional, given where the plugin is listed: - -```json title="opencode.json" -{ - "plugin": [ - ["@opencode-cockpit/review", { "variant": "full", "source": "branch" }] - ] -} +All optional, in the `review` section of `~/.config/opencode-cockpit/config.json` or a project's +`.cockpit.json` (before 0.9 Review read no file, only its plugin entry): + +```json title="~/.config/opencode-cockpit/config.json" +{ "review": { "variant": "full", "source": "branch" } } ``` | | | | --- | --- | | `variant` | `right` (default) or `full` — where it opens | -| `source` | `worktree` (default), `branch`, or `session` — what it opens on | +| `source` | `worktree` (default) or `branch` — what it opens on | | `keybinds` | overrides for the two global keys, e.g. `{ "cockpit.review.open": "d" }` | +| `enabled` | `false` switches Review off; so does `features.review: false` | -Through the bundle, the same options go under a `review` key: - -```json title="opencode.json" -{ - "plugin": [["opencode-cockpit", { "review": { "variant": "full" } }]] -} -``` +A value Review does not know (`"variant": "left"`) is the default and a `!` row in the pane naming +the ones it does. See [Configuration](/opencode-cockpit/configuration/). The keys *inside* the panel are not configurable. They are a closed set that only exists while the panel is open, and the panel gives them straight back when it closes. diff --git a/site/src/content/docs/shell/interface.mdx b/site/src/content/docs/shell/interface.mdx index bb81893c..ce8acf9f 100644 --- a/site/src/content/docs/shell/interface.mdx +++ b/site/src/content/docs/shell/interface.mdx @@ -32,7 +32,7 @@ anything. Press s in the console to widen it to the whole project and ## The panel A dock under the chat with a tab per shell, the selected shell's live screen, and the health of each -one at a glance. `ui.dockHeight` sets how tall, `ui.dockOpen` whether it starts open. +one at a glance. `shell.dockHeight` sets how tall, `shell.dockOpen` whether it starts open. ## The console @@ -99,4 +99,4 @@ the console's; `--help` lists the states. Plain text under `NO_COLOR` or into a `/shells-stop` stops the shells you are looking at; `/shells-stop-all` stops every shell in the project and asks first when that includes conversations you cannot see. `/shells` toggles the panel, `/shell` opens the console, `/shell-new` starts one, `/shells-clear` forgets finished shells, `/shells-restart-daemon` replaces a daemon running older -code. Rebind them under `ui.keybinds`. +code. Rebind them under `shell.keybinds`. diff --git a/site/src/content/docs/start/first-session.mdx b/site/src/content/docs/start/first-session.mdx index fe6cb9d8..d11d731d 100644 --- a/site/src/content/docs/start/first-session.mdx +++ b/site/src/content/docs/start/first-session.mdx @@ -49,12 +49,13 @@ is about, it changes what needs changing, and `review_reply resolved=true` close only counts if the file actually changed. Your comments stay on the branch, so tomorrow's conversation still has them. -## And the line under the prompt +## And the table in the sidebar -If you installed the bundle you already have the statusline: how full the context is, where the -tokens went, what the session has cost, what has changed. It answers the questions you would -otherwise ask by leaving — and every part of it is a segment you can reshape, recolour or write -yourself. +If you installed the bundle you already have the statusline, at the top of the sidebar: how full the +context is, where the tokens went, a proxy's budget, what has changed. It answers the questions you +would otherwise ask by leaving — and every part of it is a segment you can reshape, recolour or +write yourself. Under it, Subagents, Shells and Trail each say `none yet` until there is something +to show. `/cockpit-setup` asks you what you want there, and writes the config. ## What to try next diff --git a/site/src/content/docs/start/what-cockpit-is.md b/site/src/content/docs/start/what-cockpit-is.md index 9f481a4a..c2926d90 100644 --- a/site/src/content/docs/start/what-cockpit-is.md +++ b/site/src/content/docs/start/what-cockpit-is.md @@ -69,9 +69,12 @@ and the interface slots are identical either way. ```json title="~/.config/opencode-cockpit/config.json" { - "defaults": { "logFile": true }, - "ui": { "dockOpen": true } + "sidebar": ["status", "subagents", "shell", "trail", "trust"], + "shell": { "defaults": { "logFile": true }, "dockOpen": true } } ``` +One section per bay, read by both halves of each; [Configuration](/opencode-cockpit/configuration/) +has every key, or type `/cockpit-setup` and the agent writes it with you. + Next: [Install](/opencode-cockpit/start/install/). diff --git a/site/src/content/docs/status/commands.md b/site/src/content/docs/status/commands.md index 03de98b7..4a4421b7 100644 --- a/site/src/content/docs/status/commands.md +++ b/site/src/content/docs/status/commands.md @@ -8,7 +8,7 @@ A statusline segment can be a shell command, fed the same JSON on stdin that Cla ```jsonc { - "statusline": { + "status": { "commands": { "mine": { "run": "~/.claude/statusline.sh", "intervalMs": 2000 } }, "segments": [{ "type": "command", "name": "mine" }] } @@ -62,7 +62,7 @@ Claude Code statuslines print one row per `echo`. Every row is kept; pick one wi ```jsonc { - "statusline": { + "status": { "commands": { "mine": { "run": "~/.claude/statusline.sh" } }, "lines": [ { "surface": "bottom", "segments": [{ "type": "command", "name": "mine", "row": 0 }] }, diff --git a/site/src/content/docs/status/configuration.md b/site/src/content/docs/status/configuration.md index 61e0eaec..2e737878 100644 --- a/site/src/content/docs/status/configuration.md +++ b/site/src/content/docs/status/configuration.md @@ -3,12 +3,52 @@ title: Segments and layout description: Every built-in segment, the surfaces they sit on, and how a line survives a narrow terminal. --- -Settings live in `~/.config/opencode-cockpit/config.json` for every project, `/.cockpit.json` -for one, and on the plugin entry itself, which beats both. +Settings live in the `status` section of `~/.config/opencode-cockpit/config.json` for every +project, and of `/.cockpit.json` for one. Comments and trailing commas are fine. The keys +every bay shares, the sidebar order and the names from before 0.9 are on +[Configuration](/opencode-cockpit/configuration/). + +## Presets + +A whole line by name, built-ins only — nothing to install, nothing to write: + +```jsonc +{ "status": { "preset": "default" } } +``` + +| Preset | Surface | What you get | +| --- | --- | --- | +| `sidebar` | sidebar | **the default**: a table — the window as one bar, the tokens in named rows, a proxy's budget, the branch's diff | +| `minimal` | bottom | how full the context is, and what changed | +| `default` | bottom | the bar, where the tokens went, what changed, how long | +| `detailed` | bottom | everything the built-ins know, for a wide window | + +``` +Context +████████████████ +tokens 85.2k · 43% +in 265 · 0% +out 60 · 0% +cache 84.9k · 100% +────────────── +spend $26.24 +avail $173.76 · 87% left +────────────── +git 5f +312 -48 vs main +``` + +That is the `sidebar` preset, and what you get with no configuration at all. A row with nothing to +say is not drawn — `spend` and `avail` with no proxy writing a budget, a hairline with nothing on +one side of it. `{ "status": { "sidebar": false } }`, or `"surface": "bottom"`, draws the `default` +line under the prompt instead. Anything you write beside a preset wins, so it is a starting point +and not a mode; a name that is not a preset draws the surface's own line, with a `!` row naming the +presets there are. + +## Your own line ```jsonc { - "statusline": { + "status": { "surface": "bottom", "separator": " │ ", "segments": [ @@ -32,8 +72,13 @@ fatal: a config written against a newer version costs you a segment, not the lin | `git.branch` | current branch, dimmed on the default branch | | | `git.diff` | `+150 / -30` — what is uncommitted in the working tree | | | `model` | `claude-opus-5` | `full` | -| `context` | how full the window is | `style`, `width`, `warnAt`, `dangerAt` | -| `tokens` | `78.5k tok` | | +| `context` | how full the window is | `style`: `percent` \| `bar` \| `solid` \| `gradient` \| `split`, `width`, `warnAt`, `dangerAt` | +| `tokens` | `78.5k tok`; `tokens 85.2k · 43%` as a table row | `format`, `style`: `parts` \| `row` | +| `title` | `Context`, bold: a column's heading | `text` | +| `in` · `out` · `cache` · `write` | `cache 84.9k · 100%` — one part of the window and its share; nothing when zero | | +| `sep` | a hairline between groups, drawn only with a row on either side | `width` | +| `spend` · `avail` | `spend $26.24`, `avail $173.76 · 87% left` — a proxy's budget; nothing without one | `file` | +| `git` | `git 5f +312 -48 vs main` — the branch against where it forked, commits and uncommitted | | | `cost` | session spend | `currency`, `showZero` | | `todo` | `3/7 todo` | `showComplete` | | `session.status` | `working 1m02s` since your prompt, or a retry and its countdown | | @@ -85,7 +130,7 @@ them testable without a filesystem: ```jsonc { - "statusline": { + "status": { "modules": [""], "commands": { "tree": { "run": "git diff --shortstat", "intervalMs": 5000 } }, "segments": [ @@ -103,42 +148,50 @@ a line — the `worktree` segment in `examples/bottom.ts` reads that and draws ` ## Replacing OpenCode's own sidebar blocks -The sidebar you see is not one panel — each block is an **internal plugin**, and `tui.json` can -switch any of them off: +The sidebar you see is not one panel — each block is a plugin of OpenCode's own, which its config +can switch off. The names differ by version: -```jsonc -// ~/.config/opencode/tui.json +```jsonc title="OpenCode 1 — ~/.config/opencode/tui.json" { "plugin": ["opencode-cockpit"], "plugin_enabled": { "internal:sidebar-context": false } } ``` -That removes OpenCode's own `Context / tokens / % used / spent` block, leaving the space to a -`sidebar` line of your own. It is the honest way to avoid the same figure twice: rather than this -bay staying quiet about what the host says, you turn off the half you would rather not read. +```jsonc title="OpenCode 2 — ~/.config/opencode/cli.json" +{ "plugins": ["opencode-cockpit", "-opencode.sidebar.context"] } +``` -| Plugin | What it draws | +That removes OpenCode's own `Context / tokens / % used / spent` block, leaving the space to the +table. It is the honest way to avoid the same figure twice: rather than this bay staying quiet about +what the host says, you turn off the half you would rather not read. `/cockpit-setup` offers it when +Status draws in the sidebar. + +On OpenCode 1 the other blocks can go the same way: + +| Plugin (OpenCode 1) | What it draws | | --- | --- | | `internal:sidebar-context` | tokens, context percentage, spend | | `internal:sidebar-files` | files this session changed | -| `internal:sidebar-todo` | the todo list | | `internal:sidebar-lsp` | language-server status | | `internal:sidebar-mcp` | MCP server status | | `internal:sidebar-footer` | the path and version at the bottom | | `internal:home-footer`, `internal:home-tips` | the home screen's furniture | -| `internal:notifications` | toasts | -`api.plugins.list()` prints the current set, so the list above can be checked rather than trusted. +On OpenCode 2, `-internal:sidebar-context` does nothing — the block is `opencode.sidebar.context` — +and its sidebar has no LSP or Todo block. Leave OpenCode's Todo block on: nothing in Cockpit +replaces it. `api.plugins.list()` prints the current set, so the list above can be checked rather +than trusted. ## The context segment -Four styles, because a context meter is the segment people care most about. +Five styles, because a context meter is the segment people care most about. | `style` | Draws | | --- | --- | | `percent` | `39% ctx` | | `bar` | a plain bar with end caps | +| `solid` | one solid bar on a dark track, no figure — the `sidebar` table's | | `gradient` | a bar whose every cell is coloured by the level it stands for, green through amber to red | | `split` | one bar coloured by what fills it — cache, fresh input, output | @@ -155,7 +208,7 @@ Both hide themselves where no context window was declared. A percentage needs a ```jsonc { - "statusline": { + "status": { "lines": [ { "surface": "bottom", "segments": ["git.diff", "todo", "session.time"] }, { "surface": "sidebar", "segments": ["context", "cost"] } @@ -172,7 +225,7 @@ Each line takes its own settings: | --- | --- | | `separator` | `" · "` across, nothing down | | `stack` | `vertical` in the sidebar, `horizontal` elsewhere | -| `maxRows` | `8`, vertical only | +| `maxRows` | the bay's `sidebarRows` (8; the `sidebar` preset 14), vertical only | | `icons` | on | | `paddingLeft` / `Right` / `Top` / `Bottom` | per surface, to line up with OpenCode's own content | @@ -192,17 +245,14 @@ On by default, in single-width glyphs — an emoji is two cells wide in most ter few, which is exactly what shears a fixed-width line. Turn them off with `"icons": false`, globally or per line, or set your own per segment with `"icon": "»"`. -## sidebarOrder +## Where it sits in the sidebar -Where the line sits among the other bays in the sidebar. Lower draws first; the statusline defaults -to 140, Subagents to 150 and Shell's list to 170, so the line is on top. +The top-level `"sidebar"` list says, for every bay at once — Status first by default: ```json title="~/.config/opencode-cockpit/config.json" -{ - "statusline": { "surface": "sidebar", "sidebarOrder": 180 } -} +{ "sidebar": ["subagents", "shell", "status", "trail", "trust"] } ``` -That puts the line under the shells. `"sidebar": ["shell", "subagents", "status"]` at the top of the -same file orders every bay at once. It has no effect on the bottom surface, where there is nothing -to share the row with. +That puts the table under the shells. It has no effect on the bottom surface, where there is nothing +to share the row with. A `sidebarOrder` in the `status` section is no longer read; it is a `!` row +pointing here. See [Configuration](/opencode-cockpit/configuration/#the-sidebar-order). diff --git a/site/src/content/docs/status/drawing.md b/site/src/content/docs/status/drawing.md index d215fb60..5371e9fe 100644 --- a/site/src/content/docs/status/drawing.md +++ b/site/src/content/docs/status/drawing.md @@ -82,7 +82,8 @@ rows(ctx: StatusContext): Piece[] { } ``` -A column keeps at most `maxRows` rows (default 8) — count them and raise it, or the extras vanish. +A column keeps at most `sidebarRows` rows (default 8; a line in `lines` takes its own `maxRows`) — +count them and raise it, or the extras vanish. The preview prints `↳ N dropped` when that happens. ## Alignment @@ -110,8 +111,8 @@ find: ## Let an agent draw it -Type **`/statusline`** in OpenCode. It draws nothing: it hands the agent already in your session a -brief carrying what it cannot look up — which config file this project reads, what is drawing right +Type **`/status-setup`** in OpenCode (`/statusline` until 0.9). It draws nothing: it hands the agent +in your session a brief carrying what it cannot look up — which config file this project reads, what is drawing right now, your modules and any that failed to load, the preset and segment names — and ends by asking what you want it to show. diff --git a/site/src/content/docs/status/modules.md b/site/src/content/docs/status/modules.md index e6f92c51..d3bd7034 100644 --- a/site/src/content/docs/status/modules.md +++ b/site/src/content/docs/status/modules.md @@ -26,7 +26,7 @@ export default { ```jsonc { - "statusline": { + "status": { "modules": ["~/.config/opencode-cockpit/statusline.ts"], "segments": ["burn", "git.diff"] } @@ -87,7 +87,7 @@ empty box with no explanation. - **A segment that throws loses only its own place.** The rest of the line draws. - **A module that will not load says so on the line itself**, as a `!` row naming the file, along with a toast and an entry in OpenCode's log. Its segments never disappear silently. -- **A column says how many rows did not fit**, as a dim `↳ N more — raise maxRows`. A row that +- **A column says how many rows did not fit**, as a dim `↳ N more — raise sidebarRows`. A row that simply never appears reads as a broken segment, and is the more expensive thing to debug. ## Keeping history @@ -121,17 +121,15 @@ session sitting at a steady 39% draws a flat wall of identical blocks. ## Worked examples -Five ship with the package, every one loaded and asserted by the test suite so none of them can rot: +Two ship with the package, both loaded and asserted by the test suite so neither can rot: - `examples/bottom.ts` — a complete line for a window with no sidebar: a capacity bar with a scale, a sparkline, spend per minute, cache share -- `examples/sidebar.ts` — a quiet column **beside** OpenCode's own Context block -- `examples/sidebar-full.ts` — a column that **replaces** that block, so it carries the percentage, - the token total and the spend the block carried -- `examples/sidebar-budget.ts` — the same column as a table: a fixed label gutter, one bar with no - figure beside it, a budget read from a proxy, and the branch's whole diff - `examples/gallery.ts` — not a statusline: every technique the renderer can draw, labelled +The sidebar examples (`sidebar.ts`, `sidebar-full.ts`, `sidebar-budget.ts`) became the `sidebar` +preset in 0.9, built-ins only; a config still pointing at one gets a `!` row saying so. + Copy one and cut it down. They are written to be edited, not run verbatim. ```sh diff --git a/site/src/content/docs/status/overview.md b/site/src/content/docs/status/overview.md index b9caf15a..4d2c45cf 100644 --- a/site/src/content/docs/status/overview.md +++ b/site/src/content/docs/status/overview.md @@ -3,19 +3,48 @@ title: Statusline description: A statusline for OpenCode you can actually configure — and what it deliberately refuses to say. --- -Statusline puts a line of live session state under your conversation, or a column of it in the -sidebar. Bay 02, new in 0.3.0. +Statusline puts a column of live session state at the top of the sidebar, or a line of it under +your conversation. Bay 02, new in 0.3.0. -![The statusline under an OpenCode conversation: a context bar at 40%, the token total with its cache, input and output parts, what is uncommitted, elapsed time and todo progress](/opencode-cockpit/media/statusline.png) +## What it shows by default, and why -That is the default line — what you get having written no configuration at all. +With no configuration at all, a table in the sidebar: -## What it shows by default, and why +``` +Context +████████████████ +working 1m02s +tokens 85.2k · 43% +in 265 · 0% +out 60 · 0% +cache 84.9k · 100% +────────────── +spend $26.24 +avail $173.76 · 87% left +────────────── +git 5f +312 -48 vs main +``` + +The `sidebar` preset, and the default since 0.9: how full the window is as one solid bar, the tokens +broken into named rows with their share of it, a proxy's budget, and the branch's whole diff against +where it forked — what a reviewer will read. Every number gets a word, in a fixed column so the +figures line up; colour is a level (calm, then the warning, then the error), never a label. A row +with nothing to say is not drawn: `write` with no cache writes, `spend` and `avail` with no proxy +writing a budget ([Proxies](/opencode-cockpit/status/proxies/)), `working` while nothing runs. It sits +beside OpenCode's own Context block; to keep only one, see +[Replacing OpenCode's own sidebar blocks](/opencode-cockpit/status/configuration/#replacing-opencodes-own-sidebar-blocks). + +### At the bottom instead + +`{ "status": { "sidebar": false } }` — or `"surface": "bottom"` — draws the `default` line under the +conversation: + +![The statusline under an OpenCode conversation: a context bar at 40%, the token total with its cache, input and output parts, what is uncommitted, elapsed time and todo progress](/opencode-cockpit/media/statusline.png) OpenCode's own furniture already carries a lot. Its footer has the path, the branch and the token count. Its sidebar has the context percentage and the spend. Its prompt has the agent and the model. -The default line repeats one of those on purpose — the token count and the percentage — because it +The bottom line repeats one of those on purpose — the token count and the percentage — because it says them better: a bar you read without looking, with the total's parts beside it, is a different instrument from `78.5K (39%)` in a corner. What stays out are the facts a second copy adds nothing to: the path, the branch, the model, the spend. @@ -55,8 +84,8 @@ See [Proxies](/opencode-cockpit/status/proxies/). | `surface` | Where | Good for | | --- | --- | --- | +| `sidebar` | the sidebar, stacked vertically — the default | a table: every figure with its word | | `bottom` | full-width line under the conversation | everything, when no sidebar is open | -| `sidebar` | the sidebar, stacked vertically by default | trends and composition — the time dimension | There were three. The third sat inside the prompt box, which is both the narrowest place in the window and the one OpenCode already fills with the agent, the model and the elapsed time — so a line @@ -68,7 +97,8 @@ Every segment carries a priority. A line too wide for its surface drops the lowe until it fits, so how full the context is survives a 60-column window and the version string does not. A hard right-cut would have kept whichever segments happened to sit on the left. -A vertical line drops by `maxRows` instead, and each row is cut to the column's width. +A column drops by `sidebarRows` instead (a line's own `maxRows`), says how many with a `↳ N more` +row, and cuts each row to the column's width. ## Keys and commands @@ -78,10 +108,13 @@ have given to a bay you actually operate would be charging you for the privilege | Command | Does | | --- | --- | -| `/statusline` | Hands the agent a brief on your line: which config file this project reads, what is in it, and anything that failed to load | +| `/status-setup` | Hands the agent a brief on your line: which config file this project reads, what is drawing, the settings to fix first, and anything that failed to load | +| `/cockpit-setup` | The same for every bay at once: which show, where, in what order — see [Configuration](/opencode-cockpit/configuration/) | -`/statusline` writes into the conversation rather than opening a panel, because the useful next step -is usually "change this for me", and the agent needs to know what it is changing. +`/status-setup` writes into the conversation rather than opening a panel, because the useful next +step is usually "change this for me", and the agent needs to know what it is changing. From the home +screen it opens a conversation for the brief. `/statusline`, its name until 0.9, still works for one +release and says the new name. ## Three ways to configure it diff --git a/site/src/content/docs/status/proxies.md b/site/src/content/docs/status/proxies.md index 66238830..f4d806f2 100644 --- a/site/src/content/docs/status/proxies.md +++ b/site/src/content/docs/status/proxies.md @@ -49,7 +49,7 @@ estimate cannot match that. Read the real figure with a ```jsonc { - "statusline": { + "status": { "commands": { "budget": { "run": "curl -sf $LLM_PROXY/spend | jq -r .remaining", "intervalMs": 30000 } }, diff --git a/site/src/content/docs/subagents/overview.md b/site/src/content/docs/subagents/overview.md index fc430227..b00218bc 100644 --- a/site/src/content/docs/subagents/overview.md +++ b/site/src/content/docs/subagents/overview.md @@ -32,16 +32,17 @@ Subagents 1 running · 1 needs you A spinner is working and `○` waits on you — the two in colour, because they are the two that may need you; a red `�` failed. A finished subagent is a quiet `�` and one row; one you stopped says `stopped`, quietly too, since stopping is not failing. The heading counts every state, and keeps -what needs you when the column is narrow. The agent is named unless it is `general`, the one a -subagent is when nobody chose. A subagent that launched its own has them indented under it, and subagents with the same parent, -agent and task — a helper asked again and again — are one entry with a count (`×6`). A finished -nested one leaves the sidebar after `hideNestedAfter` seconds (30 by default); the heading still -counts it and the pane still reaches it. Working subagents are always shown; finished ones fold -into a count past `sidebarRows`, and leave after `hideFinishedAfter` minutes if you set it. No -subagents, no block. - -The sidebar reads statusline, subagents, shells, top to bottom — `"sidebar"` in -[Cockpit's config](/configuration/) reorders it. +what needs you when the column is narrow. Every row names its agent, muted — `general` too — and +the agent's name shortens before the title does; a subagent with no title is named by its task's +first words. A subagent that launched its own has them indented under it, and subagents with the +same parent, agent and task — a helper asked again and again — are one entry with a count (`×6`). A +finished nested one leaves the sidebar after `hideNestedAfterSeconds` (30 by default); the heading +still counts it and the pane still reaches it. Working subagents are always shown; finished ones +fold into a count past `sidebarRows`, and leave after `hideFinishedAfterMinutes` if you set it. With +none yet the block says so — its heading and `none yet` — unless `hideWhenEmpty` is on. + +The sidebar reads status, subagents, shells, trail, top to bottom — `"sidebar"` in +[Cockpit's config](/opencode-cockpit/configuration/#the-sidebar-order) reorders it. ## The pane @@ -156,18 +157,27 @@ on, under the same condition on OpenCode 1. ## Settings -In the bundle's entry (`"subagents": { … }`) or this package's own: +In the `subagents` section of `~/.config/opencode-cockpit/config.json`, or a project's +`.cockpit.json` — read by both halves: + +```jsonc +{ "subagents": { "sidebarRows": 6, "hideWhenEmpty": false, "hideFinishedAfterMinutes": 60 } } +``` | Setting | Default | | | --- | --- | --- | | `sidebarRows` | `6` | Subagents shown before the rest fold into a count, working ones first | -| `hideFinishedAfter` | unset | Minutes a finished subagent stays in the sidebar; unset keeps it for the conversation | -| `hideNestedAfter` | `30` | Seconds a finished *nested* subagent — one a subagent launched — stays in the sidebar; a negative number keeps them | -| `sidebarOrder` | `150` | Where the block sits among sidebar blocks; lower draws first | +| `hideWhenEmpty` | `false` | With no subagents the block says `none yet` under its heading; `true` draws nothing | +| `hideFinishedAfterMinutes` | unset | Minutes a finished subagent stays in the sidebar; unset keeps it for the conversation | +| `hideNestedAfterSeconds` | `30` | Seconds a finished *nested* subagent — one a subagent launched — stays in the sidebar; a negative number keeps them | | `keybinds` | `{ "cockpit.subagents.open": "w" }` | The key that opens the one working now | | `guidance` | `true` | Tell the main agent about background subagents and follow-ups (agent side) | +| `enabled` | `true` | `false` switches both halves off; so does `features.subagents: false` | -Switch the whole bay off in the bundle with `{ "features": { "subagents": false } }`. +Where the block sits is the top-level `"sidebar"` list's to say. The names from before 0.9 — +`hideFinishedAfter`, `hideNestedAfter`, `sidebarOrder` — are no longer read: the block shows a `!` +row naming the new one, and `/cockpit-setup` fixes it. See +[Configuration](/opencode-cockpit/configuration/). ## Known limits diff --git a/site/src/content/docs/trust/overview.md b/site/src/content/docs/trust/overview.md index 8b7ae27f..7e630a43 100644 --- a/site/src/content/docs/trust/overview.md +++ b/site/src/content/docs/trust/overview.md @@ -268,9 +268,12 @@ In the bundle's entry (`"trust": { … }`), the package's own, or the `trust` se | `enabled` | `true` | `false` turns Trust off | | `sidebar` | `false` | Show the block in the sidebar. The palette's "Show or hide Trust in the sidebar" flips it for the session | | `sidebarRows` | `3` | Answers listed in the sidebar | -| `sidebarOrder` | `160` | Where the block sits in the sidebar; lower draws first | | `keybinds` | `{ "cockpit.trust.ledger": "p" }` | The key that opens the ledger | +Where the block sits is the top-level `"sidebar"` list's to say — Trust last by default; a +`trust.sidebarOrder` from before 0.9 is no longer read. See +[Configuration](/opencode-cockpit/configuration/#the-sidebar-order). + ## Where it keeps what it learned `~/.local/share/opencode-cockpit/trust/-/events.ndjson` — outside the project, so it diff --git a/site/src/content/docs/updater/overview.mdx b/site/src/content/docs/updater/overview.mdx index 92eeda29..c7334bfc 100644 --- a/site/src/content/docs/updater/overview.mdx +++ b/site/src/content/docs/updater/overview.mdx @@ -100,4 +100,5 @@ again. To turn it off: { "updater": { "updateCheck": false } } ``` -Shell's old `ui.updateCheck: false` is honoured too. +Shell's old `ui.updateCheck` is no longer read: a file that still has it gets a doctor line naming +`updater.updateCheck`, and `/cockpit-setup` moves it here. diff --git a/site/src/data/landing.ts b/site/src/data/landing.ts index 06e1ca50..0c671c0d 100644 --- a/site/src/data/landing.ts +++ b/site/src/data/landing.ts @@ -38,9 +38,9 @@ export const hero = { export const specs = [ { value: "1", label: "daemon, shared by every window" }, - { value: "9", label: "agent tools" }, + { value: "17", label: "agent tools" }, { value: "35", label: "watch presets" }, - { value: "14", label: "statusline segments" }, + { value: "23", label: "statusline segments" }, ] export const compare = { @@ -197,18 +197,18 @@ export const bays = { points: [ "A capacity bar that means something at a glance", "Tokens split into cache, input and output", - "Fourteen segments, or your own in TypeScript", + "A table in the sidebar by default, or a line under the prompt", + "Twenty-three segments, or your own in TypeScript", "Your Claude Code statusline script runs unchanged", - "Colours follow whatever theme you run", "Silent about anything the host already says better", ], - foot: ["14 segments", "2 surfaces", "@opencode-cockpit/status"], + foot: ["23 segments", "2 surfaces", "@opencode-cockpit/status"], docs: "/status/overview/", media: { kind: "image" as const, src: "/media/statusline.png", alt: "The statusline under an OpenCode conversation: a context bar at 40 per cent, the token total with its cache, input and output parts, the session diff, and elapsed time", - caption: "the default line · no configuration written at all", + caption: "the bottom line · { \"status\": { \"sidebar\": false } }", }, }, { @@ -406,7 +406,7 @@ export const install = { steps: [ "Run the command above — on OpenCode 1 it writes both plugin entries, on OpenCode 2 one entry loads both halves.", "Restart OpenCode. The daemon starts on first use and exits when idle.", - "Optional: put kinds, watch presets and defaults in ~/.config/opencode-cockpit/config.json.", + "Optional: type /cockpit-setup and the agent sets up ~/.config/opencode-cockpit/config.json with you — which bays show, where, in what order.", "Something off? npx opencode-cockpit@latest doctor checks your setup and prints the fix.", ], } diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro index ebce4c38..0015919f 100644 --- a/site/src/pages/index.astro +++ b/site/src/pages/index.astro @@ -13,7 +13,7 @@ import Closing from "../components/landing/Closing.astro" ---