diff --git a/CHANGELOG.md b/CHANGELOG.md index af3795b..ede3afb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,42 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +### Added + +- **Cockpit runs on OpenCode 2.** Every package now loads on OpenCode 1.18+ and 2.0.15+ from the + same entry: the panels, console, Review, statusline and updater in the interface, and the `shell_*` + and `review_*` tools, system guidance and exit notifications on the agent side. Each bay is written + once against a host (`@opencode-cockpit/client/host` and `/server`) that each version supplies. On + OpenCode 2, `/plugins-update` says to change the version in `opencode.json`: the updater edits + OpenCode 1's files only. + +- **One log for everything Cockpit does inside OpenCode.** Both halves of every bay write JSON lines + to `~/.cache/opencode-cockpit/cockpit.log`, beside the daemon's `cockpitd.log`: which OpenCode + (v1 or v2, and its version) loaded which entry, every error with its stack — including ones that + used to be a toast and nothing else — and every tool that failed. `COCKPIT_DEBUG=1 opencode` adds + the detail (console actions, each tool call and how long it took) and turns the daemon's debug + lines on too. The file moves to `cockpit.log.1` past 5 MB. + +- **`npx opencode-cockpit@latest doctor`.** Checks a setup and prints the fix for anything wrong, + for the OpenCode you have: its version; every Cockpit entry in `opencode.json`, `tui.json` and + `cli.json` in either OpenCode's spelling — a bay configured twice, a half missing on OpenCode 1, a + pin older than the newest release, a checkout OpenCode 2 cannot load; what the log says last ran, + and on which OpenCode; recent errors; the daemon; `git` and `ps`; settings files and statusline + modules. Runs under Node outside OpenCode, so it works when Cockpit will not load. `--json` for an + issue; exits 1 when something must be fixed. + +### Changed + +- `createShellServer` and `createReviewServer` return a feature for `dualServer` rather than a v1 + plugin function. + +### Fixed + +- **Full-screen shell console and the Review pane were see-through on transparent themes.** A theme + that leaves its background transparent (OpenCode's "system" theme, which shows the terminal's own) + painted the full-window surface with nothing, and the conversation showed through it. They now use + the first opaque background the theme has, and a solid one when it has none. + ## [0.5.2] - 2026-09-24 ### Added diff --git a/README.md b/README.md index e38553f..591282e 100644 --- a/README.md +++ b/README.md @@ -188,10 +188,24 @@ installed — and shows every plugin you have, not just this one: npx opencode-cockpit@latest update # or: bunx opencode-cockpit@latest update ``` -Restart OpenCode. Requires OpenCode 1.18+ on macOS or Linux. Install a feature either through +Restart OpenCode. Requires OpenCode 1.18+ or 2.0.15+ on macOS or Linux. Install a feature either through `opencode-cockpit` or on its own — if both are configured, the first one loaded is used and OpenCode warns you which entry to remove. +**On OpenCode 2** the same packages load — one entry serves both versions. v2 reads `plugins` (not +`plugin`) from `opencode.json` for the agent side and from `cli.json` for the interface, and passes +options as an object: + +```json +{ + "plugins": [{ "package": "opencode-cockpit@0.5.2", "options": { "features": { "shell": true } } }] +} +``` + +An existing v1 `opencode.json` with `plugin` is read by OpenCode 2 as well. To update there, change +the version in that entry — `/plugins-update` and `npx opencode-cockpit update` edit OpenCode 1's +files only. + **Turn features off** (in both `opencode.json` and `tui.json`): ```json @@ -219,6 +233,26 @@ your own commands, define watch rules, cap how long shells live, choose what may agent, and trade context tokens for accuracy. Each feature's README documents its own settings: [Shell](packages/shell#configuration). +## Troubleshooting + +```sh +npx opencode-cockpit@latest doctor +``` + +checks OpenCode, its config, Cockpit's logs and the daemon, and prints the fix for anything wrong — +on OpenCode 1 and 2, and when Cockpit will not load at all ([what it checks](https://codestz.github.io/opencode-cockpit/help/doctor/)). + +Everything Cockpit does inside OpenCode goes to one file — which OpenCode loaded which bay, and every +error with its stack: + +```sh +tail -50 ~/.cache/opencode-cockpit/cockpit.log +``` + +`COCKPIT_DEBUG=1 opencode` adds the detail. [Troubleshooting](https://codestz.github.io/opencode-cockpit/help/troubleshooting/) covers +the failures people hit and what to attach to an issue; [OpenCode 1 and 2](https://codestz.github.io/opencode-cockpit/start/opencode-versions/) +covers what differs between the two. + ## How it works ``` diff --git a/bun.lock b/bun.lock index a968bc7..50ec626 100644 --- a/bun.lock +++ b/bun.lock @@ -19,10 +19,15 @@ "name": "@opencode-cockpit/client", "version": "0.5.2", "dependencies": { + "@opencode-ai/plugin": "1.18.31", "@opencode-cockpit/protocol": "workspace:*", }, "devDependencies": { "@opencode-cockpit/daemon": "workspace:*", + "@opentui/core": "0.4.5", + "@opentui/keymap": "0.4.5", + "@opentui/solid": "0.4.5", + "solid-js": "1.9.12", }, }, "packages/daemon": { @@ -36,8 +41,12 @@ "packages/opencode": { "name": "opencode-cockpit", "version": "0.5.2", + "bin": { + "opencode-cockpit": "./dist/bin.js", + }, "dependencies": { "@opencode-ai/plugin": "1.18.31", + "@opencode-cockpit/client": "workspace:*", "@opencode-cockpit/review": "workspace:*", "@opencode-cockpit/shell": "workspace:*", "@opencode-cockpit/status": "workspace:*", diff --git a/bunfig.toml b/bunfig.toml index 7a2ac1c..3a6f4e5 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,3 +1,4 @@ [test] # Tests run real PTYs and a real daemon; shared CI runners can be slow to spawn processes. timeout = 20000 +preload = ["./scripts/test-env.ts"] diff --git a/package.json b/package.json index b0dffbc..495248b 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "record": "bun scripts/record.ts", "clean:daemons": "bun scripts/clean-daemons.ts", "preview": "bun packages/status/src/cli/preview.ts", - "capture": "bun scripts/capture.ts" + "capture": "bun scripts/capture.ts", + "dev:install": "bun run build && bun scripts/dev-install.ts" }, "devDependencies": { "@babel/core": "7.28.0", diff --git a/packages/client/README.md b/packages/client/README.md index 33c9db4..cc73186 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -115,6 +115,15 @@ claim.release() | `claimFeature`, `duplicateFeatureMessage`, `FeatureClaim` | First-one-wins guard for duplicate loads | | `SpawnOptions` | `entry`, `execPath`, `env` | +Subpaths, for bays: + +| Import | What it is | +| --- | --- | +| `@opencode-cockpit/client/host` | `Host`, `dualTui` — the interface half of a bay, written once for OpenCode 1 and 2 | +| `@opencode-cockpit/client/server` | `ServerHost`, `dualServer`, `composeParts` — the agent half, the same way | +| `@opencode-cockpit/client/log` | `createLog`, `Log` — the shared `cockpit.log`; `COCKPIT_DEBUG=1` for detail | +| `@opencode-cockpit/client/feature` | the duplicate-load guard on its own | + ## Requirements Bun ≥ 1.3.5. macOS and Linux. diff --git a/packages/client/package.json b/packages/client/package.json index b3216bf..39b3a05 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -27,6 +27,18 @@ "./feature": { "types": "./types/feature.d.ts", "default": "./dist/feature.js" + }, + "./host": { + "types": "./types/host.d.ts", + "default": "./dist/host.js" + }, + "./server": { + "types": "./types/server.d.ts", + "default": "./dist/server.js" + }, + "./log": { + "types": "./types/log.d.ts", + "default": "./dist/log.js" } }, "files": [ @@ -39,10 +51,15 @@ "access": "public" }, "dependencies": { - "@opencode-cockpit/protocol": "workspace:*" + "@opencode-cockpit/protocol": "workspace:*", + "@opencode-ai/plugin": "1.18.31" }, "devDependencies": { - "@opencode-cockpit/daemon": "workspace:*" + "@opencode-cockpit/daemon": "workspace:*", + "@opentui/core": "0.4.5", + "@opentui/keymap": "0.4.5", + "@opentui/solid": "0.4.5", + "solid-js": "1.9.12" }, "engines": { "bun": ">=1.3.5" diff --git a/packages/client/src/host.ts b/packages/client/src/host.ts new file mode 100644 index 0000000..a9404db --- /dev/null +++ b/packages/client/src/host.ts @@ -0,0 +1,723 @@ +/** + * What a Cockpit TUI bay needs from OpenCode, whichever OpenCode it is. + * + * OpenCode 2 replaced the plugin API (docs/opencode/v2.md). Rather than write every bay twice, a bay + * talks to `Host` — exactly the ~30 calls the bays make, named the way v1 names them — and each + * version supplies one: `fromV1(api)` wraps the v1 API almost as it is, `fromV2(ctx)` builds the same + * shape on the v2 context. `dualTui` turns one bay into an entry both versions load: v1 calls + * `tui(api)`, v2 calls `setup(ctx)`. + * + * Nothing of OpenCode is imported at runtime here, and nothing of OpenTUI but the two helpers the + * v1 half already relied on: the v2 context is described by the structural types below. + */ + +import type { TuiDialogSelectOption, TuiPluginApi, TuiThemeCurrent } from "@opencode-ai/plugin/tui" +import type { CliRenderer, KeyEvent } from "@opentui/core" +import { createComponent, createRoot, getOwner, type JSX, type Owner, onCleanup } from "solid-js" +import { cockpitVersion, createLog, type Log, silentLog } from "./log.ts" + +type V1Layer = Parameters[0] +export type Layer = V1Layer +export type Theme = TuiThemeCurrent + +/** A key the host saw, and the way to keep it from anything else. */ +export interface InterceptContext { + event: KeyEvent + consume: (options?: { preventDefault?: boolean; stopPropagation?: boolean }) => void +} + +export interface SelectOption { + title: string + value: Value + description?: string + footer?: string + category?: string + disabled?: boolean +} + +/** Which slot a render goes into. The names are v1's; `fromV2` maps them onto v2's paths. */ +export type SlotName = "app_bottom" | "sidebar_content" | "home_bottom" | "session_prompt_right" +export type SlotRender = (input?: { sessionID?: string }) => JSX.Element + +export interface Host { + /** Which OpenCode this is. Bays should rarely need it. */ + readonly version: 1 | 2 + readonly renderer: CliRenderer + readonly theme: { readonly current: Theme } + readonly state: { + readonly path: { readonly directory: string; readonly worktree: string } + readonly vcs: { readonly branch?: string; readonly default_branch?: string } | undefined + } + readonly route: { readonly current: { name: string; params?: Record } } + readonly kv: { + get(key: string, fallback: T): T + set(key: string, value: unknown): void + } + readonly ui: { + toast(options: { + title?: string + message: string + variant?: "info" | "success" | "warning" | "error" + duration?: number + }): void + readonly dialog: { + replace(render: () => JSX.Element, onClose?: () => void): void + clear(): void + setSize(size: "medium" | "large" | "xlarge"): void + readonly depth: number + } + select(options: { + title: string + placeholder?: string + current?: Value + options: SelectOption[] + }): Promise + /** + * Text from the person. `rich` draws above the field where the host can (v1's component dialog); + * elsewhere `description` says it in words. + */ + prompt(options: { + title: string + description?: string + rich?: () => JSX.Element + placeholder?: string + value?: string + }): Promise + confirm(options: { title: string; message: string }): Promise + } + readonly keymap: { + /** A global layer, until the returned function disposes it. */ + registerLayer(layer: Layer): () => void + /** A layer owned by the calling component, for as long as it is mounted. */ + useLayer(layer: () => Layer): void + /** Every key before the keymap sees it; `consume` keeps it from everyone else. */ + intercept(handler: (context: InterceptContext) => void, options?: { priority?: number }): () => void + /** The key a command is bound to, formatted for a hint. */ + shortcut(command: string): string + } + readonly slots: { + register(input: { order?: number; slots: Partial> }): void + } + readonly lifecycle: { onDispose(fn: () => void): void } + /** The shared log (`cockpit.log`), scoped `tui`; a bay takes `log.child("shell")`. */ + readonly log: Log + /** Hands text to a conversation, as if the person had sent it. */ + promptSession(sessionID: string, text: string): Promise + /** The v1 API itself, for the calls that have no v2 equivalent. */ + readonly v1?: TuiPluginApi + /** The v2 context itself, likewise. */ + readonly v2?: V2Context +} + +/* ─── keys, without @opentui/keymap ───────────────────────────────────────────────────────────── */ + +/** + * OpenCode 2 lets a plugin import `@opentui/core` and `solid-js` and nothing else of OpenTUI: an + * import of `@opentui/keymap` fails, and a plugin whose module fails to load is skipped without a + * word (measured on 2.0.15). So the two keymap helpers the bays used are re-made here from nothing. + */ + +/** A configured key: a key string, several, an object with a `key`, or `false`/"none" for unbound. */ +export type BindingValue = string | false | { key: string } | readonly (string | { key: string })[] +export interface Binding { + key: string + cmd: string + [field: string]: unknown +} + +/** `createBindingLookup` as the bays used it: config `{ command: key }` → `{ key, cmd }` bindings. */ +export function bindingLookup(config: Readonly>) { + const byCommand = new Map() + for (const [cmd, value] of Object.entries(config)) { + if (value === undefined || value === false || value === "none") continue + const items = (Array.isArray(value) ? value : [value]) as (string | { key: string })[] + const bindings = items.map((item) => (typeof item === "string" ? { key: item, cmd } : { ...item, cmd })) + if (bindings.length > 0) byCommand.set(cmd, bindings) + } + return { + get: (command: string): Binding[] => byCommand.get(command) ?? [], + gather: (_name: string, commands: readonly string[]): Binding[] => + commands.flatMap((command) => byCommand.get(command) ?? []), + } +} + +/** A v1 layer owned by the calling component: registered now, disposed when the component goes. */ +export function useApiLayer(api: TuiPluginApi, layer: () => Layer): void { + const dispose = api.keymap.registerLayer(layer()) + onCleanup(dispose) +} + +/* ─── v1 ─────────────────────────────────────────────────────────────────────────────────────── */ + +export function fromV1(api: TuiPluginApi, log: Log = silentLog): Host { + const pick = ( + options: Parameters[0] & { options: SelectOption[] }, + ): Promise => + new Promise((resolve) => { + let done = false + const finish = (value: Value | undefined) => { + if (done) return + done = true + resolve(value) + } + api.ui.dialog.replace( + () => + createComponent( + api.ui.DialogSelect as (props: never) => JSX.Element, + { + title: options.title, + ...(options.placeholder ? { placeholder: options.placeholder } : {}), + ...(options.current !== undefined ? { current: options.current } : {}), + options: options.options as TuiDialogSelectOption[], + /** Answer first: clearing fires the close handler, which would settle it as cancelled. */ + onSelect: (option: TuiDialogSelectOption) => { + finish(option.value) + api.ui.dialog.clear() + }, + } as never, + ), + () => finish(undefined), + ) + }) + + return { + version: 1, + renderer: api.renderer as CliRenderer, + theme: api.theme, + state: api.state as Host["state"], + route: api.route as Host["route"], + kv: api.kv as Host["kv"], + ui: { + toast: (options) => api.ui.toast(options), + dialog: api.ui.dialog, + select: pick, + prompt: (options) => + new Promise((resolve) => { + api.ui.dialog.replace( + () => + createComponent( + api.ui.DialogPrompt as (props: never) => JSX.Element, + { + title: options.title, + ...(options.rich + ? { description: options.rich } + : options.description + ? { description: () => options.description } + : {}), + placeholder: options.placeholder ?? "", + value: options.value ?? "", + /** Answer first: clearing fires the close handler, which would settle it as cancelled. */ + onConfirm: (text: string) => { + resolve(text) + api.ui.dialog.clear() + }, + onCancel: () => { + resolve(undefined) + api.ui.dialog.clear() + }, + } as never, + ), + () => resolve(undefined), + ) + }), + confirm: (options) => + new Promise((resolve) => { + api.ui.dialog.replace( + () => + createComponent( + api.ui.DialogConfirm as (props: never) => JSX.Element, + { + title: options.title, + message: options.message, + onConfirm: () => { + resolve(true) + api.ui.dialog.clear() + }, + onCancel: () => { + resolve(false) + api.ui.dialog.clear() + }, + } as never, + ), + () => resolve(false), + ) + }), + }, + keymap: { + registerLayer: (layer) => api.keymap.registerLayer(layer), + useLayer: (layer) => useApiLayer(api, layer), + intercept: (handler, options) => api.keymap.intercept("key", handler as never, options), + shortcut: (command) => { + const bindings = api.keymap.getCommandBindings({ visibility: "registered", commands: [command] }) + return api.keys.formatBindings(bindings.get(command)) ?? "" + }, + }, + slots: { register: (input) => api.slots.register(input as never) }, + lifecycle: api.lifecycle, + log, + promptSession: async (sessionID, text) => { + await api.client.session.promptAsync({ sessionID, parts: [{ type: "text", text }] }) + }, + v1: api, + } +} + +/* ─── v2 ─────────────────────────────────────────────────────────────────────────────────────── */ + +/** The parts of OpenCode 2's CLI plugin context used here (`@opencode/plugin/tui/context`). */ +export interface V2Context { + readonly options: Readonly> + readonly location: { directory: string; project?: { directory?: string } } | undefined + readonly renderer: CliRenderer + readonly client: unknown + readonly theme: V2Theme + readonly data: { + readonly location: { + default(): { directory: string } | undefined + readonly vcs: { + sync(location?: unknown): Promise + info(location?: unknown): { branch?: { current?: string; default?: string } } | undefined + } + readonly model?: { + list(location?: unknown): unknown[] | undefined + sync(location?: unknown): Promise + } + readonly mcp?: { readonly server: { list(location?: unknown): unknown[] | undefined } } + } + readonly session: { + prompt?(input: unknown): Promise + get?(sessionID: string): unknown + status?(sessionID: string): unknown + sync?(sessionID: string): Promise + readonly message?: { list(sessionID: string): unknown[]; sync(sessionID: string): Promise } + } + } + readonly keymap: { + layer(input: () => V2Layer): void + shortcuts(id: string): readonly string[] + } + readonly storage: { + store( + key: string, + options: { initial: Value }, + ): readonly [Value, (mutation: (draft: Value) => void) => Promise] + } + readonly ui: { + readonly dialog: { + show(render: () => JSX.Element, onClose?: () => void): void + set(options: { size?: "medium" | "large" | "xlarge"; centered?: boolean }): void + clear(): void + confirm(options: { title: string; message: string }): Promise + prompt(options: { + title: string + description?: string + placeholder?: string + value?: string + }): Promise + select(options: { + title: string + placeholder?: string + options: readonly SelectOption[] + current?: Value + }): Promise + } + readonly toast: { + show(options: { title?: string; message: string; variant?: string; duration?: number }): void + } + readonly router: { current(): { type: string; sessionID?: string } } + slot(claim: { render: (input: never) => JSX.Element } & Record): () => void + } +} + +type Colour = TuiThemeCurrent["text"] +type States = { base: Colour } & Record +interface V2Theme { + text: { + base: Colour + muted: Colour + /** A colour per state (`base`, `hovered`, `focused`…), not a colour. */ + action: { primary: States; secondary: States } + feedback: { error: States; warning: States; success: States; info: States } + } + background: { base: Colour; raised: { base: Colour; high: Colour; max: Colour } } + border: { base: Colour } + scrollbar?: { base: Colour } + diff: { + text: { added: Colour; removed: Colour; context: Colour; hunkHeader: Colour } + background: { added: Colour; removed: Colour; context: Colour } + highlight: { added: Colour; removed: Colour } + lineNumber: { text: Colour; background: { added: Colour; removed: Colour } & Record } + } + /** The palettes the tokens are built from: `hue.accent[200]`… Shades are relative to the mode. */ + hue?: Record> + /** Which palette and shade a token was built from. */ + source?: (colour: Colour) => { hue: string; step: number | string } | undefined + syntax: Record< + | "comment" + | "keyword" + | "function" + | "variable" + | "string" + | "number" + | "type" + | "operator" + | "punctuation", + Colour + > + markdown: Record +} + +interface V2Command { + id?: string + title?: string + group?: string + bind?: false | string + palette?: true + slash?: { name: string; aliases?: string[] } + enabled?: boolean | (() => boolean) + run: (input?: string, event?: KeyEvent) => void | false | Promise +} +interface V2Layer { + mode?: string + enabled?: boolean | (() => boolean) + priority?: number + commands?: readonly V2Command[] + bindings?: readonly string[] +} + +/** + * v2's token theme under v1's names, so every bay's colour table keeps working. Each read goes to + * the live theme, so a theme switch is picked up on the next paint. + */ +/** An OpenTUI colour: v1's and v2's both keep their channels in a `buffer`. */ +const isColour = (value: unknown): value is Colour => + typeof value === "object" && value !== null && "buffer" in value + +export function themeFromV2(theme: () => V2Theme): Theme { + /** + * v2 has no token for v1's accent, primary or secondary: they are palettes (`hue.accent`, + * `hue.interactive`, `hue.blue`), and the shade that reads as text is the one `text.base` is built + * from — 200 in both dark and light mode, where shades count from the text's end. `text.action.*` + * looked right and is not: it is the text *on* an action, white in the default theme, and took + * every accent in Review and the key hints with it. Measured against 2.0.15's default theme in + * both modes, next to v1 1.18.32's (docs/opencode/v2.md). + */ + const palette = (t: V2Theme, hue: string, fallback: Colour | States) => { + const step = t.source?.(t.text.base)?.step ?? 200 + return t.hue?.[hue]?.[String(step)] ?? fallback + } + const map: Record Colour | States> = { + text: (t) => t.text.base, + textMuted: (t) => t.text.muted, + primary: (t) => palette(t, "interactive", t.text.action.primary), + secondary: (t) => palette(t, "blue", t.text.action.secondary), + accent: (t) => palette(t, "accent", t.syntax.keyword), + error: (t) => t.text.feedback.error, + warning: (t) => t.text.feedback.warning, + success: (t) => t.text.feedback.success, + info: (t) => t.text.feedback.info, + background: (t) => t.background.base, + backgroundPanel: (t) => t.background.raised.base, + backgroundElement: (t) => t.background.raised.high, + backgroundMenu: (t) => t.background.raised.high, + border: (t) => t.border.base, + /** v1's subtle border has no v2 twin; the one border there is the nearest. */ + borderSubtle: (t) => t.border.base, + /** Same grey as v1's in the default theme. */ + borderActive: (t) => t.scrollbar?.base ?? t.border.base, + selectedListItemText: (t) => t.background.base, + diffAdded: (t) => t.diff.text.added, + diffRemoved: (t) => t.diff.text.removed, + diffContext: (t) => t.diff.text.context, + diffHunkHeader: (t) => t.diff.text.hunkHeader, + diffHighlightAdded: (t) => t.diff.highlight.added, + diffHighlightRemoved: (t) => t.diff.highlight.removed, + diffAddedBg: (t) => t.diff.background.added, + diffRemovedBg: (t) => t.diff.background.removed, + diffContextBg: (t) => t.diff.background.context, + diffLineNumber: (t) => t.diff.lineNumber.text, + /** A tint, not the highlight: the highlight drew the gutter as a solid green or red block. */ + diffAddedLineNumberBg: (t) => t.diff.lineNumber.background.added, + diffRemovedLineNumberBg: (t) => t.diff.lineNumber.background.removed, + syntaxComment: (t) => t.syntax.comment, + syntaxKeyword: (t) => t.syntax.keyword, + syntaxFunction: (t) => t.syntax.function, + syntaxVariable: (t) => t.syntax.variable, + syntaxString: (t) => t.syntax.string, + syntaxNumber: (t) => t.syntax.number, + syntaxType: (t) => t.syntax.type, + syntaxOperator: (t) => t.syntax.operator, + syntaxPunctuation: (t) => t.syntax.punctuation, + } + /** v1's markdown names that v2 spells differently; the rest only lose the prefix. */ + const MARKDOWN: Record = { markdownEmph: "emphasis" } + /** + * v2's action and feedback tokens are not colours but a colour per state — `{ base, hovered, + * focused, … }` — and a bay handed one of those passed it on as a colour. `base` is the colour at + * rest; anything else that is not a colour falls back to the text colour rather than breaking a + * paint. + */ + const colour = (value: unknown): Colour => { + const found = isColour(value) + ? value + : isColour((value as { base?: unknown })?.base) + ? (value as { base: Colour }).base + : undefined + return found ?? theme().text.base + } + return new Proxy({} as Theme, { + get: (_target, key) => { + if (typeof key !== "string") return undefined + const read = map[key] + if (read) return colour(read(theme())) + /** markdownText, markdownHeading…: v2 keeps them under `markdown`. */ + if (key.startsWith("markdown")) { + const name = MARKDOWN[key] ?? key.slice(8, 9).toLowerCase() + key.slice(9) + return colour(theme().markdown[name]) + } + return theme().text.base + }, + }) +} + +/** v1 slot names onto v2's slot paths. */ +const SLOT_PATHS: Record = { + app_bottom: "app", + sidebar_content: "sidebar.content", + home_bottom: "home.footer.status", + session_prompt_right: "prompt.footer.status", +} + +/** A v1 layer — commands plus `{ key, cmd }` bindings — as a v2 layer. */ +export function layerToV2(layer: Layer): V2Layer { + const bindings = (layer.bindings ?? []) as readonly { key?: string; cmd?: unknown }[] + const keysFor = (name: string) => + bindings + .filter((binding) => binding.cmd === name && typeof binding.key === "string") + .map((binding) => binding.key as string) + .join(",") + const commands = (layer.commands ?? []) as readonly { + name: string + title?: string + category?: string + namespace?: string + slashName?: string + enabled?: boolean | (() => boolean) + run: (...args: never[]) => unknown + }[] + const enabled = (layer as { enabled?: boolean | (() => boolean) }).enabled + return { + mode: "global", + ...(layer.priority !== undefined ? { priority: layer.priority } : {}), + ...(enabled !== undefined ? { enabled } : {}), + commands: commands.map((command) => { + const keys = keysFor(command.name) + return { + id: command.name, + ...(command.title ? { title: command.title } : {}), + ...(command.category ? { group: command.category } : {}), + ...(command.namespace === "palette" ? { palette: true as const } : {}), + ...(command.slashName ? { slash: { name: command.slashName } } : {}), + ...(command.enabled !== undefined ? { enabled: command.enabled } : {}), + bind: keys || false, + run: () => { + command.run() + }, + } + }), + bindings: commands.map((command) => command.name), + } +} + +export function fromV2(ctx: V2Context, onCleanup: (fn: () => void) => void, log: Log = silentLog): Host { + const location = () => ctx.location ?? ctx.data.location.default() + void ctx.data.location.vcs.sync(location()).catch(() => {}) + const [values, update] = ctx.storage.store<{ values: Record }>("cockpit", { + initial: { values: {} }, + }) + let depth = 0 + + /** + * v2 creates a key layer through the component that owns it, and finds the keymap in that + * component's context — a layer made from `setup` fails with "Keymap.Provider is missing". So one + * invisible claim on the `app` slot is mounted, its owner kept, and every global layer is made in + * a root of its own under it: disposable on its own, and inside the host's context. Layers asked + * for before that component has rendered wait for it. + */ + let keyOwner: Owner | undefined + const waiting: (() => void)[] = [] + onCleanup( + ctx.ui.slot({ + append: "app", + render: () => { + keyOwner = getOwner() ?? undefined + for (const start of waiting.splice(0)) start() + return null as unknown as JSX.Element + }, + }), + ) + const ownedLayer = (layer: Layer): (() => void) => { + let dispose: (() => void) | undefined + let gone = false + const start = () => { + if (gone) return + dispose = createRoot((done: () => void) => { + ctx.keymap.layer(() => layerToV2(layer)) + return done + }, keyOwner) + } + if (keyOwner) start() + else waiting.push(start) + return () => { + gone = true + dispose?.() + } + } + + return { + version: 2, + renderer: ctx.renderer, + theme: { current: themeFromV2(() => ctx.theme) }, + state: { + get path() { + const directory = location()?.directory ?? process.cwd() + return { directory, worktree: directory } + }, + get vcs() { + const branch = ctx.data.location.vcs.info(location())?.branch + return branch ? { branch: branch.current, default_branch: branch.default } : undefined + }, + }, + route: { + get current() { + const route = ctx.ui.router.current() + return route.type === "session" + ? { name: "session", params: { sessionID: route.sessionID } } + : { name: route.type } + }, + }, + kv: { + get: (key: string, fallback: T): T => (key in values.values ? (values.values[key] as T) : fallback), + set: (key, value) => { + void update((draft) => { + draft.values[key] = value + }) + }, + }, + ui: { + toast: (options) => ctx.ui.toast.show(options), + dialog: { + replace: (render, onClose) => { + depth = 1 + ctx.ui.dialog.show(render, () => { + depth = 0 + onClose?.() + }) + }, + clear: () => { + depth = 0 + ctx.ui.dialog.clear() + }, + setSize: (size) => ctx.ui.dialog.set({ size }), + get depth() { + return depth + }, + }, + select: (options) => ctx.ui.dialog.select(options), + prompt: ({ rich: _rich, ...options }) => ctx.ui.dialog.prompt(options), + confirm: async (options) => (await ctx.ui.dialog.confirm(options)) === true, + }, + keymap: { + registerLayer: ownedLayer, + useLayer: (layer) => ctx.keymap.layer(() => layerToV2(layer())), + intercept: (handler) => { + /** v2 has no intercept: the renderer's own key stream, ahead of every other listener. */ + const input = ctx.renderer.keyInput as unknown as { + prependListener(event: string, fn: (event: KeyEvent) => void): void + off(event: string, fn: (event: KeyEvent) => void): void + } + const listener = (event: KeyEvent) => + handler({ + event, + consume: () => { + event.preventDefault() + event.stopPropagation?.() + }, + }) + input.prependListener("keypress", listener) + return () => input.off("keypress", listener) + }, + shortcut: (command) => ctx.keymap.shortcuts(command)[0] ?? "", + }, + slots: { + register: ({ slots }) => { + for (const [name, render] of Object.entries(slots) as [SlotName, SlotRender][]) { + const path = SLOT_PATHS[name] + if (!path || !render) continue + onCleanup(ctx.ui.slot({ append: path, render: render as never })) + } + }, + }, + lifecycle: { onDispose: onCleanup }, + log, + promptSession: async (sessionID, text) => { + await ctx.data.session.prompt?.({ sessionID, text }) + }, + v2: ctx, + } +} + +/* ─── one entry, both versions ───────────────────────────────────────────────────────────────── */ + +export type Start = (host: Host, options: Record | undefined) => Promise | void + +/** + * A TUI entry both OpenCodes load: v1 calls `tui(api, options)`, v2 calls `setup(ctx)` and runs the + * returned cleanup when it unloads the plugin. + */ +export function dualTui(id: string, start: Start) { + /** + * What loaded, where, and anything that stopped it: written before the bay runs, so a bay that never + * draws still says it was loaded and on which OpenCode, and one that throws leaves its stack. + */ + const run = async ( + host: Host, + options: Record | undefined, + opencode: string | undefined, + ) => { + host.log.info("start", { + entry: id, + opencode: host.version, + opencodeVersion: opencode, + cockpit: cockpitVersion(), + }) + try { + await start(host, options) + } catch (error) { + host.log.error("start failed", { entry: id, error }) + throw error + } + } + return { + id, + tui: async (api: TuiPluginApi, options?: unknown) => { + const host = fromV1(api, createLog("tui")) + await run(host, options as Record | undefined, api.app?.version) + }, + setup: async (ctx: V2Context) => { + const cleanups: (() => void)[] = [] + const host = fromV2(ctx, (fn) => cleanups.push(fn), createLog("tui")) + await run(host, ctx.options as Record, undefined) + return () => { + for (const fn of cleanups.reverse()) { + try { + fn() + } catch (error) { + // one bay's cleanup failing must not keep the others from running + host.log.warn("cleanup failed", { entry: id, error }) + } + } + } + }, + } +} diff --git a/packages/client/src/log.ts b/packages/client/src/log.ts new file mode 100644 index 0000000..0794ca7 --- /dev/null +++ b/packages/client/src/log.ts @@ -0,0 +1,143 @@ +import { appendFileSync, mkdirSync, readFileSync, renameSync, statSync } from "node:fs" +import { dirname, join } from "node:path" +import { resolvePaths } from "@opencode-cockpit/protocol" + +/** + * What Cockpit did inside OpenCode, as JSON lines in `/cockpit.log` — both halves, every + * bay, one file, beside the daemon's `cockpitd.log` and in the same shape, so the two read as one story. + * + * Inside OpenCode a bay has nowhere else to say anything: stderr is the terminal the interface draws + * on, and a toast is gone before it is read. So errors are always written, with their stack, and + * `COCKPIT_DEBUG=1` adds everything else — keys, paints, config reads — for the one machine where + * something happens that happens nowhere else. + * + * { "t": "…", "lvl": "error", "scope": "tui:review", "msg": "paint failed", "pid": 123, "error": {…} } + * + * Synchronous, so the last lines survive a crash, and swallowed: a log line that can break the + * interface is worse than none. + */ + +export type Level = "debug" | "info" | "warn" | "error" +const ORDER: Record = { debug: 10, info: 20, warn: 30, error: 40 } + +export interface Log { + debug(msg: string, fields?: Record): void + info(msg: string, fields?: Record): void + warn(msg: string, fields?: Record): void + error(msg: string, fields?: Record): void + /** The same log under a narrower scope: `tui` → `tui:shell`. */ + child(scope: string): Log + /** Where it writes, for a message that says where to look. Undefined when it cannot write. */ + readonly file: string | undefined +} + +/** `COCKPIT_DEBUG=1` for everything; `COCKPIT_LOG_LEVEL` to pick; errors, warnings and starts otherwise. */ +export function levelFrom(env: Record = process.env): Level { + const debug = env.COCKPIT_DEBUG + if (debug && debug !== "0" && debug !== "false") return "debug" + const named = env.COCKPIT_LOG_LEVEL as Level | undefined + return named && named in ORDER ? named : "info" +} + +/** Past this, the file moves to `cockpit.log.1` — one generation kept, so a log never grows for ever. */ +const ROTATE_BYTES = 5 * 1024 * 1024 + +/** `COCKPIT_LOG_FILE` moves it — tests point it away from the real one — else the cockpit home. */ +export function logFile(env: Record = process.env): string | undefined { + if (env.COCKPIT_LOG_FILE) return env.COCKPIT_LOG_FILE + try { + return join(resolvePaths(env).home, "cockpit.log") + } catch { + return undefined + } +} + +/** Errors do not survive `JSON.stringify`; their message and stack are what a report needs. */ +function serialise(fields: Record | undefined): Record | undefined { + if (!fields) return undefined + const out: Record = {} + for (const [key, value] of Object.entries(fields)) { + out[key] = value instanceof Error ? { message: value.message, stack: value.stack } : value + } + return out +} + +/** + * Lines written per file since it was last prepared. Checking the size on every line would be a + * `stat` per line; checking once per process let an OpenCode left open for days with + * `COCKPIT_DEBUG=1` grow the file without bound. Every `RECHECK` lines is neither. + */ +const written = new Map() +const RECHECK = 256 + +/** + * The directory, then the size. On a fresh machine the cockpit home does not exist until the daemon + * first starts — and the plugin's first lines, which say what loaded, come before that: they were + * written to a directory that was not there yet, and lost. + */ +function prepare(file: string): void { + const count = written.get(file) + written.set(file, (count ?? 0) + 1) + if (count !== undefined && count % RECHECK !== 0) return + try { + mkdirSync(dirname(file), { recursive: true, mode: 0o700 }) + } catch { + // unwritable: the append below fails and is swallowed like any other + } + try { + if (statSync(file).size > ROTATE_BYTES) renameSync(file, `${file}.1`) + } catch { + // no file yet, or it cannot be moved: either way, keep writing + } +} + +export interface LogOptions { + file?: string + level?: Level +} + +export function createLog(scope: string, options: LogOptions = {}): Log { + const file = "file" in options ? options.file : logFile() + const level = options.level ?? levelFrom() + const write = (lvl: Level, msg: string, fields?: Record) => { + if (!file || ORDER[lvl] < ORDER[level]) return + try { + prepare(file) + const line = { t: new Date().toISOString(), lvl, scope, msg, pid: process.pid, ...serialise(fields) } + appendFileSync(file, `${JSON.stringify(line)}\n`, { mode: 0o600 }) + } catch { + // Never let a log line take the interface down. Prepare again next time: the directory may + // have been removed under a running OpenCode (a cleared cache), and it would stay gone. + written.delete(file) + } + } + return { + debug: (msg, fields) => write("debug", msg, fields), + info: (msg, fields) => write("info", msg, fields), + warn: (msg, fields) => write("warn", msg, fields), + error: (msg, fields) => write("error", msg, fields), + child: (name) => createLog(`${scope}:${name}`, { file, level }), + file, + } +} + +/** Logs nothing: for tests, and for code handed no log. */ +export const silentLog: Log = { + debug() {}, + info() {}, + warn() {}, + error() {}, + child: () => silentLog, + file: undefined, +} + +/** This package's version — `../package.json` from both `src/` and `dist/`. */ +export function cockpitVersion(): string | undefined { + try { + return ( + JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version?: string } + ).version + } catch { + return undefined + } +} diff --git a/packages/client/src/server.ts b/packages/client/src/server.ts new file mode 100644 index 0000000..3ba34bc --- /dev/null +++ b/packages/client/src/server.ts @@ -0,0 +1,372 @@ +/** + * What a Cockpit server half needs from OpenCode, whichever OpenCode it is — `host.ts` for the agent + * side. + * + * A feature is written once as a `ServerStart`: given a `ServerHost` it answers with its tools and the + * few hooks it uses (`ServerParts`). `dualServer` turns that into an entry both versions load: v1 calls + * `server(input)` and gets hooks back, v2 calls `setup(ctx)` and the parts are registered on its + * domains. Tools stay written with v1's `tool()`, whose arguments are zod — v2 is handed their JSON + * Schema, and the arguments are parsed here so defaults apply the same on both. + * + * Only `tool` (for its zod) is imported from OpenCode at runtime; the v2 context is described by the + * structural types below. + */ + +import { isAbsolute, relative, resolve } from "node:path" +import { + type Hooks, + type PluginInput, + type ToolContext, + type ToolDefinition, + tool, +} from "@opencode-ai/plugin" +import { cockpitVersion, createLog, type Log, silentLog } from "./log.ts" + +export interface ServerHost { + readonly version: 1 | 2 + /** The project this OpenCode was opened in. */ + readonly directory: string + /** One object per OpenCode instance, shared by every Cockpit plugin in it — for `claimFeature`. */ + readonly scope: object + readonly session: { + get(id: string): Promise<{ parentID?: string; title?: string } | undefined> + /** A message from the plugin rather than the person, which starts a turn: v1's synthetic prompt. */ + notify(id: string, text: string): Promise + } + /** A project file's text, or undefined when there is none — or the path leaves the project. */ + readFile(path: string): Promise + /** The shared log (`cockpit.log`), scoped `server`; a feature takes `log.child("shell")`. */ + readonly log: Log +} + +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 + sessionDeleted?: (sessionID: string) => Promise + dispose?: () => Promise | void +} + +export type ServerStart = (host: ServerHost, options: unknown) => Promise + +/** Several features as one: tools unioned (a clash is a bug, so it throws), hooks run in order. */ +export function composeParts(parts: ServerParts[]): ServerParts { + const tools: Record = {} + for (const part of parts) { + for (const [name, def] of Object.entries(part.tools ?? {})) { + if (name in tools) throw new Error(`tool "${name}" is registered by more than one cockpit feature`) + tools[name] = def + } + } + /** Only what some feature has: no features is no hooks at all, not hooks that do nothing. */ + const any = (key: keyof ServerParts) => parts.some((part) => part[key] !== undefined) + return { + ...(Object.keys(tools).length > 0 ? { tools } : {}), + ...(any("system") + ? { + system: async (sessionID: string | undefined) => { + const lines: string[] = [] + for (const part of parts) lines.push(...((await part.system?.(sessionID)) ?? [])) + return lines + }, + } + : {}), + ...(any("sessionDeleted") + ? { + sessionDeleted: async (sessionID: string) => { + for (const part of parts) await part.sessionDeleted?.(sessionID) + }, + } + : {}), + ...(any("dispose") + ? { + dispose: async () => { + for (const part of parts) await part.dispose?.() + }, + } + : {}), + } +} + +// --------------------------------------------------------------------------------------------------- +// v1 + +/** v1 also has a log of its own, where people already look: warnings and errors go there too. */ +function alsoToV1(log: Log, client: PluginInput["client"]): Log { + const forward = (level: "warn" | "error", message: string) => + // Logging through the server while plugins initialise could wait on ourselves; defer it. + setTimeout(() => { + void client.app.log({ body: { service: "opencode-cockpit", level, message } }).catch(() => {}) + }, 0) + return { + ...log, + warn: (msg, fields) => { + log.warn(msg, fields) + forward("warn", msg) + }, + error: (msg, fields) => { + log.error(msg, fields) + forward("error", msg) + }, + child: (scope) => alsoToV1(log.child(scope), client), + } +} + +export function serverFromV1(input: PluginInput, log: Log = silentLog): ServerHost { + const { client, directory } = input + return { + version: 1, + directory, + scope: input, + session: { + get: async (id) => { + const result = await client.session.get({ path: { id } }).catch(() => undefined) + return result?.data as { parentID?: string; title?: string } | undefined + }, + notify: async (id, text) => { + await client.session.promptAsync({ + path: { id }, + body: { parts: [{ type: "text", text, synthetic: true } as never] }, + }) + }, + }, + readFile: async (path) => { + const result = await client.file.read({ query: { path } }).catch(() => undefined) + const content = (result?.data as { content?: string } | undefined)?.content + return typeof content === "string" ? content : undefined + }, + log: alsoToV1(log, client), + } +} + +export function partsToV1Hooks(parts: ServerParts): Hooks { + return { + ...(parts.tools ? { tool: parts.tools } : {}), + ...(parts.system + ? { + "experimental.chat.system.transform": async (input, output) => { + output.system.push(...((await parts.system?.(input.sessionID)) ?? [])) + }, + } + : {}), + ...(parts.sessionDeleted + ? { + event: async ({ event }) => { + if (event.type === "session.deleted") await parts.sessionDeleted?.(event.properties.info.id) + }, + } + : {}), + ...(parts.dispose ? { dispose: async () => parts.dispose?.() } : {}), + } as Hooks +} + +// --------------------------------------------------------------------------------------------------- +// v2 + +/** The parts of OpenCode 2.0.15's server plugin context used here (`@opencode/plugin`'s `Context`). */ +export interface V2ServerContext { + options?: unknown + location?: { directory: string } + tool?: { transform(edit: (editor: V2ToolEditor) => void): Promise } + session: { + get(input: { sessionID: string }): Promise<{ parentID?: string; title?: string } | undefined> + synthetic(input: { sessionID: string; text: string }): Promise + hook(name: "context", run: (event: { sessionID: string; system: unknown[] }) => unknown): Promise + } + event: { subscribe(options: { signal: AbortSignal }): AsyncIterable } +} + +interface V2Event { + type: string + data?: { sessionID?: string } +} + +/** A tool as v2's editor takes one. */ +export interface V2Tool { + name: string + description: string + input: unknown + execute: (input: unknown, context: V2ToolContext) => Promise<{ content?: string; metadata?: unknown }> +} + +interface V2ToolEditor { + add(tool: V2Tool): void +} + +export interface V2ToolContext { + sessionID: string + agent: string + messageID: string + signal: AbortSignal + progress: (update: Record) => Promise +} + +/** + * v1 scoped claims to the plugin input, which every plugin of an instance shares. v2 hands each plugin + * its own context, so the shared object is kept here instead, by directory — on `globalThis`, so two + * copies of this package (the bundle and a feature package) still find the same one. + */ +const SCOPES = Symbol.for("opencode-cockpit.server-scopes") +function scopeFor(directory: string): object { + const global = globalThis as { [SCOPES]?: Map } + global[SCOPES] ??= new Map() + let scope = global[SCOPES].get(directory) + if (!scope) { + scope = { directory } + global[SCOPES].set(directory, scope) + } + return scope +} + +export function serverFromV2( + ctx: V2ServerContext & { location: { directory: string } }, + log: Log = silentLog, +): ServerHost { + const directory = ctx.location.directory + return { + version: 2, + directory, + scope: scopeFor(directory), + session: { + get: (id) => ctx.session.get({ sessionID: id }).catch(() => undefined), + notify: async (id, text) => { + await ctx.session.synthetic({ sessionID: id, text }) + }, + }, + /** v2 gives plugins no file API; the file system, kept inside the project, reads the same text. */ + readFile: async (path) => { + const full = resolve(directory, path) + const inside = relative(directory, full) + if (inside.startsWith("..") || isAbsolute(inside)) return undefined + const file = Bun.file(full) + return (await file.exists()) ? await file.text().catch(() => undefined) : undefined + }, + log, + } +} + +/** A v1 tool as v2 registers one: JSON Schema in, arguments parsed here, text out. */ +export function toolToV2(name: string, def: ToolDefinition, directory: string): V2Tool { + const args = tool.schema.object(def.args) + return { + name, + description: def.description, + /** As the model writes them, not as they come out: an argument with a default is optional. */ + input: tool.schema.toJSONSchema(args, { io: "input" }), + execute: async (input: unknown, context: V2ToolContext) => { + const v1: ToolContext = { + sessionID: context.sessionID, + messageID: context.messageID, + agent: context.agent, + directory, + worktree: directory, + abort: context.signal, + metadata: (update) => void context.progress(update).catch(() => {}), + /** v2 asks for a plugin tool's own permission before it runs; there is no second prompt to raise. */ + ask: async () => {}, + } + const result = await def.execute(args.parse(input ?? {}), v1) + return typeof result === "string" + ? { content: result } + : { content: result.output, ...(result.metadata ? { metadata: result.metadata } : {}) } + }, + } +} + +// --------------------------------------------------------------------------------------------------- +// both + +/** + * Every tool call through the log: a failure with its tool and stack — tools fail in front of the + * agent, not the person, so this is the only record — and, with `COCKPIT_DEBUG`, every call and how + * long it took. + */ +function loggedTools(tools: Record | undefined, log: Log) { + if (!tools) return undefined + const out: Record = {} + for (const [name, def] of Object.entries(tools)) { + out[name] = { + ...def, + execute: async (args, context) => { + const started = performance.now() + try { + const result = await def.execute(args, context) + log.debug("tool", { tool: name, ms: Math.round(performance.now() - started) }) + return result + } catch (error) { + log.warn("tool failed", { tool: name, ms: Math.round(performance.now() - started), error }) + throw error + } + }, + } + } + return out +} + +/** Starts a feature: what loaded and where first, so a feature that never answers still said it was loaded. */ +async function begin( + id: string, + host: ServerHost, + start: ServerStart, + options: unknown, +): Promise { + host.log.info("start", { entry: id, opencode: host.version, cockpit: cockpitVersion() }) + try { + const parts = await start(host, options) + return { ...parts, tools: loggedTools(parts.tools, host.log) } + } catch (error) { + host.log.error("start failed", { entry: id, error }) + throw error + } +} + +/** One feature as an entry both versions load. */ +export function dualServer(id: string, start: ServerStart) { + return { + id, + server: async (input: PluginInput, options?: unknown): Promise => + partsToV1Hooks(await begin(id, serverFromV1(input, createLog("server")), start, options)), + setup: async (ctx: V2ServerContext) => { + /** + * v1 1.18.29+ calls `setup` too (older releases never do), with a preview context that has + * neither tools nor a location (docs/opencode/v2.md). Registering there would be registering twice. + */ + if (!ctx.tool || !ctx.location) return + const host = serverFromV2( + ctx as V2ServerContext & { location: { directory: string } }, + createLog("server"), + ) + const parts = await begin(id, host, start, ctx.options) + const tools = Object.entries(parts.tools ?? {}) + if (tools.length > 0) { + await ctx.tool.transform((editor) => { + for (const [name, def] of tools) editor.add(toolToV2(name, def, host.directory)) + }) + } + if (parts.system) { + const system = parts.system + await ctx.session.hook("context", async (event) => { + for (const text of await system(event.sessionID)) event.system.push({ type: "text", text }) + }) + } + const stop = new AbortController() + if (parts.sessionDeleted) { + const deleted = parts.sessionDeleted + void (async () => { + for await (const event of ctx.event.subscribe({ signal: stop.signal })) { + const sessionID = event.data?.sessionID + if (event.type === "session.deleted" && sessionID) { + await deleted(sessionID).catch((error) => + host.log.warn("session cleanup failed", { sessionID, error }), + ) + } + } + })().catch((error) => host.log.warn("event stream ended", { error })) + } + return async () => { + stop.abort() + await parts.dispose?.() + } + }, + } +} diff --git a/packages/client/test/fixtures/v1-default-theme.json b/packages/client/test/fixtures/v1-default-theme.json new file mode 100644 index 0000000..7a51fda --- /dev/null +++ b/packages/client/test/fixtures/v1-default-theme.json @@ -0,0 +1,54 @@ +{ + "primary": "#fab283", + "secondary": "#5c9cf5", + "accent": "#9d7cd8", + "error": "#e06c75", + "warning": "#f5a742", + "success": "#7fd88f", + "info": "#56b6c2", + "text": "#eeeeee", + "textMuted": "#808080", + "background": "#0a0a0a", + "backgroundPanel": "#141414", + "backgroundElement": "#1e1e1e", + "border": "#484848", + "borderActive": "#606060", + "borderSubtle": "#3c3c3c", + "diffAdded": "#4fd6be", + "diffRemoved": "#c53b53", + "diffContext": "#828bb8", + "diffHunkHeader": "#828bb8", + "diffHighlightAdded": "#b8db87", + "diffHighlightRemoved": "#e26a75", + "diffAddedBg": "#20303b", + "diffRemovedBg": "#37222c", + "diffContextBg": "#141414", + "diffLineNumber": "#8f8f8f", + "diffAddedLineNumberBg": "#1b2b34", + "diffRemovedLineNumberBg": "#2d1f26", + "markdownText": "#eeeeee", + "markdownHeading": "#9d7cd8", + "markdownLink": "#fab283", + "markdownLinkText": "#56b6c2", + "markdownCode": "#7fd88f", + "markdownBlockQuote": "#e5c07b", + "markdownEmph": "#e5c07b", + "markdownStrong": "#f5a742", + "markdownHorizontalRule": "#808080", + "markdownListItem": "#fab283", + "markdownListEnumeration": "#56b6c2", + "markdownImage": "#fab283", + "markdownImageText": "#56b6c2", + "markdownCodeBlock": "#eeeeee", + "syntaxComment": "#808080", + "syntaxKeyword": "#9d7cd8", + "syntaxFunction": "#fab283", + "syntaxVariable": "#e06c75", + "syntaxString": "#7fd88f", + "syntaxNumber": "#f5a742", + "syntaxType": "#e5c07b", + "syntaxOperator": "#56b6c2", + "syntaxPunctuation": "#eeeeee", + "selectedListItemText": "#0a0a0a", + "backgroundMenu": "#1e1e1e" +} diff --git a/packages/client/test/fixtures/v2-default-theme.json b/packages/client/test/fixtures/v2-default-theme.json new file mode 100644 index 0000000..4dd3ef7 --- /dev/null +++ b/packages/client/test/fixtures/v2-default-theme.json @@ -0,0 +1,1279 @@ +{ + "hue": { + "gray": { + "100": { + "hex": "#ffffffff", + "hue": "gray", + "step": 100 + }, + "200": { + "hex": "#eeeeeeff", + "hue": "gray", + "step": 200 + }, + "300": { + "hex": "#b5b5b5ff", + "hue": "gray", + "step": 300 + }, + "400": { + "hex": "#808080ff", + "hue": "gray", + "step": 400 + }, + "500": { + "hex": "#4c4c4cff", + "hue": "gray", + "step": 500 + }, + "600": { + "hex": "#1e1e1eff", + "hue": "gray", + "step": 600 + }, + "700": { + "hex": "#141414ff", + "hue": "gray", + "step": 700 + }, + "800": { + "hex": "#0a0a0aff", + "hue": "gray", + "step": 800 + }, + "900": { + "hex": "#030303ff", + "hue": "gray", + "step": 900 + } + }, + "red": { + "100": { + "hex": "#fd7e87ff", + "hue": "red", + "step": 100 + }, + "200": { + "hex": "#e06c75ff", + "hue": "red", + "step": 200 + }, + "300": { + "hex": "#c35a63ff", + "hue": "red", + "step": 300 + }, + "400": { + "hex": "#a74952ff", + "hue": "red", + "step": 400 + }, + "500": { + "hex": "#8c3941ff", + "hue": "red", + "step": 500 + }, + "600": { + "hex": "#722931ff", + "hue": "red", + "step": 600 + }, + "700": { + "hex": "#591921ff", + "hue": "red", + "step": 700 + }, + "800": { + "hex": "#410a13ff", + "hue": "red", + "step": 800 + }, + "900": { + "hex": "#280207ff", + "hue": "red", + "step": 900 + } + }, + "orange": { + "100": { + "hex": "#fddac5ff", + "hue": "orange", + "step": 100 + }, + "200": { + "hex": "#fab283ff", + "hue": "orange", + "step": 200 + }, + "300": { + "hex": "#d8976cff", + "hue": "orange", + "step": 300 + }, + "400": { + "hex": "#b67c56ff", + "hue": "orange", + "step": 400 + }, + "500": { + "hex": "#966341ff", + "hue": "orange", + "step": 500 + }, + "600": { + "hex": "#774a2cff", + "hue": "orange", + "step": 600 + }, + "700": { + "hex": "#593319ff", + "hue": "orange", + "step": 700 + }, + "800": { + "hex": "#3d1d06ff", + "hue": "orange", + "step": 800 + }, + "900": { + "hex": "#1f0b01ff", + "hue": "orange", + "step": 900 + } + }, + "yellow": { + "100": { + "hex": "#ffffffff", + "hue": "yellow", + "step": 100 + }, + "200": { + "hex": "#eeeeeeff", + "hue": "yellow", + "step": 200 + }, + "300": { + "hex": "#b5b5b5ff", + "hue": "yellow", + "step": 300 + }, + "400": { + "hex": "#808080ff", + "hue": "yellow", + "step": 400 + }, + "500": { + "hex": "#4c4c4cff", + "hue": "yellow", + "step": 500 + }, + "600": { + "hex": "#1e1e1eff", + "hue": "yellow", + "step": 600 + }, + "700": { + "hex": "#141414ff", + "hue": "yellow", + "step": 700 + }, + "800": { + "hex": "#0a0a0aff", + "hue": "yellow", + "step": 800 + }, + "900": { + "hex": "#030303ff", + "hue": "yellow", + "step": 900 + } + }, + "green": { + "100": { + "hex": "#96f7a7ff", + "hue": "green", + "step": 100 + }, + "200": { + "hex": "#7fd88fff", + "hue": "green", + "step": 200 + }, + "300": { + "hex": "#68b977ff", + "hue": "green", + "step": 300 + }, + "400": { + "hex": "#539c61ff", + "hue": "green", + "step": 400 + }, + "500": { + "hex": "#3d7f4bff", + "hue": "green", + "step": 500 + }, + "600": { + "hex": "#296336ff", + "hue": "green", + "step": 600 + }, + "700": { + "hex": "#144922ff", + "hue": "green", + "step": 700 + }, + "800": { + "hex": "#00300fff", + "hue": "green", + "step": 800 + }, + "900": { + "hex": "#011705ff", + "hue": "green", + "step": 900 + } + }, + "cyan": { + "100": { + "hex": "#68d0ddff", + "hue": "cyan", + "step": 100 + }, + "200": { + "hex": "#56b6c2ff", + "hue": "cyan", + "step": 200 + }, + "300": { + "hex": "#449da7ff", + "hue": "cyan", + "step": 300 + }, + "400": { + "hex": "#33848eff", + "hue": "cyan", + "step": 400 + }, + "500": { + "hex": "#226c75ff", + "hue": "cyan", + "step": 500 + }, + "600": { + "hex": "#0f555dff", + "hue": "cyan", + "step": 600 + }, + "700": { + "hex": "#093e44ff", + "hue": "cyan", + "step": 700 + }, + "800": { + "hex": "#02292eff", + "hue": "cyan", + "step": 800 + }, + "900": { + "hex": "#001518ff", + "hue": "cyan", + "step": 900 + } + }, + "blue": { + "100": { + "hex": "#82b4fbff", + "hue": "blue", + "step": 100 + }, + "200": { + "hex": "#5c9cf5ff", + "hue": "blue", + "step": 200 + }, + "300": { + "hex": "#4c86d6ff", + "hue": "blue", + "step": 300 + }, + "400": { + "hex": "#3c70b8ff", + "hue": "blue", + "step": 400 + }, + "500": { + "hex": "#2c5b9bff", + "hue": "blue", + "step": 500 + }, + "600": { + "hex": "#1d477fff", + "hue": "blue", + "step": 600 + }, + "700": { + "hex": "#0f3364ff", + "hue": "blue", + "step": 700 + }, + "800": { + "hex": "#02214aff", + "hue": "blue", + "step": 800 + }, + "900": { + "hex": "#01112bff", + "hue": "blue", + "step": 900 + } + }, + "purple": { + "100": { + "hex": "#b38ff4ff", + "hue": "purple", + "step": 100 + }, + "200": { + "hex": "#9d7cd8ff", + "hue": "purple", + "step": 200 + }, + "300": { + "hex": "#8869bdff", + "hue": "purple", + "step": 300 + }, + "400": { + "hex": "#7357a2ff", + "hue": "purple", + "step": 400 + }, + "500": { + "hex": "#5f4688ff", + "hue": "purple", + "step": 500 + }, + "600": { + "hex": "#4b356fff", + "hue": "purple", + "step": 600 + }, + "700": { + "hex": "#392557ff", + "hue": "purple", + "step": 700 + }, + "800": { + "hex": "#271640ff", + "hue": "purple", + "step": 800 + }, + "900": { + "hex": "#17072bff", + "hue": "purple", + "step": 900 + } + }, + "accent": { + "100": { + "hex": "#b38ff4ff", + "hue": "accent", + "step": 100 + }, + "200": { + "hex": "#9d7cd8ff", + "hue": "accent", + "step": 200 + }, + "300": { + "hex": "#8869bdff", + "hue": "accent", + "step": 300 + }, + "400": { + "hex": "#7357a2ff", + "hue": "accent", + "step": 400 + }, + "500": { + "hex": "#5f4688ff", + "hue": "accent", + "step": 500 + }, + "600": { + "hex": "#4b356fff", + "hue": "accent", + "step": 600 + }, + "700": { + "hex": "#392557ff", + "hue": "accent", + "step": 700 + }, + "800": { + "hex": "#271640ff", + "hue": "accent", + "step": 800 + }, + "900": { + "hex": "#17072bff", + "hue": "accent", + "step": 900 + } + }, + "interactive": { + "100": { + "hex": "#fddac5ff", + "hue": "interactive", + "step": 100 + }, + "200": { + "hex": "#fab283ff", + "hue": "interactive", + "step": 200 + }, + "300": { + "hex": "#d8976cff", + "hue": "interactive", + "step": 300 + }, + "400": { + "hex": "#b67c56ff", + "hue": "interactive", + "step": 400 + }, + "500": { + "hex": "#966341ff", + "hue": "interactive", + "step": 500 + }, + "600": { + "hex": "#774a2cff", + "hue": "interactive", + "step": 600 + }, + "700": { + "hex": "#593319ff", + "hue": "interactive", + "step": 700 + }, + "800": { + "hex": "#3d1d06ff", + "hue": "interactive", + "step": 800 + }, + "900": { + "hex": "#1f0b01ff", + "hue": "interactive", + "step": 900 + } + }, + "neutral": { + "100": { + "hex": "#ffffffff", + "hue": "neutral", + "step": 100 + }, + "200": { + "hex": "#eeeeeeff", + "hue": "neutral", + "step": 200 + }, + "300": { + "hex": "#b5b5b5ff", + "hue": "neutral", + "step": 300 + }, + "400": { + "hex": "#808080ff", + "hue": "neutral", + "step": 400 + }, + "500": { + "hex": "#4c4c4cff", + "hue": "neutral", + "step": 500 + }, + "600": { + "hex": "#1e1e1eff", + "hue": "neutral", + "step": 600 + }, + "700": { + "hex": "#141414ff", + "hue": "neutral", + "step": 700 + }, + "800": { + "hex": "#0a0a0aff", + "hue": "neutral", + "step": 800 + }, + "900": { + "hex": "#030303ff", + "hue": "neutral", + "step": 900 + } + } + }, + "text": { + "base": { + "hex": "#eeeeeeff", + "hue": "neutral", + "step": 200 + }, + "muted": { + "hex": "#808080ff", + "hue": "neutral", + "step": 400 + }, + "action": { + "primary": { + "base": { + "hex": "#eeeeeeff", + "hue": "neutral", + "step": 200 + }, + "focused": { + "hex": "#0a0a0aff", + "hue": "neutral", + "step": 800 + }, + "selected": { + "hex": "#fab283ff", + "hue": "interactive", + "step": 200 + }, + "disabled": { + "hex": "#808080ff", + "hue": "neutral", + "step": 400 + }, + "pressed": { + "hex": "#eeeeeeff", + "hue": "neutral", + "step": 200 + }, + "hovered": { + "hex": "#eeeeeeff", + "hue": "neutral", + "step": 200 + } + }, + "secondary": { + "base": { + "hex": "#808080ff", + "hue": "neutral", + "step": 400 + }, + "hovered": { + "hex": "#eeeeeeff", + "hue": "neutral", + "step": 200 + }, + "disabled": { + "hex": "#808080ff", + "hue": "neutral", + "step": 400 + }, + "pressed": { + "hex": "#808080ff", + "hue": "neutral", + "step": 400 + }, + "focused": { + "hex": "#808080ff", + "hue": "neutral", + "step": 400 + }, + "selected": { + "hex": "#808080ff", + "hue": "neutral", + "step": 400 + } + }, + "destructive": { + "base": { + "hex": "#0a0a0aff", + "hue": "neutral", + "step": 800 + }, + "disabled": { + "hex": "#808080ff", + "hue": "neutral", + "step": 400 + }, + "pressed": { + "hex": "#0a0a0aff", + "hue": "neutral", + "step": 800 + }, + "focused": { + "hex": "#0a0a0aff", + "hue": "neutral", + "step": 800 + }, + "selected": { + "hex": "#0a0a0aff", + "hue": "neutral", + "step": 800 + }, + "hovered": { + "hex": "#0a0a0aff", + "hue": "neutral", + "step": 800 + } + } + }, + "formfield": { + "base": { + "hex": "#eeeeeeff", + "hue": "neutral", + "step": 200 + }, + "hovered": { + "hex": "#fab283ff", + "hue": "interactive", + "step": 200 + }, + "focused": { + "hex": "#fab283ff", + "hue": "interactive", + "step": 200 + }, + "pressed": { + "hex": "#fab283ff", + "hue": "interactive", + "step": 200 + }, + "selected": { + "hex": "#fab283ff", + "hue": "interactive", + "step": 200 + }, + "disabled": { + "hex": "#808080ff", + "hue": "neutral", + "step": 400 + } + }, + "feedback": { + "error": { + "base": { + "hex": "#e06c75ff", + "hue": "red", + "step": 200 + }, + "muted": { + "hex": "#e06c75ff", + "hue": "red", + "step": 200 + } + }, + "warning": { + "base": { + "hex": "#f5a742ff" + }, + "muted": { + "hex": "#f5a742ff" + } + }, + "success": { + "base": { + "hex": "#7fd88fff", + "hue": "green", + "step": 200 + }, + "muted": { + "hex": "#7fd88fff", + "hue": "green", + "step": 200 + } + }, + "info": { + "base": { + "hex": "#56b6c2ff", + "hue": "cyan", + "step": 200 + }, + "muted": { + "hex": "#56b6c2ff", + "hue": "cyan", + "step": 200 + } + } + } + }, + "background": { + "base": { + "hex": "#0a0a0aff", + "hue": "neutral", + "step": 800 + }, + "raised": { + "base": { + "hex": "#141414ff", + "hue": "neutral", + "step": 700 + }, + "high": { + "hex": "#1e1e1eff", + "hue": "neutral", + "step": 600 + }, + "max": { + "hex": "#4c4c4cff", + "hue": "neutral", + "step": 500 + } + }, + "action": { + "primary": { + "base": { + "hex": "#00000000" + }, + "hovered": { + "hex": "#141414ff", + "hue": "neutral", + "step": 700 + }, + "focused": { + "hex": "#fab283ff", + "hue": "interactive", + "step": 200 + }, + "selected": { + "hex": "#00000000" + }, + "disabled": { + "hex": "#00000000" + }, + "pressed": { + "hex": "#00000000" + } + }, + "secondary": { + "base": { + "hex": "#00000000" + }, + "disabled": { + "hex": "#00000000" + }, + "pressed": { + "hex": "#00000000" + }, + "focused": { + "hex": "#00000000" + }, + "selected": { + "hex": "#00000000" + }, + "hovered": { + "hex": "#00000000" + } + }, + "destructive": { + "base": { + "hex": "#e06c75ff", + "hue": "red", + "step": 200 + }, + "disabled": { + "hex": "#e06c75ff", + "hue": "red", + "step": 200 + }, + "pressed": { + "hex": "#e06c75ff", + "hue": "red", + "step": 200 + }, + "focused": { + "hex": "#e06c75ff", + "hue": "red", + "step": 200 + }, + "selected": { + "hex": "#e06c75ff", + "hue": "red", + "step": 200 + }, + "hovered": { + "hex": "#e06c75ff", + "hue": "red", + "step": 200 + } + } + }, + "formfield": { + "base": { + "hex": "#0a0a0aff", + "hue": "neutral", + "step": 800 + }, + "disabled": { + "hex": "#0a0a0aff", + "hue": "neutral", + "step": 800 + }, + "pressed": { + "hex": "#0a0a0aff", + "hue": "neutral", + "step": 800 + }, + "focused": { + "hex": "#0a0a0aff", + "hue": "neutral", + "step": 800 + }, + "selected": { + "hex": "#0a0a0aff", + "hue": "neutral", + "step": 800 + }, + "hovered": { + "hex": "#0a0a0aff", + "hue": "neutral", + "step": 800 + } + }, + "feedback": { + "error": { + "base": { + "hex": "#0a0a0aff", + "hue": "neutral", + "step": 800 + } + }, + "warning": { + "base": { + "hex": "#0a0a0aff", + "hue": "neutral", + "step": 800 + } + }, + "success": { + "base": { + "hex": "#0a0a0aff", + "hue": "neutral", + "step": 800 + } + }, + "info": { + "base": { + "hex": "#0a0a0aff", + "hue": "neutral", + "step": 800 + } + } + } + }, + "border": { + "base": { + "hex": "#484848ff" + } + }, + "scrollbar": { + "base": { + "hex": "#606060ff" + } + }, + "diff": { + "text": { + "added": { + "hex": "#4fd6beff" + }, + "removed": { + "hex": "#c53b53ff" + }, + "context": { + "hex": "#828bb8ff" + }, + "hunkHeader": { + "hex": "#828bb8ff" + } + }, + "background": { + "added": { + "hex": "#20303bff" + }, + "removed": { + "hex": "#37222cff" + }, + "context": { + "hex": "#141414ff", + "hue": "neutral", + "step": 700 + } + }, + "highlight": { + "added": { + "hex": "#b8db87ff" + }, + "removed": { + "hex": "#e26a75ff" + } + }, + "lineNumber": { + "text": { + "hex": "#8f8f8fff" + }, + "background": { + "added": { + "hex": "#1b2b34ff" + }, + "removed": { + "hex": "#2d1f26ff" + } + } + } + }, + "syntax": { + "comment": { + "hex": "#808080ff", + "hue": "neutral", + "step": 400 + }, + "keyword": { + "hex": "#9d7cd8ff", + "hue": "accent", + "step": 200 + }, + "function": { + "hex": "#fab283ff", + "hue": "interactive", + "step": 200 + }, + "variable": { + "hex": "#e06c75ff", + "hue": "red", + "step": 200 + }, + "string": { + "hex": "#7fd88fff", + "hue": "green", + "step": 200 + }, + "number": { + "hex": "#f5a742ff" + }, + "type": { + "hex": "#e5c07bff" + }, + "operator": { + "hex": "#56b6c2ff", + "hue": "cyan", + "step": 200 + }, + "punctuation": { + "hex": "#eeeeeeff", + "hue": "neutral", + "step": 200 + } + }, + "markdown": { + "text": { + "hex": "#eeeeeeff", + "hue": "neutral", + "step": 200 + }, + "heading": { + "hex": "#9d7cd8ff", + "hue": "accent", + "step": 200 + }, + "link": { + "hex": "#fab283ff", + "hue": "interactive", + "step": 200 + }, + "linkText": { + "hex": "#56b6c2ff", + "hue": "cyan", + "step": 200 + }, + "code": { + "hex": "#7fd88fff", + "hue": "green", + "step": 200 + }, + "blockQuote": { + "hex": "#e5c07bff" + }, + "emphasis": { + "hex": "#e5c07bff" + }, + "strong": { + "hex": "#f5a742ff" + }, + "horizontalRule": { + "hex": "#808080ff", + "hue": "neutral", + "step": 400 + }, + "listItem": { + "hex": "#fab283ff", + "hue": "interactive", + "step": 200 + }, + "listEnumeration": { + "hex": "#56b6c2ff", + "hue": "cyan", + "step": 200 + }, + "image": { + "hex": "#fab283ff", + "hue": "interactive", + "step": 200 + }, + "imageText": { + "hex": "#56b6c2ff", + "hue": "cyan", + "step": 200 + }, + "codeBlock": { + "hex": "#eeeeeeff", + "hue": "neutral", + "step": 200 + } + }, + "categorical": { + "0": { + "100": { + "hex": "#82b4fbff", + "hue": "blue", + "step": 100 + }, + "200": { + "hex": "#5c9cf5ff", + "hue": "blue", + "step": 200 + }, + "300": { + "hex": "#4c86d6ff", + "hue": "blue", + "step": 300 + }, + "400": { + "hex": "#3c70b8ff", + "hue": "blue", + "step": 400 + }, + "500": { + "hex": "#2c5b9bff", + "hue": "blue", + "step": 500 + }, + "600": { + "hex": "#1d477fff", + "hue": "blue", + "step": 600 + }, + "700": { + "hex": "#0f3364ff", + "hue": "blue", + "step": 700 + }, + "800": { + "hex": "#02214aff", + "hue": "blue", + "step": 800 + }, + "900": { + "hex": "#01112bff", + "hue": "blue", + "step": 900 + } + }, + "1": { + "100": { + "hex": "#b38ff4ff", + "hue": "purple", + "step": 100 + }, + "200": { + "hex": "#9d7cd8ff", + "hue": "purple", + "step": 200 + }, + "300": { + "hex": "#8869bdff", + "hue": "purple", + "step": 300 + }, + "400": { + "hex": "#7357a2ff", + "hue": "purple", + "step": 400 + }, + "500": { + "hex": "#5f4688ff", + "hue": "purple", + "step": 500 + }, + "600": { + "hex": "#4b356fff", + "hue": "purple", + "step": 600 + }, + "700": { + "hex": "#392557ff", + "hue": "purple", + "step": 700 + }, + "800": { + "hex": "#271640ff", + "hue": "purple", + "step": 800 + }, + "900": { + "hex": "#17072bff", + "hue": "purple", + "step": 900 + } + }, + "2": { + "100": { + "hex": "#96f7a7ff", + "hue": "green", + "step": 100 + }, + "200": { + "hex": "#7fd88fff", + "hue": "green", + "step": 200 + }, + "300": { + "hex": "#68b977ff", + "hue": "green", + "step": 300 + }, + "400": { + "hex": "#539c61ff", + "hue": "green", + "step": 400 + }, + "500": { + "hex": "#3d7f4bff", + "hue": "green", + "step": 500 + }, + "600": { + "hex": "#296336ff", + "hue": "green", + "step": 600 + }, + "700": { + "hex": "#144922ff", + "hue": "green", + "step": 700 + }, + "800": { + "hex": "#00300fff", + "hue": "green", + "step": 800 + }, + "900": { + "hex": "#011705ff", + "hue": "green", + "step": 900 + } + }, + "3": { + "100": { + "hex": "#fddac5ff", + "hue": "orange", + "step": 100 + }, + "200": { + "hex": "#fab283ff", + "hue": "orange", + "step": 200 + }, + "300": { + "hex": "#d8976cff", + "hue": "orange", + "step": 300 + }, + "400": { + "hex": "#b67c56ff", + "hue": "orange", + "step": 400 + }, + "500": { + "hex": "#966341ff", + "hue": "orange", + "step": 500 + }, + "600": { + "hex": "#774a2cff", + "hue": "orange", + "step": 600 + }, + "700": { + "hex": "#593319ff", + "hue": "orange", + "step": 700 + }, + "800": { + "hex": "#3d1d06ff", + "hue": "orange", + "step": 800 + }, + "900": { + "hex": "#1f0b01ff", + "hue": "orange", + "step": 900 + } + }, + "4": { + "100": { + "hex": "#fd7e87ff", + "hue": "red", + "step": 100 + }, + "200": { + "hex": "#e06c75ff", + "hue": "red", + "step": 200 + }, + "300": { + "hex": "#c35a63ff", + "hue": "red", + "step": 300 + }, + "400": { + "hex": "#a74952ff", + "hue": "red", + "step": 400 + }, + "500": { + "hex": "#8c3941ff", + "hue": "red", + "step": 500 + }, + "600": { + "hex": "#722931ff", + "hue": "red", + "step": 600 + }, + "700": { + "hex": "#591921ff", + "hue": "red", + "step": 700 + }, + "800": { + "hex": "#410a13ff", + "hue": "red", + "step": 800 + }, + "900": { + "hex": "#280207ff", + "hue": "red", + "step": 900 + } + } + } +} diff --git a/packages/client/test/host.test.ts b/packages/client/test/host.test.ts new file mode 100644 index 0000000..482395f --- /dev/null +++ b/packages/client/test/host.test.ts @@ -0,0 +1,319 @@ +import { describe, expect, test } from "bun:test" +import { dualTui, fromV1, fromV2, layerToV2, themeFromV2, type V2Context } from "../src/host.ts" + +/** + * The v2 half of the host, against a fake context shaped like OpenCode 2.0.15's (docs/opencode/v2.md). + * What is checked is the translation — v1 names in, v2 calls out — since that is the part a real + * OpenCode cannot show going wrong until someone's key does nothing. + */ + +/** Colours carry a `buffer`, as OpenTUI's do; the name is only there to tell them apart. */ +const colour = (name: string) => ({ name, buffer: [] }) as never +/** Action and feedback tokens are a colour per state in OpenCode 2.0.15 — measured, not assumed. */ +const states = (name: string) => ({ base: colour(name), hovered: colour(`${name}:hovered`) }) +const theme = { + text: { + base: colour("text"), + muted: colour("muted"), + action: { primary: states("primary"), secondary: states("secondary") }, + feedback: { + error: states("error"), + warning: states("warning"), + success: { base: colour("success"), muted: colour("success:muted") }, + info: states("info"), + }, + }, + background: { + base: colour("bg"), + raised: { base: colour("panel"), high: colour("element"), max: colour("max") }, + }, + border: { base: colour("border") }, + diff: { + text: { + added: colour("added"), + removed: colour("removed"), + context: colour("context"), + hunkHeader: colour("hunk"), + }, + background: { added: colour("addedBg"), removed: colour("removedBg"), context: colour("contextBg") }, + highlight: { added: colour("addedHi"), removed: colour("removedHi") }, + lineNumber: { text: colour("lineNumber"), background: colour("lineNumberBg") }, + }, + syntax: { keyword: colour("keyword"), punctuation: colour("punct") }, + markdown: { heading: colour("heading") }, +} + +function fakeContext() { + const calls: { slot: unknown[]; toast: unknown[]; stored: Record } = { + slot: [], + toast: [], + stored: {}, + } + const store = { values: {} as Record } + const ctx = { + options: { dockHeight: 12 }, + location: { directory: "/work/project" }, + renderer: {}, + client: {}, + theme, + data: { + location: { + default: () => ({ directory: "/work/project" }), + vcs: { + sync: async () => {}, + info: () => ({ branch: { current: "feat/x", default: "main" } }), + }, + }, + session: {}, + }, + keymap: { + layer: () => {}, + shortcuts: (id: string) => (id === "cockpit.shells.dock" ? ["ctrl+x o"] : []), + }, + storage: { + store: () => + [ + store, + async (mutation: (draft: typeof store) => void) => { + mutation(store) + Object.assign(calls.stored, store.values) + }, + ] as const, + }, + ui: { + dialog: {}, + toast: { show: (options: unknown) => calls.toast.push(options) }, + router: { current: () => ({ type: "session", sessionID: "ses_1" }) }, + slot: (claim: unknown) => { + calls.slot.push(claim) + return () => {} + }, + }, + } as unknown as V2Context + return { ctx, calls } +} + +describe("a v1 key layer, as v2 reads one", () => { + test("commands keep their names, slash names and palette, and bindings become `bind`", () => { + const layer = layerToV2({ + priority: 100, + commands: [ + { + name: "cockpit.shells.dock", + title: "Toggle shells", + category: "Shells", + namespace: "palette", + slashName: "shells-dock", + run: () => {}, + }, + { name: "cockpit.console.down", title: "Scroll down", run: () => {} }, + ], + bindings: [ + { key: "j,down", cmd: "cockpit.console.down" }, + { key: "o", cmd: "cockpit.shells.dock" }, + ], + } as never) + expect(layer.priority).toBe(100) + expect(layer.mode).toBe("global") + const [dock, down] = layer.commands ?? [] + expect(dock).toMatchObject({ + id: "cockpit.shells.dock", + group: "Shells", + palette: true, + slash: { name: "shells-dock" }, + bind: "o", + }) + expect(down).toMatchObject({ id: "cockpit.console.down", bind: "j,down" }) + expect(layer.bindings).toEqual(["cockpit.shells.dock", "cockpit.console.down"]) + }) + + test("a command with no key is still reachable by slash or palette, and binds nothing", () => { + const layer = layerToV2({ + commands: [{ name: "cockpit.shells.pick", slashName: "shells", run: () => {} }], + } as never) + expect(layer.commands?.[0]?.bind).toBe(false) + }) +}) + +describe("v2's token theme under v1's names", () => { + const current = themeFromV2(() => theme as never) as unknown as Record + test("maps each name a bay reads", () => { + expect(current.text?.name).toBe("text") + expect(current.textMuted?.name).toBe("muted") + /** No palettes in this theme, so the accent falls back to the token built from it. */ + expect(current.accent?.name).toBe("keyword") + expect(current.backgroundPanel?.name).toBe("panel") + expect(current.backgroundElement?.name).toBe("element") + expect(current.diffAddedBg?.name).toBe("addedBg") + expect(current.syntaxPunctuation?.name).toBe("punct") + expect(current.markdownHeading?.name).toBe("heading") + }) + /** Handing a bay the whole group made Review's paint throw on every frame. */ + test("a token with a colour per state reads as its colour at rest", () => { + expect(current.primary?.name).toBe("primary") + expect(current.error?.name).toBe("error") + expect(current.success?.name).toBe("success") + expect("buffer" in (current.warning as object)).toBe(true) + }) + test("a name v2 has no token for falls back to the text colour rather than nothing", () => { + expect(current.somethingNew?.name).toBe("text") + }) +}) + +describe("the v2 host", () => { + test("reads the location, branch and route v1 bays expect", () => { + const { ctx } = fakeContext() + const host = fromV2(ctx, () => {}) + expect(host.version).toBe(2) + expect(host.state.path).toEqual({ directory: "/work/project", worktree: "/work/project" }) + expect(host.state.vcs).toEqual({ branch: "feat/x", default_branch: "main" }) + expect(host.route.current).toEqual({ name: "session", params: { sessionID: "ses_1" } }) + expect(host.keymap.shortcut("cockpit.shells.dock")).toBe("ctrl+x o") + }) + + test("keeps kv in plugin storage", () => { + const { ctx, calls } = fakeContext() + const host = fromV2(ctx, () => {}) + expect(host.kv.get("cockpit.dock.open", false)).toBe(false) + host.kv.set("cockpit.dock.open", true) + expect(calls.stored["cockpit.dock.open"]).toBe(true) + expect(host.kv.get("cockpit.dock.open", false)).toBe(true) + }) + + test("puts v1 slots in v2's places, and cleans them up", () => { + const { ctx, calls } = fakeContext() + const cleanups: unknown[] = [] + const host = fromV2(ctx, (fn) => cleanups.push(fn)) + host.slots.register({ slots: { app_bottom: () => null as never, sidebar_content: () => null as never } }) + /** The first claim is the host's own: an invisible `app` render that owns the key layers. */ + expect(calls.slot.map((claim) => (claim as { append: string }).append)).toEqual([ + "app", + "app", + "sidebar.content", + ]) + expect(cleanups).toHaveLength(3) + }) +}) + +describe("one entry for both", () => { + test("carries v1's tui and v2's setup, and setup hands back a cleanup that runs every dispose", async () => { + let started: number | undefined + const disposed: string[] = [] + const entry = dualTui("cockpit.test", (host) => { + started = host.version + host.lifecycle.onDispose(() => disposed.push("a")) + host.lifecycle.onDispose(() => disposed.push("b")) + }) + expect(entry.id).toBe("cockpit.test") + expect(typeof entry.tui).toBe("function") + const cleanup = await entry.setup(fakeContext().ctx) + expect(started).toBe(2) + cleanup() + expect(disposed).toEqual(["b", "a"]) + }) +}) + +/** The handler a fake dialog was given, or a failure that says which one never opened. */ +function handler( + seen: Record void>>, + dialog: string, + name: string, +): (...args: unknown[]) => void { + const found = seen[dialog]?.[name] + if (!found) throw new Error(`the ${dialog} dialog never opened with ${name}`) + return found as (...args: unknown[]) => void +} + +describe("the v1 host's dialogs", () => { + /** + * v1's dialog calls its close handler when cleared. Clearing before answering settled every prompt + * as cancelled — the new-shell prompt took a command and started nothing. + */ + function fakeV1() { + let onClose: (() => void) | undefined + const seen: Record void>> = {} + const component = (name: string) => (props: Record void>) => { + seen[name] = props + return null + } + const api = { + ui: { + dialog: { + replace: (render: () => unknown, close?: () => void) => { + onClose = close + render() + }, + clear: () => onClose?.(), + }, + DialogPrompt: component("prompt"), + DialogConfirm: component("confirm"), + DialogSelect: component("select"), + }, + } + return { host: fromV1(api as never), seen } + } + + test("a prompt answers with what was typed", async () => { + const { host, seen } = fakeV1() + const answer = host.ui.prompt({ title: "New background shell" }) + handler(seen, "prompt", "onConfirm")("npm run dev") + expect(await answer).toBe("npm run dev") + }) + + test("a confirmation answers yes, and a list answers with the choice", async () => { + const { host, seen } = fakeV1() + const yes = host.ui.confirm({ title: "Stop?", message: "3 running" }) + handler(seen, "confirm", "onConfirm")() + expect(await yes).toBe(true) + const choice = host.ui.select({ title: "Shells", options: [{ title: "a", value: "sh_a" }] }) + handler(seen, "select", "onSelect")({ value: "sh_a" }) + expect(await choice).toBe("sh_a") + }) + + test("closing without answering is a cancel", async () => { + const { host, seen } = fakeV1() + const answer = host.ui.prompt({ title: "Name" }) + handler(seen, "prompt", "onCancel")() + expect(await answer).toBeUndefined() + }) +}) + +/** + * The mapping against the real thing: OpenCode 2.0.15's default theme and v1 1.18.32's, as each + * version handed them to a plugin (captured by a probe plugin; see docs/opencode/v2.md). The first + * mapping was written from v2's docs and put a white where every accent should be — right shape, + * wrong colour, and no test could tell. This one compares colours. + */ +describe("v2's default theme, read under v1's names, is v1's default theme", async () => { + type Node = { hex?: string; hue?: string; step?: number } & Record + const v1 = (await Bun.file(`${import.meta.dir}/fixtures/v1-default-theme.json`).json()) as Record< + string, + string + > + const raw = (await Bun.file(`${import.meta.dir}/fixtures/v2-default-theme.json`).json()) as Node + + /** Colours as OpenTUI's: a `buffer`, and a source the theme can be asked for. */ + const sources = new Map() + const build = (node: Node): unknown => { + if (typeof node.hex === "string") { + const h = node.hex + const colour = { + buffer: [1, 3, 5, 7].map((i) => Number.parseInt(h.slice(i, i + 2), 16)), + hex: h.slice(0, 7), + } + if (node.hue && node.step !== undefined) sources.set(colour, { hue: node.hue, step: node.step }) + return colour + } + return Object.fromEntries(Object.entries(node).map(([key, value]) => [key, build(value as Node)])) + } + const live = { ...(build(raw) as object), source: (colour: object) => sources.get(colour) } + const current = themeFromV2(() => live as never) as unknown as Record + + /** No v2 token has v1's subtle-border grey; the nearest border stands in, a shade lighter. */ + const nearest = new Set(["borderSubtle"]) + + for (const [name, hex] of Object.entries(v1)) { + if (nearest.has(name)) continue + test(name, () => expect(current[name]?.hex).toBe(hex)) + } +}) diff --git a/packages/client/test/log.test.ts b/packages/client/test/log.test.ts new file mode 100644 index 0000000..523f557 --- /dev/null +++ b/packages/client/test/log.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { dirname, join } from "node:path" +import { createLog, levelFrom } from "../src/log.ts" + +/** + * The log is what someone attaches to an issue, so what it keeps is tested: the line shape a reader + * greps for, errors with their stack, the switch that turns detail on, and a file that cannot grow + * for ever. + */ + +const dirs: string[] = [] +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) +const fresh = () => { + const dir = mkdtempSync("/tmp/ck-log-") + dirs.push(dir) + return join(dir, "cockpit.log") +} +const lines = (file: string) => + readFileSync(file, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record) + +describe("the shared log", () => { + test("one JSON line per event, scoped by half and bay, with the process it came from", () => { + const file = fresh() + createLog("tui", { file, level: "info" }).child("shell").info("start", { opencode: 2 }) + const [line] = lines(file) + expect(line).toMatchObject({ + lvl: "info", + scope: "tui:shell", + msg: "start", + opencode: 2, + pid: process.pid, + }) + expect(typeof line?.t).toBe("string") + }) + + test("an error keeps its message and stack, which JSON would drop", () => { + const file = fresh() + createLog("server", { file, level: "info" }).error("start failed", { error: new Error("boom") }) + const error = lines(file)[0]?.error as { message: string; stack: string } + expect(error.message).toBe("boom") + expect(error.stack).toContain("boom") + }) + + test("detail is written only when asked for", () => { + const file = fresh() + createLog("tui", { file, level: "info" }).debug("paint") + expect(existsSync(file)).toBe(false) + createLog("tui", { file, level: "debug" }).debug("paint") + expect(lines(file)).toHaveLength(1) + }) + + test("a file past its size moves aside, one generation kept", () => { + const file = fresh() + writeFileSync(file, "x".repeat(6 * 1024 * 1024)) + createLog("tui", { file, level: "info" }).info("after") + expect(existsSync(`${file}.1`)).toBe(true) + expect(lines(file)).toHaveLength(1) + }) + + /** An OpenCode left open for days: the size is checked again as it writes, not only at start. */ + test("a file that grows past its size while running also moves aside", () => { + const file = fresh() + const log = createLog("tui", { file, level: "info" }) + log.info("first") + writeFileSync(file, "x".repeat(6 * 1024 * 1024)) + for (let i = 0; i < 300; i++) log.info("more") + expect(existsSync(`${file}.1`)).toBe(true) + expect(lines(file).length).toBeLessThan(300) + }) + + test("a directory removed under a running log comes back", () => { + const file = fresh() + const log = createLog("tui", { file, level: "info" }) + log.info("before") + rmSync(dirname(file), { recursive: true, force: true }) + log.info("lost") + log.info("after") + expect(lines(file).map((line) => line.msg)).toEqual(["after"]) + }) + + /** A fresh machine: the plugin's first lines come before the daemon has made the directory. */ + test("the first line makes the directory it goes in", () => { + const file = join(fresh().replace(/cockpit\.log$/, ""), "not-yet", "cockpit.log") + createLog("server", { file, level: "info" }).info("start") + expect(lines(file)).toHaveLength(1) + }) + + test("a log that cannot write stays quiet rather than breaking what called it", () => { + expect(() => + createLog("tui", { file: "/nonexistent/dir/cockpit.log", level: "debug" }).error("x"), + ).not.toThrow() + }) +}) + +describe("the switch", () => { + test("COCKPIT_DEBUG turns everything on; COCKPIT_LOG_LEVEL picks; info otherwise", () => { + expect(levelFrom({})).toBe("info") + expect(levelFrom({ COCKPIT_DEBUG: "1" })).toBe("debug") + expect(levelFrom({ COCKPIT_DEBUG: "0" })).toBe("info") + expect(levelFrom({ COCKPIT_LOG_LEVEL: "warn" })).toBe("warn") + expect(levelFrom({ COCKPIT_LOG_LEVEL: "loud" })).toBe("info") + }) +}) diff --git a/packages/client/test/server.test.ts b/packages/client/test/server.test.ts new file mode 100644 index 0000000..63c78f6 --- /dev/null +++ b/packages/client/test/server.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, test } from "bun:test" +import { tool } from "@opencode-ai/plugin" +import { composeParts, dualServer, partsToV1Hooks, type ServerParts, toolToV2 } from "../src/server.ts" + +/** + * The server half of running on both: one feature's tools and hooks, handed to v1 as hooks and to + * v2 as registrations. What is checked is the translation — against fakes shaped like each version's + * context — since a tool v2 never registered is an agent that silently cannot do the thing. + */ + +const echo = tool({ + description: "Echo", + args: { text: tool.schema.string(), loud: tool.schema.boolean().default(false) }, + execute: async (args, context) => + `${args.loud ? args.text.toUpperCase() : args.text} in ${context.directory}`, +}) + +describe("several features as one", () => { + test("unions tools and runs shared hooks in feature order", async () => { + const calls: string[] = [] + const a: ServerParts = { + tools: { a_tool: echo }, + system: async () => ["a"], + dispose: () => void calls.push("a:dispose"), + } + const b: ServerParts = { + tools: { b_tool: echo }, + system: async () => ["b"], + sessionDeleted: async (id) => void calls.push(`b:deleted ${id}`), + } + const parts = composeParts([a, b]) + expect(Object.keys(parts.tools ?? {})).toEqual(["a_tool", "b_tool"]) + expect(await parts.system?.("ses_1")).toEqual(["a", "b"]) + await parts.sessionDeleted?.("ses_1") + await parts.dispose?.() + expect(calls).toEqual(["b:deleted ses_1", "a:dispose"]) + }) + + test("a tool name registered twice is a bug and throws", () => { + expect(() => composeParts([{ tools: { same: echo } }, { tools: { same: echo } }])).toThrow( + 'tool "same" is registered by more than one cockpit feature', + ) + }) + + test("no features means no hooks", () => { + expect(composeParts([])).toEqual({}) + expect(partsToV1Hooks(composeParts([{}]))).toEqual({}) + }) +}) + +describe("on OpenCode 1", () => { + test("system lines and deleted sessions arrive through v1's hooks", async () => { + const deleted: string[] = [] + const hooks = partsToV1Hooks({ + system: async (sessionID) => [`for ${sessionID}`], + sessionDeleted: async (id) => void deleted.push(id), + }) + const output = { system: [] as string[] } + await hooks["experimental.chat.system.transform"]?.({ sessionID: "ses_1" } as never, output as never) + expect(output.system).toEqual(["for ses_1"]) + await hooks.event?.({ + event: { type: "session.deleted", properties: { info: { id: "ses_2" } } } as never, + }) + await hooks.event?.({ event: { type: "session.idle", properties: {} } as never }) + expect(deleted).toEqual(["ses_2"]) + }) +}) + +describe("a v1 tool, as v2 registers it", () => { + const v2 = toolToV2("echo", echo, "/work/project") + const context = { + sessionID: "ses_1", + agent: "build", + messageID: "msg_1", + signal: new AbortController().signal, + progress: async () => {}, + } + + test("is described by JSON Schema, which is what v2 shows the model", () => { + expect(v2.input).toMatchObject({ + type: "object", + properties: { text: { type: "string" }, loud: { type: "boolean", default: false } }, + required: ["text"], + }) + }) + + test("parses its arguments here, so defaults apply as they do on v1", async () => { + expect(await v2.execute({ text: "hi" }, context)).toEqual({ content: "hi in /work/project" }) + expect(await v2.execute({ text: "hi", loud: true }, context)).toEqual({ content: "HI in /work/project" }) + }) + + test("refuses arguments v1 would have refused", async () => { + await expect(v2.execute({ loud: true }, context)).rejects.toThrow() + }) +}) + +/** Enough of a v2 server context to watch what gets registered. */ +function fakeV2(events: { type: string; data?: { sessionID?: string } }[] = []) { + const added: string[] = [] + const hooks: string[] = [] + let context: ((event: { sessionID: string; system: unknown[] }) => unknown) | undefined + const ctx = { + options: {}, + location: { directory: "/work/project" }, + tool: { + transform: async (edit: (editor: { add: (tool: { name: string }) => void }) => void) => { + edit({ add: (tool) => added.push(tool.name) }) + }, + }, + session: { + get: async () => undefined, + synthetic: async () => undefined, + hook: async (name: string, run: typeof context) => { + hooks.push(name) + context = run + }, + }, + event: { + subscribe: async function* () { + for (const event of events) yield event + }, + }, + } + return { ctx, added, hooks, system: () => context } +} + +describe("one entry for both", () => { + test("v1's preview call to setup registers nothing", async () => { + let started = false + const entry = dualServer("cockpit.test", async () => { + started = true + return {} + }) + /** What v1 1.18.32 passes: no `tool`, no `location`. */ + await entry.setup({ options: {} } as never) + expect(started).toBe(false) + }) + + test("v2's setup registers the tools, the context hook, and follows deleted sessions", async () => { + const deleted: string[] = [] + let disposed = false + const fake = fakeV2([{ type: "session.idle" }, { type: "session.deleted", data: { sessionID: "ses_9" } }]) + const entry = dualServer("cockpit.test", async (host) => { + expect(host.version).toBe(2) + expect(host.directory).toBe("/work/project") + return { + tools: { echo }, + system: async (sessionID) => [`guidance for ${sessionID}`], + sessionDeleted: async (id) => void deleted.push(id), + dispose: () => { + disposed = true + }, + } + }) + const cleanup = await entry.setup(fake.ctx as never) + expect(fake.added).toEqual(["echo"]) + expect(fake.hooks).toEqual(["context"]) + const event = { sessionID: "ses_1", system: [] as unknown[] } + await fake.system()?.(event) + expect(event.system).toEqual([{ type: "text", text: "guidance for ses_1" }]) + await Bun.sleep(0) + expect(deleted).toEqual(["ses_9"]) + await cleanup?.() + expect(disposed).toBe(true) + }) + + test("two copies in one OpenCode 2 share a claim scope, so a feature loads once", async () => { + const scopes: object[] = [] + const entry = dualServer("cockpit.test", async (host) => { + scopes.push(host.scope) + return {} + }) + await entry.setup(fakeV2().ctx as never) + await entry.setup(fakeV2().ctx as never) + expect(scopes[0]).toBe(scopes[1]) + }) +}) diff --git a/packages/daemon/src/main.ts b/packages/daemon/src/main.ts index 9d8002c..badf029 100644 --- a/packages/daemon/src/main.ts +++ b/packages/daemon/src/main.ts @@ -16,7 +16,11 @@ const daemon = new Daemon({ shell: { registryFile: join(paths.home, "shells.json"), logDir: join(paths.home, "logs") }, }), idleTimeoutMs: Number(env.COCKPIT_IDLE_TIMEOUT_MS ?? 10 * 60_000), - logLevel: (env.COCKPIT_LOG_LEVEL as Level | undefined) ?? "info", + /** The same switches as the plugin's log (`client/src/log.ts`), so one `COCKPIT_DEBUG=1` covers both. */ + logLevel: + env.COCKPIT_DEBUG && env.COCKPIT_DEBUG !== "0" && env.COCKPIT_DEBUG !== "false" + ? "debug" + : ((env.COCKPIT_LOG_LEVEL as Level | undefined) ?? "info"), logToFile: !foreground, }) diff --git a/packages/opencode/README.md b/packages/opencode/README.md index 1cdd925..7572a9e 100644 --- a/packages/opencode/README.md +++ b/packages/opencode/README.md @@ -125,10 +125,24 @@ installed — and shows every plugin you have, not just this one: npx opencode-cockpit@latest update # or: bunx opencode-cockpit@latest update ``` -Restart OpenCode. Requires OpenCode 1.18+ on macOS or Linux. Install a feature either through +Restart OpenCode. Requires OpenCode 1.18+ or 2.0.15+ on macOS or Linux. Install a feature either through `opencode-cockpit` or on its own — if both are configured, the first one loaded is used and OpenCode warns you which entry to remove. +**On OpenCode 2** the same packages load — one entry serves both versions. v2 reads `plugins` (not +`plugin`) from `opencode.json` for the agent side and from `cli.json` for the interface, and passes +options as an object: + +```json +{ + "plugins": [{ "package": "opencode-cockpit@0.5.2", "options": { "features": { "shell": true } } }] +} +``` + +An existing v1 `opencode.json` with `plugin` is read by OpenCode 2 as well. To update there, change +the version in that entry — `/plugins-update` and `npx opencode-cockpit update` edit OpenCode 1's +files only. + **Turn features off** (in both `opencode.json` and `tui.json`): ```json @@ -156,6 +170,26 @@ your own commands, define watch rules, cap how long shells live, choose what may 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). +## Troubleshooting + +```sh +npx opencode-cockpit@latest doctor +``` + +checks OpenCode, its config, Cockpit's logs and the daemon, and prints the fix for anything wrong — +on OpenCode 1 and 2, and when Cockpit will not load at all ([what it checks](https://codestz.github.io/opencode-cockpit/help/doctor/)). + +Everything Cockpit does inside OpenCode goes to one file — which OpenCode loaded which bay, and every +error with its stack: + +```sh +tail -50 ~/.cache/opencode-cockpit/cockpit.log +``` + +`COCKPIT_DEBUG=1 opencode` adds the detail. [Troubleshooting](https://codestz.github.io/opencode-cockpit/help/troubleshooting/) covers +the failures people hit and what to attach to an issue; [OpenCode 1 and 2](https://codestz.github.io/opencode-cockpit/start/opencode-versions/) +covers what differs between the two. + ## How it works ``` diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 1e1e783..13f8a66 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -40,6 +40,8 @@ "bun": ">=1.3.5" }, "files": [ + "server.js", + "tui.js", "dist", "types", "README.md", @@ -49,9 +51,10 @@ "access": "public" }, "dependencies": { - "@opencode-cockpit/shell": "workspace:*", "@opencode-ai/plugin": "1.18.31", + "@opencode-cockpit/client": "workspace:*", "@opencode-cockpit/review": "workspace:*", + "@opencode-cockpit/shell": "workspace:*", "@opencode-cockpit/status": "workspace:*", "@opencode-cockpit/updater": "workspace:*" }, diff --git a/packages/opencode/server.js b/packages/opencode/server.js new file mode 100644 index 0000000..10f25fa --- /dev/null +++ b/packages/opencode/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/opencode/src/bin.ts b/packages/opencode/src/bin.ts index 8cb038c..11fad2f 100644 --- a/packages/opencode/src/bin.ts +++ b/packages/opencode/src/bin.ts @@ -11,13 +11,15 @@ const HELP = `Usage: npx opencode-cockpit@latest update show every OpenCode plugin you have installed and update the ones that are behind (--only , --dry-run, --yes; see \`update --help\`) + doctor check OpenCode, its config, Cockpit's logs and the daemon, and say how to fix what is + wrong (--json for an issue) ` const [command, ...rest] = process.argv.slice(2) -if (command === "update") { +if (command === "update" || command === "doctor") { const { main } = await import("@opencode-cockpit/updater/cli") - process.exitCode = await main(rest) + process.exitCode = await main(command === "doctor" ? [command, ...rest] : rest) } else { process.stdout.write(HELP) process.exitCode = command === undefined || command === "--help" || command === "-h" ? 0 : 2 diff --git a/packages/opencode/src/compose.ts b/packages/opencode/src/compose.ts deleted file mode 100644 index 961dd86..0000000 --- a/packages/opencode/src/compose.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Hooks } from "@opencode-ai/plugin" - -/** - * Merges the server hooks of several features into one plugin. Tool maps are unioned (a name - * clash is a bug, so it throws); function hooks run in feature order, each awaited, which is how - * OpenCode itself runs hooks from separate plugins. - */ -export function composeHooks(parts: Hooks[]): Hooks { - const tools: NonNullable = {} - const functions = new Map unknown)[]>() - const singles: Record = {} - - for (const part of parts) { - for (const [key, value] of Object.entries(part)) { - if (value === undefined) continue - if (key === "tool") { - for (const [id, def] of Object.entries(value as NonNullable)) { - if (id in tools) throw new Error(`tool "${id}" is registered by more than one cockpit feature`) - tools[id] = def - } - } else if (typeof value === "function") { - const list = functions.get(key) ?? [] - list.push(value as (...args: unknown[]) => unknown) - functions.set(key, list) - } else { - if (key in singles) throw new Error(`hook "${key}" is provided by more than one cockpit feature`) - singles[key] = value - } - } - } - - const hooks: Record = { ...singles } - for (const [key, list] of functions) { - hooks[key] = - list.length === 1 - ? list[0] - : async (...args: unknown[]) => { - for (const fn of list) await fn(...args) - } - } - if (Object.keys(tools).length > 0) hooks.tool = tools - return hooks as Hooks -} diff --git a/packages/opencode/src/server.ts b/packages/opencode/src/server.ts index e64eab6..8011448 100644 --- a/packages/opencode/src/server.ts +++ b/packages/opencode/src/server.ts @@ -1,19 +1,15 @@ -import type { Hooks, Plugin, PluginModule } from "@opencode-ai/plugin" +import { composeParts, dualServer } from "@opencode-cockpit/client/server" import { createReviewServer } from "@opencode-cockpit/review/server" import { createShellServer } from "@opencode-cockpit/shell/server" -import { composeHooks } from "./compose.ts" import { BUNDLE, type CockpitOptions, featureOptions, isEnabled } from "./features.ts" const shell = createShellServer({ source: BUNDLE }) const review = createReviewServer({ source: BUNDLE }) -const server: Plugin = async (input, rawOptions) => { +export default dualServer(BUNDLE, async (host, rawOptions) => { const options = rawOptions as CockpitOptions | undefined - const parts: Hooks[] = [] - if (isEnabled(options, "shell")) parts.push(await shell(input, featureOptions(options, "shell"))) - if (isEnabled(options, "review")) parts.push(await review(input, featureOptions(options, "review"))) - return composeHooks(parts) -} - -const plugin: PluginModule & { id: string } = { id: BUNDLE, server } -export default plugin + const parts = [] + if (isEnabled(options, "shell")) parts.push(await shell(host, featureOptions(options, "shell"))) + if (isEnabled(options, "review")) parts.push(await review(host, featureOptions(options, "review"))) + return composeParts(parts) +}) diff --git a/packages/opencode/src/tui.ts b/packages/opencode/src/tui.ts index 7f754ec..38fb352 100644 --- a/packages/opencode/src/tui.ts +++ b/packages/opencode/src/tui.ts @@ -1,4 +1,4 @@ -import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui" +import { dualTui } from "@opencode-cockpit/client/host" import { createReviewTui } from "@opencode-cockpit/review/tui" import { createShellTui } from "@opencode-cockpit/shell/tui" import { createStatusTui } from "@opencode-cockpit/status/tui" @@ -10,13 +10,11 @@ const status = createStatusTui({ source: BUNDLE }) const review = createReviewTui({ source: BUNDLE }) const updater = createUpdaterTui({ source: BUNDLE }) -const tui: TuiPlugin = async (api, rawOptions, meta) => { +/** One entry for both OpenCodes (docs/opencode/v2.md): each bay starts on the same Host. */ +export default dualTui(BUNDLE, async (host, rawOptions) => { const options = rawOptions as CockpitOptions | undefined - if (isEnabled(options, "shell")) await shell(api, featureOptions(options, "shell"), meta) - if (isEnabled(options, "status")) await status(api, featureOptions(options, "status"), meta) - if (isEnabled(options, "review")) await review(api, featureOptions(options, "review"), meta) - if (isEnabled(options, "updater")) await updater(api, featureOptions(options, "updater"), meta) -} - -const plugin: TuiPluginModule & { id: string } = { id: BUNDLE, tui } -export default plugin + if (isEnabled(options, "shell")) await shell(host, featureOptions(options, "shell")) + if (isEnabled(options, "status")) await status(host, featureOptions(options, "status")) + if (isEnabled(options, "review")) await review(host, featureOptions(options, "review")) + if (isEnabled(options, "updater")) await updater(host, featureOptions(options, "updater")) +}) diff --git a/packages/opencode/test/bundle.test.ts b/packages/opencode/test/bundle.test.ts index 93fa6bf..430f100 100644 --- a/packages/opencode/test/bundle.test.ts +++ b/packages/opencode/test/bundle.test.ts @@ -1,39 +1,6 @@ import { describe, expect, test } from "bun:test" -import type { Hooks } from "@opencode-ai/plugin" -import { composeHooks } from "../src/compose.ts" import { featureOptions, isEnabled } from "../src/features.ts" -describe("composeHooks", () => { - test("unions tools and runs shared hooks in feature order", async () => { - const calls: string[] = [] - const a: Hooks = { - tool: { a_tool: {} as never }, - event: async () => void calls.push("a:event"), - dispose: async () => void calls.push("a:dispose"), - } - const b: Hooks = { - tool: { b_tool: {} as never }, - event: async () => void calls.push("b:event"), - } - const hooks = composeHooks([a, b]) - expect(Object.keys(hooks.tool ?? {})).toEqual(["a_tool", "b_tool"]) - await hooks.event?.({ event: {} as never }) - await hooks.dispose?.() - expect(calls).toEqual(["a:event", "b:event", "a:dispose"]) - }) - - test("a tool name registered twice is a bug and throws", () => { - expect(() => composeHooks([{ tool: { same: {} as never } }, { tool: { same: {} as never } }])).toThrow( - 'tool "same" is registered by more than one cockpit feature', - ) - }) - - test("no features means no hooks", () => { - expect(composeHooks([])).toEqual({}) - expect(composeHooks([{}])).toEqual({}) - }) -}) - describe("feature options", () => { test("features are on unless switched off", () => { expect(isEnabled(undefined, "shell")).toBe(true) diff --git a/packages/opencode/tui.js b/packages/opencode/tui.js new file mode 100644 index 0000000..7a56e93 --- /dev/null +++ b/packages/opencode/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.js" diff --git a/packages/review/README.md b/packages/review/README.md index 0e1bcdb..9d5b9fb 100644 --- a/packages/review/README.md +++ b/packages/review/README.md @@ -100,3 +100,23 @@ someone's `git status` it becomes a thing to explain in a pull request. note at all. [MIT](./LICENSE) + +## Troubleshooting + +```sh +npx opencode-cockpit@latest doctor +``` + +checks OpenCode, its config, Cockpit's logs and the daemon, and prints the fix for anything wrong — +on OpenCode 1 and 2, and when Cockpit will not load at all ([what it checks](https://codestz.github.io/opencode-cockpit/help/doctor/)). + +Everything Cockpit does inside OpenCode goes to one file — which OpenCode loaded which bay, and every +error with its stack: + +```sh +tail -50 ~/.cache/opencode-cockpit/cockpit.log +``` + +`COCKPIT_DEBUG=1 opencode` adds the detail. [Troubleshooting](https://codestz.github.io/opencode-cockpit/help/troubleshooting/) covers +the failures people hit and what to attach to an issue; [OpenCode 1 and 2](https://codestz.github.io/opencode-cockpit/start/opencode-versions/) +covers what differs between the two. diff --git a/packages/review/package.json b/packages/review/package.json index 0b04950..563ff8b 100644 --- a/packages/review/package.json +++ b/packages/review/package.json @@ -42,6 +42,8 @@ "bun": ">=1.3.5" }, "files": [ + "server.js", + "tui.js", "dist", "types", "README.md", diff --git a/packages/review/server.js b/packages/review/server.js new file mode 100644 index 0000000..10f25fa --- /dev/null +++ b/packages/review/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/review/src/agent/plugin.ts b/packages/review/src/agent/plugin.ts index 31ac0ed..39b2e47 100644 --- a/packages/review/src/agent/plugin.ts +++ b/packages/review/src/agent/plugin.ts @@ -1,5 +1,10 @@ -import type { Hooks, Plugin, PluginInput } from "@opencode-ai/plugin" import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client" +import { + dualServer, + type ServerHost, + type ServerParts, + type ServerStart, +} from "@opencode-cockpit/client/server" import { waitingOn } from "../core/model/thread.ts" import { reviewPaths } from "../core/store/paths.ts" import { createPersistence, type Persistence } from "../core/store/persist.ts" @@ -26,30 +31,20 @@ export interface ReviewServerOptions { } /** Review's server half as a factory, so bundles such as `opencode-cockpit` can include it. */ -export function createReviewServer({ source = REVIEW_PACKAGE }: ReviewServerOptions = {}): Plugin { - return async (input) => { - const claim = claimFeature(input, "review", source) +export function createReviewServer({ source = REVIEW_PACKAGE }: ReviewServerOptions = {}): ServerStart { + return async (host) => { + const claim = claimFeature(host.scope, "review", source) if (!claim.active) { - // Logging through the server during plugin initialisation could wait on ourselves; defer it. - setTimeout(() => { - void input.client.app - .log({ - body: { - service: "opencode-cockpit", - level: "warn", - message: duplicateFeatureMessage("Review", claim.owner, source), - }, - }) - .catch(() => {}) - }, 0) + host.log.warn(duplicateFeatureMessage("Review", claim.owner, source)) return {} } - const hooks = await reviewHooks(input) - return { ...hooks, dispose: async () => claim.release() } + const parts = await reviewParts(host) + return { ...parts, dispose: () => claim.release() } } } -async function reviewHooks({ client: opencode, directory }: PluginInput): Promise { +async function reviewParts(host: ServerHost): Promise { + const { directory } = host /** * The branch is asked for per call, not cached. * @@ -70,27 +65,23 @@ async function reviewHooks({ client: opencode, directory }: PluginInput): Promis const store = async (): Promise => createPersistence(reviewPaths(directory, await branchOf())) /** - * A file's text as it is now, for checking a resolve. + * A file's text as it is now, for checking a resolve — read through the host, which refuses a path + * outside the project. * - * Read through OpenCode rather than the filesystem so it sees the same content the session does, - * and so a path outside the project is refused by something that already knows how. - */ - /** * The host resolves the path, so a read that succeeds is a path that exists in this project — and * that is the path the review files things under. A suffix the host cannot resolve comes back * undefined, and the tools say so rather than filing a note nobody will see. */ const contentsOf = async (path: string): Promise => { - const result = await opencode.file.read({ query: { path } }).catch(() => undefined) - const content = (result?.data as { content?: string } | undefined)?.content - return typeof content === "string" ? { path, text: content } : undefined + const text = await host.readFile(path) + return text === undefined ? undefined : { path, text } } return { - tool: createTools({ opencode, directory, store, contentsOf }), + tools: createTools({ directory, store, contentsOf }), /** Said once per conversation, the way Shell explains its shells. */ - "experimental.chat.system.transform": async (_input, output) => { - output.system.push(GUIDANCE) + system: async () => { + const system = [GUIDANCE] /** * And what is actually waiting, so the agent does not have to ask to find out there is nothing. @@ -104,10 +95,13 @@ async function reviewHooks({ client: opencode, directory }: PluginInput): Promis .catch(() => []) if (waiting.length > 0) { const files = [...new Set(waiting.map((thread) => thread.file))] - output.system.push( + system.push( `${waiting.length} review comment${waiting.length === 1 ? "" : "s"} are waiting on you in ${files.join(", ")}. Read them with review_list.`, ) } + return system }, } } + +export default dualServer("opencode-cockpit.review", createReviewServer()) diff --git a/packages/review/src/agent/tools/shared.ts b/packages/review/src/agent/tools/shared.ts index 9353042..0a8348d 100644 --- a/packages/review/src/agent/tools/shared.ts +++ b/packages/review/src/agent/tools/shared.ts @@ -6,7 +6,6 @@ * almost never touch the same bytes. */ -import type { PluginInput } from "@opencode-ai/plugin" import type { Thread } from "../../core/model/thread.ts" import type { Persistence } from "../../core/store/persist.ts" @@ -20,7 +19,6 @@ export interface FileContents { } export interface ToolDeps { - opencode: PluginInput["client"] directory: string /** The threads for whatever branch is checked out now, re-resolved per call. */ store: () => Promise diff --git a/packages/review/src/server.ts b/packages/review/src/server.ts index 2d8d9cb..498926b 100644 --- a/packages/review/src/server.ts +++ b/packages/review/src/server.ts @@ -1,2 +1,2 @@ /** Published entry point: `@opencode-cockpit/review/server`. */ -export { createReviewServer, REVIEW_PACKAGE, type ReviewServerOptions } from "./agent/plugin.ts" +export { createReviewServer, default, REVIEW_PACKAGE, type ReviewServerOptions } from "./agent/plugin.ts" diff --git a/packages/review/src/tui/data/changes.ts b/packages/review/src/tui/data/changes.ts index 63ecdbf..0c9486f 100644 --- a/packages/review/src/tui/data/changes.ts +++ b/packages/review/src/tui/data/changes.ts @@ -14,7 +14,7 @@ * that pushed reactive state into a slot would be pushing it somewhere nothing reads it. */ -import type { TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { Host } from "@opencode-cockpit/client/host" import { branchChanges, withCounts, worktreeChanges } from "../../core/git/sources.ts" import type { ChangeSet, Source } from "../../core/model/review.ts" @@ -39,7 +39,7 @@ export interface Store { const empty = (source: Source): ChangeSet => ({ source, files: [] }) -export function createStore(api: TuiPluginApi, initial: Source = "branch"): Store { +export function createStore(api: Host, initial: Source = "branch"): Store { let source = initial let latest: Loaded = { changes: empty(initial) } let inFlight = 0 diff --git a/packages/review/src/tui/index.tsx b/packages/review/src/tui/index.tsx index 8425dd0..1a2965e 100644 --- a/packages/review/src/tui/index.tsx +++ b/packages/review/src/tui/index.tsx @@ -1,7 +1,7 @@ /** @jsxImportSource @opentui/solid */ -import { createBindingLookup, type TuiPlugin, type TuiPluginModule } from "@opencode-ai/plugin/tui" 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 type { Source } from "../core/model/review.ts" @@ -59,8 +59,8 @@ export interface ReviewTuiOptions { * the same handful of mutable variables, so nothing could be moved out without taking the state with * it. Giving that state a name — `Surface` — is what let everything else leave. */ -export function createReviewTui({ source = REVIEW_PACKAGE }: { source?: string } = {}): TuiPlugin { - return async (api, rawOptions) => { +export function createReviewTui({ source = REVIEW_PACKAGE }: { source?: string } = {}) { + return async (api: Host, rawOptions?: unknown) => { // The renderer is shared by every TUI plugin in this OpenCode window. const claim = claimFeature(api.renderer, "review", source) if (!claim.active) { @@ -75,7 +75,7 @@ export function createReviewTui({ source = REVIEW_PACKAGE }: { source?: string } api.lifecycle.onDispose(() => claim.release()) const options = (rawOptions ?? {}) as ReviewTuiOptions - const keys = createBindingLookup({ ...DEFAULT_KEYS, ...options.keybinds }) + const keys = bindingLookup({ ...DEFAULT_KEYS, ...options.keybinds }) const store = createStore(api, options.source ?? "worktree") const surface = createSurface(options.variant ?? "right") @@ -310,8 +310,5 @@ export function createReviewTui({ source = REVIEW_PACKAGE }: { source?: string } } } -const plugin: TuiPluginModule & { id: string } = { - id: "opencode-cockpit.review", - tui: createReviewTui(), -} -export default plugin +/** One entry for both OpenCodes: v1 calls `tui`, v2 calls `setup` (docs/opencode/v2.md). */ +export default dualTui("opencode-cockpit.review", createReviewTui()) diff --git a/packages/review/src/tui/panel/actions.ts b/packages/review/src/tui/panel/actions.ts index c159363..d4b7704 100644 --- a/packages/review/src/tui/panel/actions.ts +++ b/packages/review/src/tui/panel/actions.ts @@ -10,7 +10,7 @@ * lets a key change without touching what it does. */ -import type { TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { Host } from "@opencode-cockpit/client/host" import { baseCandidates } from "../../core/git/sources.ts" import type { Guard } from "../../core/guard.ts" import { @@ -45,7 +45,7 @@ import type { Queries } from "./queries.ts" import type { Surface } from "./surface.ts" export interface ActionDeps { - api: TuiPluginApi + api: Host surface: Surface store: Store guard: Guard @@ -418,7 +418,9 @@ export function createActions(deps: ActionDeps): Actions { * an agent to "run review_list" when it has no such tool is worse than sending it too much. When * the server half is not installed the whole review travels as prose instead. */ - const hasTools = (): boolean => toolsConfigured(api.state.config.plugin, REVIEW_PACKAGE) + const hasTools = (): boolean => + /** On OpenCode 2 the tools arrive with this package's own server half, which v2 always loads. */ + api.v1 ? toolsConfigured(api.v1.state.config.plugin, REVIEW_PACKAGE) : true /** The conversation this review would be handed to. */ const sessionID = (): string | undefined => { @@ -486,10 +488,7 @@ export function createActions(deps: ActionDeps): Actions { }) if (!said) return guard.task("submit", async () => { - await api.client.session.promptAsync({ - sessionID: id, - parts: [{ type: "text", text: said.text }], - }) + await api.promptSession(id, said.text) }) api.ui.toast({ variant: "success", diff --git a/packages/review/src/tui/panel/keys.ts b/packages/review/src/tui/panel/keys.ts index 6bac0d1..a557876 100644 --- a/packages/review/src/tui/panel/keys.ts +++ b/packages/review/src/tui/panel/keys.ts @@ -11,12 +11,12 @@ * disposed the moment the review closes. */ -import type { TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { Host } from "@opencode-cockpit/client/host" import type { Guard } from "../../core/guard.ts" import { metrics } from "../../core/perf.ts" import type { Actions } from "./actions.ts" -type Layer = Parameters[0] +type Layer = Parameters[0] type Command = NonNullable[number] /** diff --git a/packages/review/src/tui/panel/paint.ts b/packages/review/src/tui/panel/paint.ts index 61c021f..89f0f7d 100644 --- a/packages/review/src/tui/panel/paint.ts +++ b/packages/review/src/tui/panel/paint.ts @@ -7,7 +7,7 @@ * the faster you move. */ -import type { TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { Host } from "@opencode-cockpit/client/host" import type { BoxRenderable } from "@opentui/core" import type { Guard } from "../../core/guard.ts" import { filesElsewhere } from "../../core/model/review.ts" @@ -17,7 +17,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 } from "../view/pool.ts" +import { type RowPool, solidSurface } from "../view/pool.ts" import type { Queries } from "./queries.ts" import type { Surface } from "./surface.ts" @@ -29,7 +29,7 @@ export interface Boxes { } export interface PaintDeps { - api: TuiPluginApi + api: Host surface: Surface store: Store guard: Guard @@ -58,6 +58,8 @@ export function createPainter(deps: PaintDeps): Painter { backdrop.height = surface.open ? screen.height : 0 backdrop.visible = surface.open + /** Opaque whatever the theme says, or the conversation shows through (transparent themes). */ + panel.backgroundColor = solidSurface(api.theme.current, "panel") panel.width = frame.width panel.height = surface.open ? screen.height : 0 /** diff --git a/packages/review/src/tui/panel/queries.ts b/packages/review/src/tui/panel/queries.ts index cd45b7d..928837f 100644 --- a/packages/review/src/tui/panel/queries.ts +++ b/packages/review/src/tui/panel/queries.ts @@ -7,7 +7,7 @@ * draw loop needs several of these and none of the verbs. */ -import type { TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { Host } from "@opencode-cockpit/client/host" import type { FileChange } from "../../core/model/review.ts" import { threadsFor, threadsOnLine } from "../../core/model/review.ts" import type { Store } from "../data/changes.ts" @@ -28,7 +28,7 @@ export interface Queries { contents: () => ReadonlyMap } -export function createQueries(api: TuiPluginApi, surface: Surface, store: Store): Queries { +export function createQueries(api: Host, surface: Surface, store: Store): Queries { /** * What to call what is on screen. * diff --git a/packages/review/src/tui/panel/trouble.ts b/packages/review/src/tui/panel/trouble.ts index a85a56f..aa84fc7 100644 --- a/packages/review/src/tui/panel/trouble.ts +++ b/packages/review/src/tui/panel/trouble.ts @@ -8,7 +8,7 @@ */ import { appendFile, mkdir } from "node:fs/promises" -import type { TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { Host } from "@opencode-cockpit/client/host" import { createGuard, type Guard } from "../../core/guard.ts" import { metrics } from "../../core/perf.ts" import { reviewPaths } from "../../core/store/paths.ts" @@ -27,7 +27,7 @@ export function createTrouble({ surface, store, }: { - api: TuiPluginApi + api: Host surface: Surface store: Store }): Trouble { @@ -56,13 +56,20 @@ export function createTrouble({ meter: metrics, context: situation, report: (trouble, detail) => { + const where = reviewPaths(api.state.path.worktree || api.state.path.directory, api.state.vcs?.branch) + /** The one line everyone's log has; the full report — perf, geometry — stays in the review's own file. */ + api.log.error("review: trouble", { + where: trouble.where, + message: trouble.message, + stack: trouble.stack, + report: where.log, + }) api.ui.toast({ variant: "error", title: "Review", message: `${trouble.where}: ${trouble.message}`, duration: 8_000, }) - const where = reviewPaths(api.state.path.worktree || api.state.path.directory, api.state.vcs?.branch) void mkdir(where.dir, { recursive: true }) .then(() => appendFile(where.log, detail)) .catch(() => {}) diff --git a/packages/review/src/tui/view/dialogs.tsx b/packages/review/src/tui/view/dialogs.tsx index f7394be..c5e9555 100644 --- a/packages/review/src/tui/view/dialogs.tsx +++ b/packages/review/src/tui/view/dialogs.tsx @@ -1,5 +1,5 @@ /** @jsxImportSource @opentui/solid */ -import type { TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { Host } from "@opencode-cockpit/client/host" import type { BaseCandidate } from "../../core/git/sources.ts" import { type Thread, threadWhere } from "../../core/model/thread.ts" import { cardRows } from "../../core/view/card.ts" @@ -88,12 +88,11 @@ export interface AskOptions { * dialog had a list of grey lines. */ export function askForNote( - api: TuiPluginApi, + api: Host, { title, description, thread, quoted, value, allowEmpty }: AskOptions, onConfirm: (text: string) => void, onClose?: () => void, ): void { - const DialogPrompt = api.ui.DialogPrompt const theme = () => api.theme.current /** @@ -140,49 +139,56 @@ export function askForNote( const colour = (tone: Tone | undefined) => toneColour(theme(), tone) const behind = (fill: Fill | undefined) => fillColour(theme(), fill) - api.ui.dialog.replace( - () => ( - ( - - - {description} - - {context().map((row) => ( - - {row.runs.map((run) => ( - - {run.text} - - ))} - - ))} - - )} - placeholder="what should change, and why" - value={value ?? ""} - onConfirm={(text: string) => { - api.ui.dialog.clear() - onClose?.() - const trimmed = text.trim() - if (trimmed || allowEmpty) onConfirm(trimmed) - }} - onCancel={() => { - api.ui.dialog.clear() - onClose?.() - }} - /> - ), - /** Dismissed any other way — escape, a click outside — still has to give the keys back. */ - () => onClose?.(), + const rich = () => ( + + + {description} + + {context().map((row) => ( + + {row.runs.map((run) => ( + + {run.text} + + ))} + + ))} + ) + /** The same context in words, for a host whose prompt takes only text (OpenCode 2). */ + const plain = [ + description, + ...context().map((row) => + row.runs + .map((run) => run.text) + .join("") + .trimEnd(), + ), + ] + .filter((line, index) => index === 0 || line.trim()) + .join("\n") + + void api.ui + .prompt({ + title, + description: plain, + rich, + placeholder: "what should change, and why", + value: value ?? "", + }) + .then((text) => { + /** However it closed, the keys come back. */ + onClose?.() + if (text === undefined) return + const trimmed = text.trim() + if (trimmed || allowEmpty) onConfirm(trimmed) + }) } /** @@ -193,7 +199,7 @@ export function askForNote( * how many commits of yours it would show, which is the number that decides it. */ export function askForBase( - api: TuiPluginApi, + api: Host, { current, guessed, @@ -202,33 +208,27 @@ export function askForBase( onSelect: (base: string | undefined) => void, onClose?: () => void, ): void { - const DialogSelect = api.ui.DialogSelect const AUTO = "\0auto" const plural = (n: number) => `${n} commit${n === 1 ? "" : "s"}` - api.ui.dialog.replace( - () => ( - ({ - title: each.ref, - value: each.ref, - description: `${plural(each.own)} of yours${each.other ? ` · it is ${plural(each.other)} ahead` : ""}`, - })), - ]} - onSelect={(option) => { - api.ui.dialog.clear() - onClose?.() - onSelect(option.value === AUTO ? undefined : (option.value as string)) - }} - /> - ), - () => onClose?.(), - ) + void api.ui + .select({ + title: "Compare the branch against", + current: current ?? AUTO, + options: [ + { + title: "auto: nearest parent", + value: AUTO, + description: guessed ? `currently ${guessed}` : "the branch this one grew from", + }, + ...candidates.map((each) => ({ + title: each.ref, + value: each.ref, + description: `${plural(each.own)} of yours${each.other ? ` · it is ${plural(each.other)} ahead` : ""}`, + })), + ], + }) + .then((value) => { + onClose?.() + if (value !== undefined) onSelect(value === AUTO ? undefined : value) + }) } diff --git a/packages/review/src/tui/view/overlay.tsx b/packages/review/src/tui/view/overlay.tsx index 2a85eb5..f1c83ae 100644 --- a/packages/review/src/tui/view/overlay.tsx +++ b/packages/review/src/tui/view/overlay.tsx @@ -1,11 +1,11 @@ /** @jsxImportSource @opentui/solid */ // biome-ignore-all lint/a11y/noStaticElementInteractions: these are terminal boxes, not DOM elements -import type { TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { Host } from "@opencode-cockpit/client/host" import type { BoxRenderable, MouseEvent, TextRenderable } from "@opentui/core" import type { JSX } from "solid-js" export interface OverlayProps { - api: TuiPluginApi + api: Host /** Hands the boxes and the line pool up. Everything about them is set from the plugin. */ onReady: (parts: { backdrop: BoxRenderable; panel: BoxRenderable; lines: TextRenderable[] }) => void /** A click landed outside the panel: dismiss, the way clicking off any overlay does. */ @@ -86,7 +86,6 @@ export function Overlay(props: OverlayProps): JSX.Element { flexShrink={0} flexDirection="column" backgroundColor={theme().backgroundPanel} - titleColor={theme().accent} /** Kept from reaching the backdrop, whose mouse-down dismisses the review. */ onMouseDown={(event: MouseEvent) => event.stopPropagation()} /** diff --git a/packages/review/src/tui/view/pool.ts b/packages/review/src/tui/view/pool.ts index 2fd6166..2d5a5aa 100644 --- a/packages/review/src/tui/view/pool.ts +++ b/packages/review/src/tui/view/pool.ts @@ -191,7 +191,9 @@ const soften = (ink: RGBA, behind: RGBA): RGBA => { let byBack = softened.get(ink) const known = byBack?.get(behind) if (known) return known - const Colour = ink.constructor as unknown as { clone: (colour: RGBA) => RGBA } + const Colour = ink.constructor as unknown as { clone?: (colour: RGBA) => RGBA } + /** Something that is not a colour is drawn as it came rather than taking the whole paint down. */ + if (typeof Colour.clone !== "function" || typeof behind?.r !== "number") return ink const out = Colour.clone(ink) out.r = ink.r + (behind.r - ink.r) * SOFTEN out.g = ink.g + (behind.g - ink.g) * SOFTEN @@ -225,6 +227,28 @@ const chunk = (theme: TuiThemeCurrent, run: Row["runs"][number]): TextChunk => { } as TextChunk } +/** + * A surface colour that is certain to paint. + * + * A theme may leave its backgrounds fully transparent — OpenCode's "system" theme lets the terminal's + * own background show through — and a full-window surface painted with one is not a surface at all: + * the conversation underneath shows through it. So the first opaque of the theme's backgrounds, and + * failing all of them, a solid near-black or near-white chosen against the text colour. + */ +export function solidSurface(theme: TuiThemeCurrent, prefer: "base" | "panel" = "base"): RGBA { + const order = + prefer === "panel" + ? [theme.backgroundPanel, theme.background, theme.backgroundElement] + : [theme.background, theme.backgroundPanel, theme.backgroundElement] + const found = order.find((colour) => colour !== undefined && colour.a > 0) + if (found) return found + const text = theme.text + const scale = Math.max(text.r, text.g, text.b) > 1 ? 255 : 1 + const light = (0.2126 * text.r + 0.7152 * text.g + 0.0722 * text.b) / scale > 0.5 + const Colour = text.constructor as unknown as { fromHex?: (hex: string) => RGBA } + return Colour.fromHex?.(light ? "#0b0b0e" : "#fafafa") ?? text +} + export interface RowPool { /** Draws these rows onto the pool's lines. */ draw: (rows: readonly Row[], theme: TuiThemeCurrent) => void diff --git a/packages/review/test/agent/plugin.test.ts b/packages/review/test/agent/plugin.test.ts index 8dfe297..15c4c87 100644 --- a/packages/review/test/agent/plugin.test.ts +++ b/packages/review/test/agent/plugin.test.ts @@ -3,6 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import type { Hooks, PluginInput } from "@opencode-ai/plugin" +import { partsToV1Hooks, serverFromV1 } from "@opencode-cockpit/client/server" import { createReviewServer } from "../../src/agent/plugin.ts" import type { Thread } from "../../src/core/model/thread.ts" import { reviewPaths } from "../../src/core/store/paths.ts" @@ -33,6 +34,10 @@ const input = (): PluginInput => }, }) as unknown as PluginInput +/** Review's server half, as v1 would load it. */ +const start = async (): Promise => + partsToV1Hooks(await createReviewServer()(serverFromV1(input()), undefined)) + /** The branch is whatever git says in a directory that is not a repository: nothing. */ const store = () => createPersistence(reviewPaths(home, undefined, { COCKPIT_HOME: home })) @@ -63,12 +68,12 @@ afterEach(async () => { describe("what the agent is told", () => { test("the three tools, and nothing it has to be told about twice", async () => { - const hooks = (await createReviewServer()(input())) as Hooks + const hooks = await start() expect(Object.keys(hooks.tool ?? {}).sort()).toEqual(["review_list", "review_open", "review_reply"]) }) test("how the review works, once per conversation", async () => { - const hooks = (await createReviewServer()(input())) as Hooks + const hooks = await start() const system = await systemOf(hooks) expect(system.join("\n")).toContain("review_list") expect(system.join("\n")).toContain("review_reply") @@ -76,7 +81,7 @@ describe("what the agent is told", () => { /** The guidance says the agent can leave notes of its own, so the tool for it has to exist. */ test("nothing the guidance promises is missing from the tools", async () => { - const hooks = (await createReviewServer()(input())) as Hooks + const hooks = await start() const system = (await systemOf(hooks)).join("\n") for (const name of Object.keys(hooks.tool ?? {})) { if (system.includes(name)) expect(hooks.tool?.[name]).toBeDefined() @@ -87,7 +92,7 @@ describe("what the agent is told", () => { /** The count is a second line, added only when there is one to add — so it is counted, not matched. */ test("and what is actually waiting, so it does not have to ask to find out there is nothing", async () => { - const hooks = (await createReviewServer()(input())) as Hooks + const hooks = await start() expect(await systemOf(hooks)).toHaveLength(1) await store().save(thread()) @@ -110,13 +115,13 @@ describe("what the agent is told", () => { entries: [{ author: "agent", body: "noted", at: 2 }], }), ) - const hooks = (await createReviewServer()(input())) as Hooks + const hooks = await start() expect(await systemOf(hooks)).toHaveLength(1) }) test("a review it cannot read is no reason to fail a conversation", async () => { await rm(home, { recursive: true, force: true }) - const hooks = (await createReviewServer()(input())) as Hooks + const hooks = await start() expect((await systemOf(hooks)).length).toBeGreaterThan(0) }) }) @@ -124,11 +129,11 @@ describe("what the agent is told", () => { describe("two copies of Review", () => { /** One from the bundle, one installed directly: the second stands down rather than double-register. */ test("the second registers nothing, and says why in the log", async () => { - const host = input() - const first = (await createReviewServer({ source: "opencode-cockpit" })(host)) as Hooks - const second = (await createReviewServer({ source: "@opencode-cockpit/review" })(host)) as Hooks - expect(Object.keys(first.tool ?? {})).toHaveLength(3) - expect(second.tool).toBeUndefined() + const host = serverFromV1(input()) + const first = await createReviewServer({ source: "opencode-cockpit" })(host, undefined) + const second = await createReviewServer({ source: "@opencode-cockpit/review" })(host, undefined) + expect(Object.keys(first.tools ?? {})).toHaveLength(3) + expect(second.tools).toBeUndefined() await Bun.sleep(5) expect(logged.join(" ")).toContain("Review") }) diff --git a/packages/review/test/watch/surface.test.ts b/packages/review/test/watch/surface.test.ts new file mode 100644 index 0000000..172617e --- /dev/null +++ b/packages/review/test/watch/surface.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test" +import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui" +import { solidSurface } from "../../src/tui/view/pool.ts" + +/** Just enough of OpenTUI's colour: channels 0..1, and the class a fallback is built from. */ +class Colour { + constructor( + readonly r: number, + readonly g: number, + readonly b: number, + readonly a: number, + readonly hex = "", + ) {} + static fromHex(hex: string): Colour { + return new Colour(0, 0, 0, 1, hex) + } +} +const clear = new Colour(0, 0, 0, 0) +const theme = (over: Partial>) => + ({ + text: new Colour(0.9, 0.9, 0.9, 1), + background: clear, + backgroundPanel: clear, + backgroundElement: clear, + ...over, + }) as unknown as TuiThemeCurrent + +describe("a surface that has to cover what is under it", () => { + test("uses the theme's own background when it paints", () => { + const solid = new Colour(0.1, 0.1, 0.1, 1) + expect(solidSurface(theme({ background: solid }))).toBe(solid as never) + }) + + /** OpenCode's "system" theme: every background transparent, so the terminal shows through. */ + test("skips transparent backgrounds for the next one that paints", () => { + const panel = new Colour(0.2, 0.2, 0.2, 1) + expect(solidSurface(theme({ backgroundPanel: panel }))).toBe(panel as never) + }) + + test("with none that paint, falls back to a solid colour against the text", () => { + expect((solidSurface(theme({})) as unknown as Colour).hex).toBe("#0b0b0e") + const dark = theme({ text: new Colour(0.1, 0.1, 0.1, 1) }) + expect((solidSurface(dark) as unknown as Colour).hex).toBe("#fafafa") + }) +}) diff --git a/packages/review/tui.js b/packages/review/tui.js new file mode 100644 index 0000000..43232b0 --- /dev/null +++ b/packages/review/tui.js @@ -0,0 +1,6 @@ +/** + * OpenCode 2 finds a plugin configured by *path* by the files at its root — `/tui`, + * `/server` — rather than through `exports` (docs/opencode/v2.md). A package installed by + * name resolves through `exports` as before; this file is only the door for the path case. + */ +export { default } from "./dist/tui/index.js" diff --git a/packages/shell/README.md b/packages/shell/README.md index 373ef7c..0f83b73 100644 --- a/packages/shell/README.md +++ b/packages/shell/README.md @@ -32,7 +32,7 @@ drives, and that you watch and control without leaving the chat. Part of opencode plugin @opencode-cockpit/shell@0.5.2 --global --force ``` -Restart OpenCode. Requires OpenCode 1.18 or newer on macOS or Linux. The version is pinned on +Restart OpenCode. Requires OpenCode 1.18+ or 2.0.15+ on macOS or Linux. The version is pinned on purpose: OpenCode never re-resolves a plugin spec, so `@latest` would stay on the first release it installed. To update, run `npx opencode-cockpit@latest update`. @@ -220,15 +220,18 @@ is exactly why the config file exists. | Problem | Look at | |---|---| +| Anything — start here | `npx opencode-cockpit@latest doctor`, then `~/.cache/opencode-cockpit/cockpit.log` (`COCKPIT_DEBUG=1 opencode` for detail) | | Tools fail with "did not start" | `~/.cache/opencode-cockpit/cockpitd.log` | | Plugin not loading | newest file in `~/.local/share/opencode/log/` | -| Warning: "Shell is configured twice" | Remove either `opencode-cockpit` or `@opencode-cockpit/shell` from `opencode.json` and `tui.json` | +| Warning: "Shell is configured twice" | Remove either `opencode-cockpit` or `@opencode-cockpit/shell` from `opencode.json` (and `tui.json` on OpenCode 1) | | Panel says the daemon runs older code | `/shells-restart-daemon` once your shells are done | +More in [Troubleshooting](https://codestz.github.io/opencode-cockpit/help/troubleshooting/). + ## How it works -OpenCode runs its interface and its server in separate threads, so the plugin's two halves can't -share memory. Both talk to `cockpitd`, which owns every process. Each shell's output feeds a line +OpenCode runs its interface and its server apart — threads on OpenCode 1, processes on OpenCode 2 — +so the plugin's two halves can't share memory. Both talk to `cockpitd`, which owns every process. Each shell's output feeds a line normalizer (the agent's log), a headless terminal emulator (the screen you see) and a raw ring buffer (replay for late viewers). See [CONTRIBUTING.md](https://github.com/Codestz/opencode-cockpit/blob/main/CONTRIBUTING.md). diff --git a/packages/shell/package.json b/packages/shell/package.json index 077a77a..73f3daa 100644 --- a/packages/shell/package.json +++ b/packages/shell/package.json @@ -42,6 +42,8 @@ "bun": ">=1.3.5" }, "files": [ + "server.js", + "tui.js", "dist", "types", "README.md", diff --git a/packages/shell/server.js b/packages/shell/server.js new file mode 100644 index 0000000..10f25fa --- /dev/null +++ b/packages/shell/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/shell/src/agent/plugin.ts b/packages/shell/src/agent/plugin.ts index 853fd80..fe2e33b 100644 --- a/packages/shell/src/agent/plugin.ts +++ b/packages/shell/src/agent/plugin.ts @@ -1,5 +1,10 @@ -import type { Hooks, Plugin, PluginInput, PluginModule } from "@opencode-ai/plugin" import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client" +import { + dualServer, + type ServerHost, + type ServerParts, + type ServerStart, +} from "@opencode-cockpit/client/server" import type { ShellInfo } from "@opencode-cockpit/protocol/shell" import { createClient } from "../connect.ts" import { loadConfig } from "../core/config.ts" @@ -20,37 +25,27 @@ export interface ShellServerOptions { } /** Shell's server half as a factory, so bundles such as `opencode-cockpit` can include it. */ -export function createShellServer({ source = SHELL_PACKAGE }: ShellServerOptions = {}): Plugin { - return async (input, options) => { - const claim = claimFeature(input, "shell", source) +export function createShellServer({ source = SHELL_PACKAGE }: ShellServerOptions = {}): ServerStart { + return async (host, options) => { + const claim = claimFeature(host.scope, "shell", source) if (!claim.active) { - // Logging through the server during plugin initialisation could wait on ourselves; defer it. - setTimeout(() => { - void input.client.app - .log({ - body: { - service: "opencode-cockpit", - level: "warn", - message: duplicateFeatureMessage("Shell", claim.owner, source), - }, - }) - .catch(() => {}) - }, 0) + host.log.warn(duplicateFeatureMessage("Shell", claim.owner, source)) return {} } - const hooks = await shellHooks(input, options) - const dispose = hooks.dispose + const parts = await shellParts(host, options) return { - ...hooks, + ...parts, dispose: async () => { claim.release() - await dispose?.() + await parts.dispose?.() }, } } } -async function shellHooks({ client: opencode, directory }: PluginInput, options?: unknown): Promise { +async function shellParts(host: ServerHost, options?: unknown): Promise { + const { directory } = host + const log = host.log.child("shell") const config = loadConfig(directory, options) // Identifies this OpenCode window to the daemon, so shells can end with it. const instance = crypto.randomUUID() @@ -84,8 +79,7 @@ async function shellHooks({ client: opencode, directory }: PluginInput, options? if (hit && Date.now() - hit.at < 60_000) return hit.root let at = sessionID for (let hop = 0; hop < 8; hop++) { - const result = await opencode.session.get({ path: { id: at } }).catch(() => undefined) - const parent = (result?.data as { parentID?: string } | undefined)?.parentID + const parent = (await host.session.get(at))?.parentID if (!parent || parent === at) break at = parent } @@ -98,8 +92,7 @@ async function shellHooks({ client: opencode, directory }: PluginInput, options? const sessionTitle = async (sessionID: string): Promise => { const hit = titles.get(sessionID) if (hit && Date.now() - hit.at < 60_000) return hit.title - const result = await opencode.session.get({ path: { id: sessionID } }).catch(() => undefined) - const title = (result?.data as { title?: string } | undefined)?.title + const title = (await host.session.get(sessionID))?.title titles.set(sessionID, { title, at: Date.now() }) return title } @@ -108,7 +101,7 @@ async function shellHooks({ client: opencode, directory }: PluginInput, options? cockpit.on("shell.exited", (info) => { if (info.owner.instance !== instance || !info.owner.session) return if (quiet.delete(info.id) || config.notify?.exit === false) return - void notifyExit(info).catch(() => {}) + void notifyExit(info).catch((error) => log.warn("exit notice not delivered", { shell: info.id, error })) }) // A watcher's health changed. This is the whole point of watching: one message per change, never @@ -128,12 +121,9 @@ async function shellHooks({ client: opencode, directory }: PluginInput, options? ] .filter(Boolean) .join("\n") - void opencode.session - .promptAsync({ - path: { id: info.owner.session }, - body: { parts: [{ type: "text", text, synthetic: true } as never] }, - }) - .catch(() => {}) + void host.session + .notify(info.owner.session, text) + .catch((error) => log.warn("health notice not delivered", { shell: info.id, error })) }) async function notifyExit(info: ShellInfo): Promise { @@ -152,14 +142,11 @@ async function shellHooks({ client: opencode, directory }: PluginInput, options? ? `Investigate with shell_read id=${info.id} grep="error|fail" if the failure matters to the task.` : `Full output: shell_read id=${info.id}.`, ].join("\n") - await opencode.session.promptAsync({ - path: { id: session }, - body: { parts: [{ type: "text", text, synthetic: true } as never] }, - }) + await host.session.notify(session, text) } return { - tool: createTools({ + tools: createTools({ client: cockpit, instance, quiet, @@ -170,17 +157,18 @@ async function shellHooks({ client: opencode, directory }: PluginInput, options? shellCommand: (command) => ({ command: userShell, args: ["-c", command] }), }), - "experimental.chat.system.transform": async (input, output) => { - if (config.guidance !== false) output.system.push(GUIDANCE) + system: async (sessionID) => { + const system: string[] = [] + if (config.guidance !== false) system.push(GUIDANCE) /** Shells are owned by the conversation, so "is this mine?" has to ask about the same thing. */ - const here = (await rootSession(input.sessionID).catch(() => undefined)) ?? input.sessionID + const here = (await rootSession(sessionID).catch(() => undefined)) ?? sessionID const listLimit = config.listRunningShells ?? 15 - if (listLimit <= 0) return + if (listLimit <= 0) return system const running = await cockpit .call("shell.list", { owner: { project: directory }, includeExited: false }) .catch(() => [] as ShellInfo[]) if (running.length > 0) { - output.system.push( + system.push( `Background shells currently running in this project:\n${running .slice(0, listLimit) .map((s) => { @@ -195,11 +183,10 @@ async function shellHooks({ client: opencode, directory }: PluginInput, options? .join("\n")}`, ) } + return system }, - event: async ({ event }) => { - if (event.type !== "session.deleted") return - const sessionID = event.properties.info.id + sessionDeleted: async (sessionID) => { const owned = await cockpit .call("shell.list", { owner: { session: sessionID } }) .catch(() => [] as ShellInfo[]) @@ -215,5 +202,4 @@ async function shellHooks({ client: opencode, directory }: PluginInput, options? } } -const plugin: PluginModule & { id: string } = { id: "opencode-cockpit.shell", server: createShellServer() } -export default plugin +export default dualServer("opencode-cockpit.shell", createShellServer()) diff --git a/packages/shell/src/tui/components/badge.tsx b/packages/shell/src/tui/components/badge.tsx index b0a2b73..2d5e69d 100644 --- a/packages/shell/src/tui/components/badge.tsx +++ b/packages/shell/src/tui/components/badge.tsx @@ -1,5 +1,5 @@ /** @jsxImportSource @opentui/solid */ -import type { TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { Host } from "@opencode-cockpit/client/host" import type { ShellInfo } from "@opencode-cockpit/protocol/shell" import { BADGE_RULE, badgeText, kindColor, kindOf } from "../lib/view.ts" @@ -10,7 +10,7 @@ import { BADGE_RULE, badgeText, kindColor, kindOf } from "../lib/view.ts" * them reads as a wall of colour rather than as a list of shells; the rule carries the same * meaning in one column. */ -export function Badge(props: { api: TuiPluginApi; shell: ShellInfo; frame: number }) { +export function Badge(props: { api: Host; shell: ShellInfo; frame: number }) { const theme = () => props.api.theme.current const kind = () => kindOf(props.shell) const colour = () => kindColor(theme(), kind()) diff --git a/packages/shell/src/tui/components/console.tsx b/packages/shell/src/tui/components/console.tsx index 094e3af..f1198d6 100644 --- a/packages/shell/src/tui/components/console.tsx +++ b/packages/shell/src/tui/components/console.tsx @@ -1,27 +1,20 @@ /** @jsxImportSource @opentui/solid */ -import type { TuiPluginApi } from "@opencode-ai/plugin/tui" -import { useBindings } from "@opentui/keymap/solid" +import type { Host } from "@opencode-cockpit/client/host" import { For } from "solid-js" import type { Row } from "../lib/console.ts" import { fillColour, toneColour } from "../view/pool.ts" -/** The shared table's commands and bindings, and when they apply — all `useBindings` needs. */ -export interface DialogKeys { - commands: Layer["commands"] - bindings: Layer["bindings"] - enabled: () => boolean -} -type Layer = Parameters[0] +type Layer = Parameters[0] export interface ConsoleProps { - api: TuiPluginApi + api: Host /** The console's rows at the dialog's size — the same rows full screen draws, smaller. */ rows: () => Row[] /** * The console's one key table (`panel/keys.ts`). Registered from inside the dialog: while the * host's dialog is open it takes the keys, so a global layer never hears them. */ - keys: () => DialogKeys + keys: () => Layer } /** @@ -33,14 +26,8 @@ export interface ConsoleProps { */ export function Console(props: ConsoleProps) { const theme = () => props.api.theme.current - useBindings(() => { - const keys = props.keys() - return { commands: keys.commands, bindings: keys.bindings, enabled: keys.enabled } as Parameters< - typeof useBindings - >[0] extends () => infer L - ? L - : never - }) + /** Owned by this component, so the keys go when the dialog does — on either OpenCode. */ + props.api.keymap.useLayer(props.keys) return ( diff --git a/packages/shell/src/tui/components/dock.tsx b/packages/shell/src/tui/components/dock.tsx index c28a4fb..5c13d7a 100644 --- a/packages/shell/src/tui/components/dock.tsx +++ b/packages/shell/src/tui/components/dock.tsx @@ -1,5 +1,5 @@ /** @jsxImportSource @opentui/solid */ -import type { TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { Host } from "@opencode-cockpit/client/host" import { useTerminalDimensions } from "@opentui/solid" import { createMemo, For, Show } from "solid-js" import { @@ -18,7 +18,7 @@ import { useScreen } from "../state/store.ts" import { Badge } from "./badge.tsx" export interface DockProps { - api: TuiPluginApi + api: Host store: ShellStore height: number /** Paint the colours programs print (config: ui.colors). */ diff --git a/packages/shell/src/tui/components/sidebar.tsx b/packages/shell/src/tui/components/sidebar.tsx index 0139f93..0051f26 100644 --- a/packages/shell/src/tui/components/sidebar.tsx +++ b/packages/shell/src/tui/components/sidebar.tsx @@ -1,12 +1,12 @@ /** @jsxImportSource @opentui/solid */ -import type { TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { Host } from "@opencode-cockpit/client/host" import { createMemo, For, Show } from "solid-js" import { kindOf, shortDetail, truncate, watchColor, watchLabel } from "../lib/view.ts" import type { ShellStore } from "../state/store.ts" import { Badge } from "./badge.tsx" export interface SidebarProps { - api: TuiPluginApi + api: Host store: ShellStore /** Rows shown before the rest folds away; the sidebar is a narrow, shared column. */ rows?: number diff --git a/packages/shell/src/tui/dialogs.tsx b/packages/shell/src/tui/dialogs.tsx index a77399b..49838a6 100644 --- a/packages/shell/src/tui/dialogs.tsx +++ b/packages/shell/src/tui/dialogs.tsx @@ -1,48 +1,42 @@ -/** @jsxImportSource @opentui/solid */ - -import type { TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { Host } from "@opencode-cockpit/client/host" import { order, shellListItem } from "./lib/view.ts" import type { ShellStore } from "./state/store.ts" -export function newShell(api: TuiPluginApi, store: ShellStore, open: (id?: string) => void) { - const DialogPrompt = api.ui.DialogPrompt - api.ui.dialog.replace(() => ( - { - const command = value.trim() - if (!command) return api.ui.dialog.clear() - const shell = - process.env.SHELL && /(bash|zsh|fish|sh)$/.test(process.env.SHELL) ? process.env.SHELL : "/bin/bash" - store.client - .call("shell.start", { - command: shell, - args: ["-c", command], - cwd: store.project(), - title: command.slice(0, 60), - owner: { project: store.project(), session: store.session() }, - reuse: true, - }) - .then((info) => { - void store.refresh() - open(info.id) - }) - .catch((err) => { - api.ui.dialog.clear() - api.ui.toast({ - variant: "error", - title: "Shell", - message: err instanceof Error ? err.message : String(err), - }) - }) - }} - onCancel={() => api.ui.dialog.clear()} - /> - )) +/** + * The dialogs are the host's own — a prompt, a confirmation, a list — reached through `Host`, so the + * same code opens v1's components and v2's promise dialogs. + */ +export function newShell(api: Host, store: ShellStore, open: (id?: string) => void) { + void api.ui.prompt({ title: "New background shell", placeholder: "npm run dev" }).then((value) => { + const command = value?.trim() + if (!command) return + const shell = + process.env.SHELL && /(bash|zsh|fish|sh)$/.test(process.env.SHELL) ? process.env.SHELL : "/bin/bash" + store.client + .call("shell.start", { + command: shell, + args: ["-c", command], + cwd: store.project(), + title: command.slice(0, 60), + owner: { project: store.project(), session: store.session() }, + reuse: true, + }) + .then((info) => { + void store.refresh() + open(info.id) + }) + .catch((error) => { + api.log.error("shell: start failed", { error }) + api.ui.toast({ + variant: "error", + title: "Shell", + message: error instanceof Error ? error.message : String(error), + }) + }) + }) } -export function restartDaemon(api: TuiPluginApi, store: ShellStore) { +export function restartDaemon(api: Host, store: ShellStore) { const running = store.shells().filter((s) => s.status === "running").length const restart = (force: boolean) => { api.ui.dialog.clear() @@ -56,25 +50,27 @@ export function restartDaemon(api: TuiPluginApi, store: ShellStore) { message: ok ? "Shell daemon restarted" : "Shells are running; restart was not forced", }) }) - .catch((err) => api.ui.toast({ variant: "error", title: "Shells", message: String(err) })) + .catch((error) => { + api.log.error("shell: daemon restart failed", { error }) + api.ui.toast({ variant: "error", title: "Shells", message: String(error) }) + }) } if (running === 0) return restart(false) - const DialogConfirm = api.ui.DialogConfirm - api.ui.dialog.replace(() => ( - restart(true)} - onCancel={() => api.ui.dialog.clear()} - /> - )) + void api.ui + .confirm({ + title: "Restart shell daemon?", + message: `${running} running shell${running === 1 ? "" : "s"} will be stopped.`, + }) + .then((yes) => { + if (yes) restart(true) + }) } /** * Stopping shells in bulk. Two commands rather than one, because "everything I can see" and * "everything, including what I cannot" are different intentions — and the second one asks first. */ -export function stopShells(api: TuiPluginApi, store: ShellStore, reach: "view" | "project"): void { +export function stopShells(api: Host, store: ShellStore, reach: "view" | "project"): void { const list = (reach === "view" ? store.shells() : store.all()).filter((s) => s.status === "running") if (list.length === 0) { api.ui.toast({ title: "Shells", message: "Nothing is running.", duration: 3000 }) @@ -103,15 +99,14 @@ export function stopShells(api: TuiPluginApi, store: ShellStore, reach: "view" | return } - const DialogConfirm = api.ui.DialogConfirm - api.ui.dialog.replace(() => ( - api.ui.dialog.clear()} - /> - )) + void api.ui + .confirm({ + title: "Stop every shell in this project?", + message: `${list.length} running · ${elsewhere} from other conversations.`, + }) + .then((yes) => { + if (yes) stop() + }) } /** @@ -123,16 +118,15 @@ export function stopShells(api: TuiPluginApi, store: ShellStore, reach: "view" | */ const NEW_SHELL = "\0new" -export function pickShell(api: TuiPluginApi, store: ShellStore, open: (id?: string) => void) { - const DialogSelect = api.ui.DialogSelect +export function pickShell(api: Host, store: ShellStore, open: (id?: string) => void) { /** Every shell in the project: this is where you go to find one, whichever conversation started it. */ const shells = order(store.all()) - api.ui.dialog.replace(() => ( - ({ + title: "Shells", + placeholder: "Search shells", + current: store.selected()?.id ?? NEW_SHELL, + options: [ { title: "+ New shell", value: NEW_SHELL, @@ -146,14 +140,14 @@ export function pickShell(api: TuiPluginApi, store: ShellStore, open: (id?: stri value: s.id, description: item.description, category: item.category, - /** Plain text: the host draws the footer inside its own text node. */ footer: `● ${item.status}`, } }), - ]} - onSelect={(option) => - option.value === NEW_SHELL ? newShell(api, store, open) : open(option.value as string) - } - /> - )) + ], + }) + .then((value) => { + if (value === undefined) return + if (value === NEW_SHELL) newShell(api, store, open) + else open(value) + }) } diff --git a/packages/shell/src/tui/index.tsx b/packages/shell/src/tui/index.tsx index b35f0e9..c480e6f 100644 --- a/packages/shell/src/tui/index.tsx +++ b/packages/shell/src/tui/index.tsx @@ -1,7 +1,7 @@ /** @jsxImportSource @opentui/solid */ -import { createBindingLookup, type TuiPlugin, type TuiPluginModule } from "@opencode-ai/plugin/tui" import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client" +import { bindingLookup, dualTui, type Host } from "@opencode-cockpit/client/host" import type { BoxRenderable } from "@opentui/core" import { createSignal } from "solid-js" import { createClient } from "../connect.ts" @@ -12,7 +12,6 @@ import { SidebarShells } from "./components/sidebar.tsx" import { newShell, pickShell, restartDaemon, stopShells } from "./dialogs.tsx" import { screenCols } from "./lib/console.ts" import { isReleaseKey, keyToBytes } from "./lib/keys.ts" -import { trace } from "./lib/trace.ts" import { createActions, HISTORY } from "./panel/actions.ts" import { createFeed } from "./panel/feed.ts" import { consoleLayer } from "./panel/keys.ts" @@ -33,8 +32,8 @@ export type ShellTuiOptions = NonNullable const SHELL_PACKAGE = "@opencode-cockpit/shell" /** Shell's TUI half as a factory, so bundles such as `opencode-cockpit` can include it. */ -export function createShellTui({ source = SHELL_PACKAGE }: { source?: string } = {}): TuiPlugin { - return async (api, rawOptions, meta) => { +export function createShellTui({ source = SHELL_PACKAGE }: { source?: string } = {}) { + return async (api: Host, rawOptions?: unknown) => { // The renderer is shared by every TUI plugin in this OpenCode window. const claim = claimFeature(api.renderer, "shell", source) if (!claim.active) { @@ -47,17 +46,18 @@ export function createShellTui({ source = SHELL_PACKAGE }: { source?: string } = return } api.lifecycle.onDispose(() => claim.release()) - await shellTui(api, rawOptions, meta) + await shellTui(api, rawOptions) } } -const shellTui: TuiPlugin = async (api, rawOptions, _meta) => { +const shellTui = async (api: Host, rawOptions?: unknown) => { // Settings come from the shared config file; plugin-entry options still win, flat or under "ui". + const log = api.log.child("shell") const config = loadConfig(api.state.path.directory, rawOptions) const options: ShellTuiOptions = config.ui ?? {} const client = createClient("opencode-cockpit/tui") const store = createShellStore(api, client, { historyMinutes: options.historyMinutes }) - const keys = createBindingLookup({ ...DEFAULT_KEYS, ...options.keybinds }) + const keys = bindingLookup({ ...DEFAULT_KEYS, ...options.keybinds }) // An explicit `ui.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. @@ -69,10 +69,7 @@ const shellTui: TuiPlugin = async (api, rawOptions, _meta) => { setDockOpen(next) api.kv.set("cockpit.dock.open", next) } - const shortcut = (command: string) => { - const bindings = api.keymap.getCommandBindings({ visibility: "registered", commands: [command] }) - return api.keys.formatBindings(bindings.get(command)) ?? "" - } + const shortcut = (command: string) => api.keymap.shortcut(command) /** * The console: one surface, one feed, one painter, one key table — at two sizes. @@ -151,14 +148,10 @@ const shellTui: TuiPlugin = async (api, rawOptions, _meta) => { version() return painter.rows() }} - keys={() => { - const layer = consoleLayer(actions) - return { - commands: layer.commands, - bindings: layer.bindings, - enabled: () => !surface.typing && !surface.searching, - } - }} + keys={() => ({ + ...consoleLayer(actions), + enabled: () => !surface.typing && !surface.searching, + })} /> ), /** Closed by the host — escape, a click outside — is the console closing, unless we swapped. */ @@ -174,7 +167,7 @@ const shellTui: TuiPlugin = async (api, rawOptions, _meta) => { const closeConsole = () => { if (!surface.open) return - trace("console: close", { full: surface.full }) + log.debug("console: close", { full: surface.full }) Object.assign(surface, { open: false, typing: false, searching: false, notice: undefined }) disposeKeys?.() disposeKeys = undefined @@ -197,7 +190,7 @@ const shellTui: TuiPlugin = async (api, rawOptions, _meta) => { swapping = true surface.full = !surface.full api.kv.set(FULL_KEY, surface.full) - trace("console: resize", { full: surface.full }) + log.debug("console: resize", { full: surface.full }) if (surface.full) { api.ui.dialog.clear() disposeKeys ??= api.keymap.registerLayer(consoleLayer(actions)) @@ -225,7 +218,6 @@ const shellTui: TuiPlugin = async (api, rawOptions, _meta) => { /** Search and typing take keys before the layer: a query or a program must get every key. */ api.keymap.intercept( - "key", (ctx) => { if (!surface.open) return const event = ctx.event @@ -259,7 +251,7 @@ const shellTui: TuiPlugin = async (api, rawOptions, _meta) => { const openConsole = (id?: string, typeInto = false) => { if (id) store.select(id) - trace("console: open", { id, full: surface.full }) + log.debug("console: open", { id, full: surface.full }) const already = surface.open Object.assign(surface, { open: true, @@ -340,7 +332,10 @@ const shellTui: TuiPlugin = async (api, rawOptions, _meta) => { message: `Cleared ${n} finished shell${n === 1 ? "" : "s"}`, }), ) - .catch((err) => api.ui.toast({ variant: "error", title: "Shells", message: String(err) })) + .catch((error) => { + log.error("clear failed", { error }) + api.ui.toast({ variant: "error", title: "Shells", message: String(error) }) + }) }, }, { @@ -389,7 +384,7 @@ const shellTui: TuiPlugin = async (api, rawOptions, _meta) => { onReady={(parts) => { backdrop = parts.backdrop pool = createRowPool(parts.lines) - trace("full: mounted") + log.debug("full: mounted") draw() }} onScroll={(delta) => actions.scroll(delta)} @@ -434,5 +429,5 @@ const shellTui: TuiPlugin = async (api, rawOptions, _meta) => { }) } -const plugin: TuiPluginModule & { id: string } = { id: "opencode-cockpit.shell", tui: createShellTui() } -export default plugin +/** One entry for both OpenCodes: v1 calls `tui`, v2 calls `setup` (docs/opencode/v2.md). */ +export default dualTui("opencode-cockpit.shell", createShellTui()) diff --git a/packages/shell/src/tui/lib/trace.ts b/packages/shell/src/tui/lib/trace.ts deleted file mode 100644 index 1cca071..0000000 --- a/packages/shell/src/tui/lib/trace.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { appendFileSync } from "node:fs" -import { join } from "node:path" -import { resolvePaths } from "@opencode-cockpit/protocol" - -/** - * What the TUI half did, as JSON lines in `/tui.log` — beside the daemon's own log, in - * the same shape, so the two read as one story. - * - * The TUI has nowhere else to say anything: stderr is the terminal it is drawing on, and a toast is - * gone before you read it. This exists for the moments a view does something only on one person's - * machine. Synchronous and swallowed: a trace that can break the interface is worse than none. - */ -const file = (() => { - try { - return join(resolvePaths().home, "tui.log") - } catch { - return undefined - } -})() - -export function trace(msg: string, fields?: Record): void { - if (!file) return - try { - appendFileSync( - file, - `${JSON.stringify({ t: new Date().toISOString(), scope: "shell-tui", msg, ...fields })}\n`, - { - mode: 0o600, - }, - ) - } catch { - // Never let a log line take the interface down. - } -} diff --git a/packages/shell/src/tui/panel/keys.ts b/packages/shell/src/tui/panel/keys.ts index b00bd6a..4b14f7a 100644 --- a/packages/shell/src/tui/panel/keys.ts +++ b/packages/shell/src/tui/panel/keys.ts @@ -7,10 +7,10 @@ * given back the moment it closes. Typing and search take keys before this, in the host's intercept. */ -import type { TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { Host } from "@opencode-cockpit/client/host" import type { Actions } from "./actions.ts" -type Layer = Parameters[0] +type Layer = Parameters[0] export function consoleLayer(actions: Actions): Layer { return { diff --git a/packages/shell/src/tui/panel/paint.ts b/packages/shell/src/tui/panel/paint.ts index 0dfc15b..5bc0050 100644 --- a/packages/shell/src/tui/panel/paint.ts +++ b/packages/shell/src/tui/panel/paint.ts @@ -1,9 +1,9 @@ -import type { TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { Host } from "@opencode-cockpit/client/host" import type { BoxRenderable } from "@opentui/core" import { bodyHeight, type ConsoleInput, consoleRows } from "../lib/console.ts" import { order } from "../lib/view.ts" import type { ShellStore } from "../state/store.ts" -import type { RowPool } from "../view/pool.ts" +import { type RowPool, solidSurface } from "../view/pool.ts" import type { Feed } from "./feed.ts" import type { Surface } from "./surface.ts" @@ -14,7 +14,7 @@ export interface Boxes { } export interface PaintDeps { - api: TuiPluginApi + api: Host store: ShellStore surface: Surface feed: Feed @@ -112,6 +112,8 @@ export function createPainter(deps: PaintDeps): Painter { const { backdrop, pool } = deps.boxes() const full = surface.open && surface.full if (backdrop && pool) { + /** Opaque whatever the theme says, or the conversation shows through (transparent themes). */ + backdrop.backgroundColor = solidSurface(api.theme.current) backdrop.width = api.renderer.width backdrop.height = full ? api.renderer.height : 0 backdrop.visible = full diff --git a/packages/shell/src/tui/state/store.ts b/packages/shell/src/tui/state/store.ts index e92259d..520c3ef 100644 --- a/packages/shell/src/tui/state/store.ts +++ b/packages/shell/src/tui/state/store.ts @@ -1,6 +1,6 @@ import { existsSync } from "node:fs" -import type { TuiPluginApi } from "@opencode-ai/plugin/tui" import type { CockpitClient } from "@opencode-cockpit/client" +import type { Host } from "@opencode-cockpit/client/host" import type { ScreenResult, ShellInfo } from "@opencode-cockpit/protocol/shell" import { type Accessor, createEffect, createMemo, createRoot, createSignal, on, onCleanup } from "solid-js" import { createStore, reconcile } from "solid-js/store" @@ -45,11 +45,7 @@ export interface StoreOptions { scope?: Scope } -export function createShellStore( - api: TuiPluginApi, - client: CockpitClient, - options: StoreOptions = {}, -): ShellStore { +export function createShellStore(api: Host, client: CockpitClient, options: StoreOptions = {}): ShellStore { return createRoot((dispose) => { const [state, setState] = createStore<{ list: ShellInfo[] }>({ list: [] }) const [connected, setConnected] = createSignal(false) diff --git a/packages/shell/src/tui/view/overlay.tsx b/packages/shell/src/tui/view/overlay.tsx index a7e93a4..d739fe1 100644 --- a/packages/shell/src/tui/view/overlay.tsx +++ b/packages/shell/src/tui/view/overlay.tsx @@ -1,6 +1,6 @@ /** @jsxImportSource @opentui/solid */ // biome-ignore-all lint/a11y/noStaticElementInteractions: these are terminal boxes, not DOM elements -import type { TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { Host } from "@opencode-cockpit/client/host" import type { BoxRenderable, MouseEvent, TextRenderable } from "@opentui/core" import type { JSX } from "solid-js" @@ -8,7 +8,7 @@ import type { JSX } from "solid-js" export const MAX_LINES = 300 export interface OverlayProps { - api: TuiPluginApi + api: Host onReady: (parts: { backdrop: BoxRenderable; lines: TextRenderable[] }) => void /** The wheel turned over the console: negative is up. */ onScroll: (delta: number) => void diff --git a/packages/shell/src/tui/view/pool.ts b/packages/shell/src/tui/view/pool.ts index d74119e..b8d3b89 100644 --- a/packages/shell/src/tui/view/pool.ts +++ b/packages/shell/src/tui/view/pool.ts @@ -37,6 +37,28 @@ export const toneColour = (theme: TuiThemeCurrent, name: Tone | undefined): RGBA export const fillColour = (theme: TuiThemeCurrent, run: Run): RGBA | undefined => run.tone === "match" ? theme.warning : run.raised ? theme.backgroundElement : undefined +/** + * A surface colour that is certain to paint. + * + * A theme may leave its backgrounds fully transparent — OpenCode's "system" theme lets the terminal's + * own background show through — and a full-window surface painted with one is not a surface at all: + * the conversation underneath shows through it. So the first opaque of the theme's backgrounds, and + * failing all of them, a solid near-black or near-white chosen against the text colour. + */ +export function solidSurface(theme: TuiThemeCurrent, prefer: "base" | "panel" = "base"): RGBA { + const order = + prefer === "panel" + ? [theme.backgroundPanel, theme.background, theme.backgroundElement] + : [theme.background, theme.backgroundPanel, theme.backgroundElement] + const found = order.find((colour) => colour !== undefined && colour.a > 0) + if (found) return found + const text = theme.text + const scale = Math.max(text.r, text.g, text.b) > 1 ? 255 : 1 + const light = (0.2126 * text.r + 0.7152 * text.g + 0.0722 * text.b) / scale > 0.5 + const Colour = text.constructor as unknown as { fromHex?: (hex: string) => RGBA } + return Colour.fromHex?.(light ? "#0b0b0e" : "#fafafa") ?? text +} + export interface RowPool { draw: (rows: readonly Row[], theme: TuiThemeCurrent) => void clear: () => void diff --git a/packages/shell/tui.js b/packages/shell/tui.js new file mode 100644 index 0000000..43232b0 --- /dev/null +++ b/packages/shell/tui.js @@ -0,0 +1,6 @@ +/** + * OpenCode 2 finds a plugin configured by *path* by the files at its root — `/tui`, + * `/server` — rather than through `exports` (docs/opencode/v2.md). A package installed by + * name resolves through `exports` as before; this file is only the door for the path case. + */ +export { default } from "./dist/tui/index.js" diff --git a/packages/status/README.md b/packages/status/README.md index e67e952..21147d9 100644 --- a/packages/status/README.md +++ b/packages/status/README.md @@ -1,6 +1,6 @@ # @opencode-cockpit/status -A statusline for [OpenCode](https://opencode.ai) you can actually configure — declarative segments, +A statusline for [OpenCode](https://opencode.ai) you can actually configure: declarative segments, your own TypeScript, or the statusline script you already wrote for Claude Code. ![The statusline under an OpenCode conversation: a context bar at 40%, the token total with its cache, input and output parts, the session diff, elapsed time and todo progress](https://raw.githubusercontent.com/Codestz/opencode-cockpit/main/media/statusline.png) @@ -292,9 +292,29 @@ Without them the `cost` and `context` segments stay silent instead of reporting 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. +## Troubleshooting + +```sh +npx opencode-cockpit@latest doctor +``` + +checks OpenCode, its config, Cockpit's logs and the daemon, and prints the fix for anything wrong — +on OpenCode 1 and 2, and when Cockpit will not load at all ([what it checks](https://codestz.github.io/opencode-cockpit/help/doctor/)). + +Everything Cockpit does inside OpenCode goes to one file — which OpenCode loaded which bay, and every +error with its stack: + +```sh +tail -50 ~/.cache/opencode-cockpit/cockpit.log +``` + +`COCKPIT_DEBUG=1 opencode` adds the detail. [Troubleshooting](https://codestz.github.io/opencode-cockpit/help/troubleshooting/) covers +the failures people hit and what to attach to an issue; [OpenCode 1 and 2](https://codestz.github.io/opencode-cockpit/start/opencode-versions/) +covers what differs between the two. + ## Requirements -OpenCode 1.18+ and Bun 1.3.5+. +OpenCode 1.18+ or 2.0.15+, and Bun 1.3.5+. ## Licence diff --git a/packages/status/examples/sidebar.ts b/packages/status/examples/sidebar.ts index 4a4137e..7b55450 100644 --- a/packages/status/examples/sidebar.ts +++ b/packages/status/examples/sidebar.ts @@ -1,5 +1,5 @@ /** - * A small, quiet sidebar: a coloured context bar and the two figures behind it. + * 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 diff --git a/packages/status/package.json b/packages/status/package.json index 2027f9a..38e6103 100644 --- a/packages/status/package.json +++ b/packages/status/package.json @@ -46,6 +46,7 @@ "bun": ">=1.3.5" }, "files": [ + "tui.js", "dist", "types", "examples", diff --git a/packages/status/src/core/custom.ts b/packages/status/src/core/custom.ts index 1d36204..b135371 100644 --- a/packages/status/src/core/custom.ts +++ b/packages/status/src/core/custom.ts @@ -58,6 +58,12 @@ const DEFAULT_PRIORITY = 45 /** The specifier a module is written against, which is the whole point of the failure below. */ const AUTHORING = "@opencode-cockpit/status/segment" +/** + * What the failure names. v1 says `Cannot find module '@opencode-cockpit/status/segment'`; v2 names + * only the package — `Cannot find package '@opencode-cockpit/status'` — and matching the full + * specifier left every module outside a project unloaded there. + */ +const AUTHORING_PACKAGE = "@opencode-cockpit/status" /** * Loading a module that lives outside a project. @@ -125,7 +131,7 @@ export async function loadCustomSegments( } catch (err) { // Only the one failure is worth retrying; anything else is the module's own problem. const message = err instanceof Error ? err.message : String(err) - if (!message.includes(AUTHORING)) throw err + if (!message.includes(AUTHORING_PACKAGE)) throw err loaded = (await importWithAuthoring(full)) as { default?: CustomModule } & CustomModule } const module = loaded.default ?? loaded diff --git a/packages/status/src/tui/components/statusline.tsx b/packages/status/src/tui/components/statusline.tsx index ce865eb..a6bb7e5 100644 --- a/packages/status/src/tui/components/statusline.tsx +++ b/packages/status/src/tui/components/statusline.tsx @@ -1,6 +1,7 @@ /** @jsxImportSource @opentui/solid */ -import type { TuiPluginApi, TuiThemeCurrent } from "@opencode-ai/plugin/tui" +import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui" +import type { Host } from "@opencode-cockpit/client/host" import type { JSX } from "solid-js" import { For, Show } from "solid-js" import type { Run, Segment, Tone } from "../../core/segments.ts" @@ -65,7 +66,7 @@ function decorate(run: Run): JSX.Element { } export interface StatusLineProps { - api: TuiPluginApi + api: Host segments: () => Segment[] separator: string /** Across the window, or down a column. */ diff --git a/packages/status/src/tui/index.tsx b/packages/status/src/tui/index.tsx index bda0226..693b377 100644 --- a/packages/status/src/tui/index.tsx +++ b/packages/status/src/tui/index.tsx @@ -1,7 +1,7 @@ /** @jsxImportSource @opentui/solid */ -import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui" import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client/feature" +import { dualTui, type Host } from "@opencode-cockpit/client/host" import { createMemo } from "solid-js" import pkg from "../../package.json" with { type: "json" } import { asSegmentConfig, loadStatusConfig, type ResolvedLine, resolveLines } from "../core/config.ts" @@ -12,14 +12,14 @@ 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 } from "./state/snapshot.ts" +import { buildContext, currentSession } from "./state/snapshot.ts" import { createStatusStore } from "./state/store.ts" const STATUS_PACKAGE = "@opencode-cockpit/status" /** Status' TUI half as a factory, so bundles such as `opencode-cockpit` can include it. */ -export function createStatusTui({ source = STATUS_PACKAGE }: { source?: string } = {}): TuiPlugin { - return async (api, rawOptions) => { +export function createStatusTui({ source = STATUS_PACKAGE }: { source?: string } = {}) { + return async (api: Host, rawOptions?: unknown) => { // The renderer is shared by every TUI plugin in this OpenCode window. const claim = claimFeature(api.renderer, "status", source) if (!claim.active) { @@ -52,8 +52,9 @@ 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 }) - void api.client.app + void api.v1?.client.app .log({ service: "opencode-cockpit.status", level: "error", @@ -148,16 +149,25 @@ export function createStatusTui({ source = STATUS_PACKAGE }: { source?: string } * like a command that did nothing. */ setTimeout(() => { - void api.client.tui - .appendPrompt({ text: brief }) - .then(() => api.client.tui.submitPrompt()) - .catch(() => - api.ui.toast({ - variant: "error", - title: "Statusline", - message: "could not reach the prompt", - }), - ) + 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) }, }, @@ -191,8 +201,5 @@ export function createStatusTui({ source = STATUS_PACKAGE }: { source?: string } } } -const plugin: TuiPluginModule & { id: string } = { - id: STATUS_PACKAGE, - tui: createStatusTui(), -} -export default plugin +/** One entry for both OpenCodes: v1 calls `tui`, v2 calls `setup` (docs/opencode/v2.md). */ +export default dualTui("opencode-cockpit.status", createStatusTui()) diff --git a/packages/status/src/tui/state/snapshot.ts b/packages/status/src/tui/state/snapshot.ts index 6a30527..d73ea43 100644 --- a/packages/status/src/tui/state/snapshot.ts +++ b/packages/status/src/tui/state/snapshot.ts @@ -1,5 +1,6 @@ import { homedir } from "node:os" import type { TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { Host, V2Context } from "@opencode-cockpit/client/host" import type { SessionSnapshot, StatusContext, TokenCounts } from "../../core/context.ts" import type { DiffCounts } from "../../core/diff.ts" @@ -17,7 +18,7 @@ function counted(tokens: TokenCounts): number { } /** The session the interface is showing, when it is showing one. */ -export function currentSession(api: TuiPluginApi): string | undefined { +export function currentSession(api: Host): string | undefined { const route = api.route.current return route.name === "session" ? (route.params as { sessionID?: string }).sessionID : undefined } @@ -107,8 +108,81 @@ function describeModel( } } +/** Numbers read defensively: v2's records are typed, but a field missing must read as absent, not NaN. */ +const num = (value: unknown): number | undefined => + typeof value === "number" && Number.isFinite(value) ? value : undefined + +/** + * The same snapshot from OpenCode 2's data. + * + * v2 keeps the running cost and the token total on the session record itself, and its assistant + * messages carry the same token shape v1's did — so the newest message that has reported is still + * what occupies the window. Todos are not a v2 concept, so there are none to count. + */ +export function sessionSnapshotV2( + ctx: V2Context, + id: string, + now: number, + diff: DiffCounts, +): SessionSnapshot { + void ctx.data.session.sync?.(id).catch(() => {}) + const session = ctx.data.session.get?.(id) as + | { title?: string; cost?: number; time?: { created?: number } } + | undefined + const status = ctx.data.session.status?.(id) as + | { type?: string; attempt?: number; message?: string; next?: number } + | undefined + const messages = (ctx.data.session.message?.list(id) ?? []) as { + type?: string + tokens?: TokenCounts + model?: { id?: string; providerID?: string } + cost?: number + }[] + let tokens: TokenCounts | undefined + let model: { id?: string; providerID?: string } | undefined + let summed = 0 + for (const message of messages) { + if (message.type !== "assistant") continue + summed += num(message.cost) ?? 0 + if (message.tokens && counted(message.tokens) > 0) tokens = message.tokens + if (message.model) model = message.model + } + const models = (ctx.data.location.model?.list(ctx.location) ?? []) as { + id?: string + providerID?: string + limit?: { context?: number } + cost?: unknown[] + }[] + const info = models.find((each) => each.id === model?.id && each.providerID === model?.providerID) + const limit = num(info?.limit?.context) + return { + id, + title: session?.title, + status: status?.type === "busy" ? "busy" : status?.type === "retry" ? "retry" : "idle", + ...(status?.type === "retry" + ? { retry: { attempt: status.attempt ?? 0, message: status.message ?? "", next: status.next ?? 0 } } + : {}), + ...(model?.id && model.providerID + ? { + model: { + providerID: model.providerID, + modelID: model.id, + ...(limit && limit > 0 ? { contextLimit: limit } : {}), + }, + } + : {}), + ...(tokens ? { tokens } : {}), + cost: num(session?.cost) ?? summed, + priced: Array.isArray(info?.cost) && info.cost.length > 0, + messages: messages.length, + startedAt: num(session?.time?.created) ?? now, + diff, + todo: { total: 0, completed: 0 }, + } +} + export function buildContext( - api: TuiPluginApi, + api: Host, options: { now: number width: number @@ -127,9 +201,23 @@ export function buildContext( ...(api.state.vcs?.default_branch ? { defaultBranch: api.state.vcs.default_branch } : {}), version: options.version, ...(options.diff ? { diff: options.diff } : {}), - ...(sessionID ? { session: sessionSnapshot(api, sessionID, options.now, options.diff ?? NOTHING) } : {}), - lsp: api.state.lsp().map((item) => ({ name: item.id, status: String(item.status) })), - mcp: api.state.mcp().map((item) => ({ name: item.name, status: String(item.status) })), + ...(sessionID + ? { + session: api.v1 + ? sessionSnapshot(api.v1, sessionID, options.now, options.diff ?? NOTHING) + : sessionSnapshotV2(api.v2 as V2Context, sessionID, options.now, options.diff ?? NOTHING), + } + : {}), + /** v2 runs no language servers; its MCP servers carry a name and a status as v1's did. */ + lsp: api.v1 ? api.v1.state.lsp().map((item) => ({ name: item.id, status: String(item.status) })) : [], + mcp: api.v1 + ? api.v1.state.mcp().map((item) => ({ name: item.name, status: String(item.status) })) + : ( + (api.v2?.data.location.mcp?.server.list(api.v2.location) ?? []) as { + name?: string + status?: unknown + }[] + ).map((item) => ({ name: item.name ?? "", status: String(item.status ?? "") })), commands: options.commands, width: options.width, } diff --git a/packages/status/src/tui/state/store.ts b/packages/status/src/tui/state/store.ts index a6fd2cb..e2cef6b 100644 --- a/packages/status/src/tui/state/store.ts +++ b/packages/status/src/tui/state/store.ts @@ -1,4 +1,4 @@ -import type { TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { Host } from "@opencode-cockpit/client/host" import { type Accessor, createMemo, createRoot, createSignal } from "solid-js" import { type CommandRunner, createRunner, execShell } from "../../core/command.ts" import { resolveLines, type StatusConfig } from "../../core/config.ts" @@ -26,7 +26,7 @@ export interface StoreOptions { exec?: (command: string, stdin: string, timeoutMs: number) => Promise now?: () => number build: ( - api: TuiPluginApi, + api: Host, input: { now: number width: number @@ -37,11 +37,7 @@ export interface StoreOptions { ) => StatusContext } -export function createStatusStore( - api: TuiPluginApi, - config: StatusConfig, - options: StoreOptions, -): StatusStore { +export function createStatusStore(api: Host, config: StatusConfig, options: StoreOptions): StatusStore { return createRoot((dispose) => { const clock = options.now ?? (() => Date.now()) const [now, setNow] = createSignal(clock()) diff --git a/packages/status/test/custom.test.ts b/packages/status/test/custom.test.ts index e55e576..1fcf703 100644 --- a/packages/status/test/custom.test.ts +++ b/packages/status/test/custom.test.ts @@ -242,6 +242,24 @@ describe("a module that lives outside a project", () => { expect(segmentText(drawn as Segment)).toBe("used 2k") }) + /** + * OpenCode 2 words the failure differently: it names the package, not the subpath, and matching + * the subpath left every module outside a project unloaded there. + */ + test("OpenCode 2's wording of the failure is retried too", async () => { + const { dir, file } = moduleIn(` + import { compact } from "@opencode-cockpit/status/segment" + export default { segments: { one: () => compact(3000) } } + `) + const v2 = async () => { + throw new Error(`Cannot find package '@opencode-cockpit/status' imported from ${file}`) + } + const { segments, errors } = await loadCustomSegments([file], dir, v2) + expect(errors).toEqual([]) + const [drawn] = buildSegments(ctx(), [{ type: "one" }], { custom: segments }) + expect(segmentText(drawn as Segment)).toBe("3k") + }) + // Only the authoring import is worth retrying; a module's own failure is reported as itself. test("a module that fails for its own reasons still reports that reason", async () => { const { dir, file } = moduleIn(`throw new Error("module said no")\nexport default {}`) diff --git a/packages/status/test/snapshot.test.ts b/packages/status/test/snapshot.test.ts index 2ccd966..8ed2b20 100644 --- a/packages/status/test/snapshot.test.ts +++ b/packages/status/test/snapshot.test.ts @@ -23,6 +23,12 @@ interface FakeState { route?: string } +/** What `fromV1` hands a bay on OpenCode 1: the host's own fields, and the v1 API itself beside them. */ +const v1Host = (state: FakeState = {}) => { + const v1 = api(state) + return Object.assign(v1, { v1 }) as unknown as Parameters[0] +} + const api = (state: FakeState = {}): TuiPluginApi => ({ app: { version: "1.0.0" }, @@ -167,7 +173,7 @@ describe("the rest of the session", () => { describe("the whole context", () => { test("carries the branch, services and paths the segments read", () => { - const ctx = buildContext(api({ branch: "status-bay" }), { + const ctx = buildContext(v1Host({ branch: "status-bay" }), { now: 5000, width: 100, version: "0.2.2", @@ -183,7 +189,7 @@ describe("the whole context", () => { // Off a session route there is no session to report on. test("has no session when the interface is not showing one", () => { - const ctx = buildContext(api({ route: "home" }), { + const ctx = buildContext(v1Host({ route: "home" }), { now: 5000, width: 100, version: "0.2.2", diff --git a/packages/status/tui.js b/packages/status/tui.js new file mode 100644 index 0000000..43232b0 --- /dev/null +++ b/packages/status/tui.js @@ -0,0 +1,6 @@ +/** + * OpenCode 2 finds a plugin configured by *path* by the files at its root — `/tui`, + * `/server` — rather than through `exports` (docs/opencode/v2.md). A package installed by + * name resolves through `exports` as before; this file is only the door for the path case. + */ +export { default } from "./dist/tui/index.js" diff --git a/packages/updater/README.md b/packages/updater/README.md index 4e7af6e..c7c2bae 100644 --- a/packages/updater/README.md +++ b/packages/updater/README.md @@ -17,6 +17,8 @@ A plugin cannot fix that for itself. Its fix only reaches people who already upd ## From a shell — works on any version, including a stuck one +**OpenCode 1 only.** On OpenCode 2, change the version in your `opencode.json` entry and restart. + ```sh npx opencode-cockpit@latest update # or @@ -72,3 +74,23 @@ never "current". | key | | | --- | --- | | `updateCheck` | `false` stops the daily check. Default `true`. | + +## Troubleshooting + +```sh +npx opencode-cockpit@latest doctor +``` + +checks OpenCode, its config, Cockpit's logs and the daemon, and prints the fix for anything wrong — +on OpenCode 1 and 2, and when Cockpit will not load at all ([what it checks](https://codestz.github.io/opencode-cockpit/help/doctor/)). + +Everything Cockpit does inside OpenCode goes to one file — which OpenCode loaded which bay, and every +error with its stack: + +```sh +tail -50 ~/.cache/opencode-cockpit/cockpit.log +``` + +`COCKPIT_DEBUG=1 opencode` adds the detail. [Troubleshooting](https://codestz.github.io/opencode-cockpit/help/troubleshooting/) covers +the failures people hit and what to attach to an issue; [OpenCode 1 and 2](https://codestz.github.io/opencode-cockpit/start/opencode-versions/) +covers what differs between the two. diff --git a/packages/updater/package.json b/packages/updater/package.json index 8506de0..b00f2fc 100644 --- a/packages/updater/package.json +++ b/packages/updater/package.json @@ -39,6 +39,7 @@ "node": ">=18" }, "files": [ + "tui.js", "dist", "types", "README.md", diff --git a/packages/updater/src/cli/main.ts b/packages/updater/src/cli/main.ts index 1220f55..2106bfa 100644 --- a/packages/updater/src/cli/main.ts +++ b/packages/updater/src/cli/main.ts @@ -8,25 +8,65 @@ */ import { spawnSync } from "node:child_process" -import { rmSync } from "node:fs" +import { accessSync, constants, existsSync, mkdirSync, rmSync } from "node:fs" import { homedir } from "node:os" import { createInterface } from "node:readline/promises" import { nodeDisk } from "../core/disk.ts" import { fetchAllLatest, registryFrom } from "../core/registry.ts" import { runOpencode } from "../core/spawn.ts" +import { doctor } from "../doctor/run.ts" import { update } from "./run.ts" +function worktree(cwd: string): string | undefined { + const git = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" }) + return git.status === 0 ? git.stdout.trim() || undefined : undefined +} + export function main(argv: readonly string[]): Promise { const tty = Boolean(process.stdout.isTTY) + if (argv[0] === "doctor") { + const cwd = process.cwd() + const tree = worktree(cwd) + return doctor(argv, { + env: process.env, + home: homedir(), + cwd, + ...(tree ? { worktree: tree } : {}), + disk: nodeDisk, + exists: (path) => existsSync(path), + run(command, args) { + const result = spawnSync(command, args, { encoding: "utf8", timeout: 10_000 }) + return result.error ? undefined : { status: result.status ?? 1, stdout: result.stdout ?? "" } + }, + alive(pid) { + try { + process.kill(pid, 0) + return true + } catch { + return false + } + }, + writable(dir) { + try { + mkdirSync(dir, { recursive: true }) + accessSync(dir, constants.W_OK) + return true + } catch { + return false + } + }, + fetchLatest: (names) => fetchAllLatest(names, { registry: registryFrom(process.env) }), + now: Date.now(), + write: (text) => process.stdout.write(text), + color: tty && !process.env.NO_COLOR, + }) + } return update(argv, { env: process.env, home: homedir(), cwd: process.cwd(), disk: nodeDisk, - worktree(cwd) { - const git = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" }) - return git.status === 0 ? git.stdout.trim() || undefined : undefined - }, + worktree, fetchLatest: (names) => fetchAllLatest(names, { registry: registryFrom(process.env) }), opencode: (args, cwd) => runOpencode("opencode", args, cwd), remove: (dir) => rmSync(dir, { recursive: true, force: true }), diff --git a/packages/updater/src/doctor/checks.ts b/packages/updater/src/doctor/checks.ts new file mode 100644 index 0000000..8bff4a7 --- /dev/null +++ b/packages/updater/src/doctor/checks.ts @@ -0,0 +1,384 @@ +/** + * What `opencode-cockpit doctor` concludes, from facts gathered elsewhere (`gather.ts`). Pure: every + * check takes what was found and says what it means — so each one is tested against a machine made + * of an object, and a check never touches the disk it is judging. + * + * Every problem carries the command that fixes it, for the OpenCode that is actually installed: the + * point is not a report, it is the next step (docs/roadmap/doctor.md). + */ + +import { isNewer, type Spec } from "../core/spec.ts" + +export type State = "ok" | "info" | "warn" | "fail" + +export interface Check { + title: string + state: State + summary: string + /** Supporting lines, shown under the summary. */ + detail?: string[] + /** What to run or change, exactly. */ + fix?: string[] +} + +/** The packages Cockpit ships, and which halves each has. */ +export const BUNDLE = "opencode-cockpit" +export const BAYS = ["shell", "review", "status", "updater"] 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"] + +/** Where a config file sits in OpenCode's split: agent plugins or interface plugins. */ +export type Half = "server" | "tui" + +export interface Entry { + /** The config file it was found in. */ + file: string + half: Half + /** As written. */ + raw: string + spec: Spec + /** `opencode-cockpit`, `@opencode-cockpit/shell`…; a local path is named by its own package.json. */ + name: string | undefined +} + +export interface Facts { + opencode: { version: string | undefined; major: number | undefined } + entries: Entry[] + configErrors: { path: string; message: string }[] + /** Newest published version per package name, when the registry answered. */ + latest: Map + /** Local entries whose directory has its own OpenTUI: a checkout rather than an install. */ + checkouts: Set + log: LogFacts + daemon: DaemonFacts + env: { git: boolean; ps: boolean; homeWritable: boolean; home: string } + settings: SettingsFacts +} + +export interface LogFacts { + file: string + exists: boolean + /** Newest start line per half and entry. */ + starts: { + scope: string + entry: string + opencode?: number + opencodeVersion?: string + cockpit?: string + t: string + }[] + /** Errors and warnings in the last day, newest last. */ + recent: { t: string; lvl: string; scope: string; msg: string; message?: string }[] +} + +export interface DaemonFacts { + log: string + running: boolean + pid?: number + build?: string + errors: { t: string; msg: string }[] +} + +export interface SettingsFacts { + files: { path: string; error?: string }[] + modules: { path: string; exists: boolean }[] +} + +/** The package an entry is, when it is one of ours. */ +export function bayOf(name: string | undefined): Bay | "bundle" | undefined { + if (name === BUNDLE) return "bundle" + const match = /^@opencode-cockpit\/([a-z]+)$/.exec(name ?? "") + return match && (BAYS as readonly string[]).includes(match[1] as string) ? (match[1] as Bay) : undefined +} + +const ours = (facts: Facts) => facts.entries.filter((entry) => bayOf(entry.name)) + +/** + * Whether the OpenCode installed reads this entry's file. OpenCode 1 reads `opencode.json` and + * `tui.json`; OpenCode 2 reads `opencode.json` — for both halves — and `cli.json`. A `cli.json` entry + * does nothing on OpenCode 1, and a `tui.json` one is only copied into `cli.json` once, on OpenCode 2's + * first start. + */ +export function readBy(entry: Entry, v2: boolean): boolean { + const name = entry.file.split("/").pop() ?? "" + if (name.startsWith("opencode.")) return true + return v2 ? name.startsWith("cli.") : name.startsWith("tui.") +} + +// --------------------------------------------------------------------------------------------------- + +export function checkOpencode(facts: Facts): Check { + const { version, major } = facts.opencode + if (!version || major === undefined) { + return { + title: "OpenCode", + state: "fail", + summary: "`opencode` is not on PATH, or would not say its version", + fix: ["Install OpenCode: https://opencode.ai"], + } + } + if (major >= 2) return { title: "OpenCode", state: "ok", summary: `${version} (OpenCode 2)` } + const [minor] = version.split(".").slice(1).map(Number) + if (major === 1 && (minor ?? 0) >= 18) + return { title: "OpenCode", state: "ok", summary: `${version} (OpenCode 1)` } + return { + title: "OpenCode", + state: "fail", + summary: `${version} is older than Cockpit supports`, + detail: ["Cockpit 0.6 needs OpenCode 1.18 or newer; 0.5.2 was the last for older releases."], + fix: ["opencode upgrade"], + } +} + +/** The line that pins `name` at `version`, spelled for the OpenCode that is installed. */ +function installLine(major: number | undefined, name: string, version: string): string { + return major !== undefined && major >= 2 + ? `change the entry to "${name}@${version}" in opencode.json, then restart OpenCode` + : `npx opencode-cockpit@latest update # or: opencode plugin ${name}@${version} --global --force` +} + +export function checkConfig(facts: Facts): Check { + const major = facts.opencode.major + const v2 = major !== undefined && major >= 2 + const found = ours(facts) + const detail: string[] = [] + const fix: string[] = [] + let state = "ok" as State + const raise = (to: State) => { + const order: State[] = ["ok", "info", "warn", "fail"] + if (order.indexOf(to) > order.indexOf(state)) state = to + } + + for (const error of facts.configErrors) { + raise("fail") + detail.push(`${error.path}: ${error.message}`) + } + + if (found.length === 0) { + const latest = facts.latest.get(BUNDLE) ?? "" + return { + title: "Config", + state: "fail", + summary: "Cockpit is not in any OpenCode config", + detail, + fix: [ + v2 + ? `opencode plugin add ${BUNDLE}@${latest}` + : `opencode plugin ${BUNDLE}@${latest} --global --force`, + ], + } + } + + for (const entry of found) { + const ignored = readBy(entry, v2) ? "" : ` — not read by OpenCode ${v2 ? 2 : 1}` + detail.push(`${entry.raw} (${entry.file})${ignored}`) + } + const read = found.filter((entry) => readBy(entry, v2)) + + /** + * The same bay twice: the bundle and a standalone package. On OpenCode 1 per half, since each half + * has its own file. On OpenCode 2 across every file it reads: `opencode.json` loads the interface + * too, so the bundle there and a bay in `cli.json` is the same bay loaded twice. + */ + const groups = v2 ? [read] : [read.filter((e) => e.half === "server"), read.filter((e) => e.half === "tui")] + for (const here of groups) { + const bundle = here.find((entry) => bayOf(entry.name) === "bundle") + for (const entry of here) { + const bay = bayOf(entry.name) + if (bundle && bay && bay !== "bundle") { + raise("warn") + fix.push(`${entry.raw} (${entry.file}) is also inside ${BUNDLE} (${bundle.file}): remove one of them`) + } + } + } + + // OpenCode 1 loads each half from its own file; OpenCode 2 loads both from opencode.json. + if (!v2) { + const halves = (bay: string) => ({ + server: read.some((entry) => entry.half === "server" && bayOf(entry.name) === bay), + tui: read.some((entry) => entry.half === "tui" && bayOf(entry.name) === bay), + }) + for (const bay of ["bundle", ...SERVER_BAYS]) { + const { server, tui } = halves(bay) + const name = bay === "bundle" ? BUNDLE : `@opencode-cockpit/${bay}` + if (server && !tui) { + raise("warn") + fix.push( + `${name} is in opencode.json but not tui.json: its panels will not show — add it to tui.json too`, + ) + } + if (tui && !server) { + raise("warn") + fix.push( + `${name} is in tui.json but not opencode.json: the agent has none of its tools — add it to opencode.json too`, + ) + } + } + } + + for (const entry of found) { + if (entry.spec.kind === "local") { + if (v2 && facts.checkouts.has(entry.raw)) { + raise("fail") + fix.push( + `${entry.raw} is a git checkout: OpenCode 2 cannot load it beside its own OpenTUI. Point the entry at an install — from the checkout, \`bun run dev:install\` makes one in ~/.cockpit-dev`, + ) + } else { + raise("info") + } + continue + } + const name = entry.spec.name + const latest = facts.latest.get(name) + const pin = entry.spec.pin + if (pin.type !== "exact") { + raise("warn") + fix.push( + `${entry.raw} is not pinned: OpenCode keeps whatever it installed first. ${installLine(major, name, latest ?? "")}`, + ) + continue + } + if (v2 && isNewer("0.6.0", pin.version)) { + raise("fail") + fix.push( + `${entry.raw} is OpenCode 1 only (0.6 is the first for both). ${installLine(major, name, latest ?? "0.6.0")}`, + ) + } else if (latest && isNewer(latest, pin.version)) { + raise("warn") + fix.push(`${entry.raw}: ${latest} is out. ${installLine(major, name, latest)}`) + } + } + + const summary = + state === "fail" + ? "Cockpit is configured, but will not load as written" + : state === "warn" + ? "Cockpit is configured, with something to change" + : `${found.length} Cockpit entr${found.length === 1 ? "y" : "ies"}` + return { title: "Config", state, summary, detail, ...(fix.length ? { fix } : {}) } +} + +/** What the log says actually ran — the one answer to "config says one thing, OpenCode runs another". */ +export function checkRunning(facts: Facts): Check { + const { log } = facts + if (!log.exists || log.starts.length === 0) { + return { + title: "Last run", + state: "info", + summary: "nothing logged yet — start OpenCode once, then run doctor again", + detail: [`Cockpit logs to ${log.file} from 0.6 on.`], + } + } + const newest = log.starts.reduce((a, b) => (a.t > b.t ? a : b)) + const detail = log.starts + .slice() + .sort((a, b) => a.scope.localeCompare(b.scope) || a.entry.localeCompare(b.entry)) + .map( + (start) => + `${start.scope.padEnd(7)} ${start.entry} ${start.cockpit ?? "?"} on OpenCode ${start.opencodeVersion ?? start.opencode ?? "?"} (${start.t})`, + ) + const versions = new Set(log.starts.map((start) => start.cockpit).filter(Boolean)) + const mixed = versions.size > 1 + return { + title: "Last run", + state: mixed ? "warn" : "ok", + summary: mixed + ? `different Cockpit versions loaded: ${[...versions].join(", ")}` + : `Cockpit ${newest.cockpit ?? "?"} on OpenCode ${newest.opencodeVersion ?? newest.opencode ?? "?"}, ${newest.t}`, + detail, + ...(mixed ? { fix: ["pin every Cockpit entry at the same version, then restart OpenCode"] } : {}), + } +} + +export function checkErrors(facts: Facts): Check { + const errors = facts.log.recent.filter((line) => line.lvl === "error") + const warnings = facts.log.recent.filter((line) => line.lvl === "warn") + const daemon = facts.daemon.errors + const total = errors.length + daemon.length + const detail = [ + ...[...errors, ...warnings] + .sort((a, b) => a.t.localeCompare(b.t)) + .slice(-5) + .map( + (line) => + `${line.t} ${line.lvl.padEnd(5)} ${line.scope} ${line.msg}${line.message ? `: ${line.message}` : ""}`, + ), + ...daemon.slice(-3).map((line) => `${line.t} error cockpitd ${line.msg}`), + ] + if (total === 0 && warnings.length === 0) { + return { title: "Errors", state: "ok", summary: "none in the last day" } + } + return { + title: "Errors", + state: total > 0 ? "warn" : "info", + summary: `${errors.length} error${errors.length === 1 ? "" : "s"}, ${warnings.length} warning${warnings.length === 1 ? "" : "s"}${daemon.length ? `, ${daemon.length} from the daemon` : ""} in the last day`, + detail, + fix: [`the full lines, with stacks: tail -100 ${facts.log.file}`], + } +} + +export function checkDaemon(facts: Facts): Check { + const { daemon } = facts + if (!daemon.running) { + return { + title: "Daemon", + state: "info", + summary: "not running — it starts with the first shell, and stops when idle", + ...(daemon.build ? { detail: [`last build: ${daemon.build}`] } : {}), + } + } + return { + title: "Daemon", + state: "ok", + summary: `running (pid ${daemon.pid})${daemon.build ? `, build ${daemon.build}` : ""}`, + } +} + +export function checkEnvironment(facts: Facts): Check { + const { env } = facts + const fix: string[] = [] + if (!env.git) fix.push("git is not on PATH: Review reads the branch through it") + if (!env.ps) fix.push("ps is not on PATH: the daemon needs it to stop a shell's processes") + if (!env.homeWritable) fix.push(`${env.home} is not writable: nothing can log, and the daemon cannot start`) + return { + title: "Environment", + state: fix.length ? "fail" : "ok", + summary: fix.length ? "missing what Cockpit needs" : "git, ps, and a writable Cockpit home", + ...(fix.length ? { fix } : {}), + } +} + +export function checkSettings(facts: Facts): Check { + const { settings } = facts + const fix: string[] = [] + for (const file of settings.files) + if (file.error) fix.push(`${file.path}: ${file.error} — the whole file is ignored`) + for (const module of settings.modules) { + if (!module.exists) fix.push(`statusline module ${module.path} does not exist`) + } + const read = settings.files.filter((file) => !file.error).length + return { + title: "Settings", + state: fix.length ? "warn" : "ok", + summary: fix.length + ? "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"}` : ""}`, + detail: settings.files.map((file) => file.path), + ...(fix.length ? { fix } : {}), + } +} + +export function allChecks(facts: Facts): Check[] { + return [ + checkOpencode(facts), + checkConfig(facts), + checkRunning(facts), + checkErrors(facts), + checkDaemon(facts), + checkEnvironment(facts), + checkSettings(facts), + ] +} diff --git a/packages/updater/src/doctor/gather.ts b/packages/updater/src/doctor/gather.ts new file mode 100644 index 0000000..25d4908 --- /dev/null +++ b/packages/updater/src/doctor/gather.ts @@ -0,0 +1,268 @@ +/** + * Everything doctor looks at, read from the machine through `DoctorIo` — the config files of both + * OpenCodes, the logs Cockpit writes, the daemon, the tools on PATH — into `Facts` for `checks.ts`. + * + * Node APIs only, like the updater it ships with: doctor runs under `npx` because the most useful + * time to run it is when Cockpit will not load inside OpenCode at all. + */ + +import { join, resolve } from "node:path" +import { globalConfigDir } from "../core/configs.ts" +import type { Disk } from "../core/disk.ts" +import { parseJsonc } from "../core/jsonc.ts" +import { parseSpec } from "../core/spec.ts" +import { + BUNDLE, + bayOf, + type DaemonFacts, + type Entry, + type Facts, + type Half, + type LogFacts, +} from "./checks.ts" + +export interface DoctorIo { + env: Readonly> + home: string + cwd: string + /** The project's worktree root, when `cwd` is in one. */ + worktree?: string + disk: Disk + exists(path: string): boolean + /** Runs a program; undefined when it could not start. */ + run(command: string, args: readonly string[]): { status: number; stdout: string } | undefined + alive(pid: number): boolean + writable(dir: string): boolean + fetchLatest(names: readonly string[]): Promise> + now: number +} + +/** The files each OpenCode reads plugins from, and which half each one loads. */ +const CONFIG_FILES: { name: string; half: Half }[] = [ + { name: "opencode.json", half: "server" }, + { name: "opencode.jsonc", half: "server" }, + { name: "tui.json", half: "tui" }, + { name: "tui.jsonc", half: "tui" }, + { name: "cli.json", half: "tui" }, + { name: "cli.jsonc", half: "tui" }, +] + +/** `COCKPIT_HOME`, else the cache directory — as `@opencode-cockpit/protocol`'s `resolvePaths`. */ +export function cockpitHome(io: Pick): string { + return io.env.COCKPIT_HOME ?? join(io.env.XDG_CACHE_HOME ?? join(io.home, ".cache"), "opencode-cockpit") +} + +/** + * A plugin entry in either OpenCode's spelling: v1's `"spec"` and `["spec", options]` under + * `plugin`, v2's `"spec"` and `{ package, options }` under `plugins`. + */ +function specsIn(value: unknown): string[] { + const out: string[] = [] + const config = (value ?? {}) as { plugin?: unknown; plugins?: unknown } + for (const list of [config.plugin, config.plugins]) { + if (!Array.isArray(list)) continue + for (const item of list) { + if (typeof item === "string") out.push(item) + else if (Array.isArray(item) && typeof item[0] === "string") out.push(item[0]) + else if ( + item && + typeof item === "object" && + typeof (item as { package?: unknown }).package === "string" + ) { + out.push((item as { package: string }).package) + } + } + } + return out +} + +/** A local entry is named by its own package.json — that is what OpenCode loads. */ +function localPath(raw: string, io: Pick, base: string): string { + const path = raw.replace(/^file:\/\//, "").replace(/^file:/, "") + if (path.startsWith("~/")) return join(io.home, path.slice(2)) + return resolve(base, path) +} + +function readEntries(io: DoctorIo): { + entries: Entry[] + errors: Facts["configErrors"] + checkouts: Set +} { + const entries: Entry[] = [] + const errors: Facts["configErrors"] = [] + const checkouts = new Set() + const dirs = [globalConfigDir(io)] + if (io.worktree) dirs.push(join(io.worktree, ".opencode"), io.worktree) + + for (const dir of dirs) { + for (const { name, half } of CONFIG_FILES) { + const file = join(dir, name) + const text = io.disk.read(file) + if (text === undefined) continue + const parsed = parseJsonc(text) + if (!parsed.ok) { + errors.push({ path: file, message: parsed.message }) + continue + } + for (const raw of specsIn(parsed.value)) { + const spec = parseSpec(raw) + let pkgName: string | undefined + if (spec.kind === "npm") pkgName = spec.name + else { + const path = localPath(spec.raw, io, dir) + const manifest = io.disk.read(join(path, "package.json")) + try { + pkgName = manifest ? (JSON.parse(manifest) as { name?: string }).name : undefined + } catch { + pkgName = undefined + } + /** + * A checkout has the repository's own OpenTUI beside it, which OpenCode 2 refuses to load + * next to its own. An install never does: those are dev dependencies. + */ + if (bayOf(pkgName) && !path.includes("/node_modules/")) { + for (const up of [path, join(path, ".."), join(path, "..", "..")]) { + if (io.exists(join(up, "node_modules", "@opentui", "core"))) checkouts.add(raw) + } + } + } + entries.push({ file, half, raw, spec, name: pkgName }) + } + } + } + return { entries, errors, checkouts } +} + +function parseLines(text: string | undefined): Record[] { + if (!text) return [] + // The end of the file is what matters; a large log is not parsed whole. + return text + .slice(-2_000_000) + .split("\n") + .flatMap((line) => { + if (!line.startsWith("{")) return [] + try { + return [JSON.parse(line) as Record] + } catch { + return [] + } + }) +} + +const DAY = 24 * 60 * 60 * 1000 + +function readLog(io: DoctorIo, home: string): LogFacts { + const file = io.env.COCKPIT_LOG_FILE ?? join(home, "cockpit.log") + const text = io.disk.read(file) + const lines = parseLines(text) + const starts = new Map() + const recent: LogFacts["recent"] = [] + for (const line of lines) { + const t = String(line.t ?? "") + const scope = String(line.scope ?? "") + if (line.msg === "start" && typeof line.entry === "string") { + starts.set(`${scope} ${line.entry}`, { + scope, + entry: line.entry, + t, + ...(typeof line.opencode === "number" ? { opencode: line.opencode } : {}), + ...(typeof line.opencodeVersion === "string" ? { opencodeVersion: line.opencodeVersion } : {}), + ...(typeof line.cockpit === "string" ? { cockpit: line.cockpit } : {}), + }) + } + if ((line.lvl === "error" || line.lvl === "warn") && io.now - Date.parse(t) < DAY) { + const error = line.error as { message?: unknown } | undefined + const message = + typeof error?.message === "string" + ? error.message + : typeof line.message === "string" + ? line.message + : undefined + recent.push({ + t, + lvl: String(line.lvl), + scope, + msg: String(line.msg ?? ""), + ...(message ? { message } : {}), + }) + } + } + return { file, exists: text !== undefined, starts: [...starts.values()], recent } +} + +function readDaemon(io: DoctorIo, home: string): DaemonFacts { + const log = join(home, "cockpitd.log") + const lines = parseLines(io.disk.read(log)) + const started = lines.filter((line) => line.msg === "daemon started").at(-1) + const errors = lines + .filter((line) => line.lvl === "error" && io.now - Date.parse(String(line.t)) < DAY) + .map((line) => ({ t: String(line.t), msg: String(line.msg) })) + const pid = Number.parseInt(io.disk.read(join(home, "cockpitd.pid"))?.trim() ?? "", 10) + const running = Number.isFinite(pid) && io.alive(pid) + return { + log, + running, + ...(running ? { pid } : {}), + ...(typeof started?.build === "string" ? { build: started.build } : {}), + errors, + } +} + +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 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 + if (!Array.isArray(list)) continue + for (const module of list) { + if (typeof module !== "string") continue + // As Status resolves them: `~/` is home, absolute is itself, anything else is the project's. + const full = module.startsWith("~/") ? join(io.home, module.slice(2)) : resolve(project, module) + modules.push({ path: module, exists: io.exists(full) }) + } + } + return { files, modules } +} + +export async function gatherFacts(io: DoctorIo): Promise { + const home = cockpitHome(io) + const versionOut = io.run("opencode", ["--version"]) + const version = versionOut?.status === 0 ? /(\d+\.\d+\.\d+)/.exec(versionOut.stdout)?.[1] : undefined + const { entries, errors, checkouts } = readEntries(io) + const names = [ + ...new Set([ + BUNDLE, + ...entries.flatMap((entry) => + entry.spec.kind === "npm" && bayOf(entry.name) ? [entry.spec.name] : [], + ), + ]), + ] + const latest = await io.fetchLatest(names).catch(() => new Map()) + return { + opencode: { version, major: version ? Number(version.split(".")[0]) : undefined }, + entries, + configErrors: errors, + latest, + checkouts, + log: readLog(io, home), + daemon: readDaemon(io, home), + env: { + git: io.run("git", ["--version"])?.status === 0, + ps: io.run("ps", ["-o", "pid="])?.status === 0, + homeWritable: io.writable(home), + home, + }, + settings: readSettings(io), + } +} diff --git a/packages/updater/src/doctor/run.ts b/packages/updater/src/doctor/run.ts new file mode 100644 index 0000000..4790c84 --- /dev/null +++ b/packages/updater/src/doctor/run.ts @@ -0,0 +1,82 @@ +/** + * `opencode-cockpit doctor`: checks a setup and says how to fix what is wrong. Facts in (`gather.ts`), + * conclusions (`checks.ts`), then text for a person or `--json` for an issue. + * + * Exits 1 when a check fails, so a script can ask "is Cockpit set up" and get an answer. + */ + +import { allChecks, type Check, type State } from "./checks.ts" +import { type DoctorIo, gatherFacts } from "./gather.ts" + +export interface RunIo extends DoctorIo { + write(text: string): void + color: boolean +} + +const HELP = `Usage: npx opencode-cockpit@latest doctor [--json] + + Checks OpenCode, its config, the logs Cockpit writes, the daemon and the tools it needs, and says + what to change for anything wrong. Works on OpenCode 1 and 2, and when Cockpit will not load at all. + + --json everything doctor found, for attaching to an issue +` + +const MARK: Record = { + ok: { text: "✓", code: "32" }, + info: { text: "·", code: "90" }, + warn: { text: "!", code: "33" }, + fail: { text: "✗", code: "31" }, +} + +function render(checks: readonly Check[], color: boolean): string { + const paint = (code: string, text: string) => (color ? `\x1b[${code}m${text}\x1b[0m` : text) + const width = Math.max(...checks.map((check) => check.title.length)) + 2 + const pad = " ".repeat(width + 3) + const out: string[] = ["", paint("1", "Cockpit doctor"), ""] + for (const check of checks) { + const mark = MARK[check.state] + out.push(` ${paint(mark.code, mark.text)} ${check.title.padEnd(width)}${check.summary}`) + for (const line of check.detail ?? []) out.push(paint("90", `${pad}${line}`)) + for (const line of check.fix ?? []) out.push(`${pad}${paint("36", "→")} ${line}`) + } + const failed = checks.filter((check) => check.state === "fail").length + const warned = checks.filter((check) => check.state === "warn").length + out.push( + "", + failed + warned === 0 + ? paint("32", "Nothing to fix.") + : [failed && paint("31", `${failed} to fix`), warned && paint("33", `${warned} to look at`)] + .filter(Boolean) + .join(", "), + paint("90", "Stuck? https://codestz.github.io/opencode-cockpit/help/troubleshooting/"), + "", + ) + return out.join("\n") +} + +export async function doctor(argv: readonly string[], io: RunIo): Promise { + const args = argv[0] === "doctor" ? argv.slice(1) : argv + if (args.includes("--help") || args.includes("-h")) { + io.write(HELP) + return 0 + } + const unknown = args.find((arg) => arg !== "--json") + if (unknown) { + io.write(`unknown argument: ${unknown}\n\n${HELP}`) + return 2 + } + const facts = await gatherFacts(io) + const checks = allChecks(facts) + if (args.includes("--json")) { + const plain = { + ...facts, + latest: Object.fromEntries(facts.latest), + checkouts: [...facts.checkouts], + checks, + } + io.write(`${JSON.stringify(plain, null, 2)}\n`) + } else { + io.write(render(checks, io.color)) + } + return checks.some((check) => check.state === "fail") ? 1 : 0 +} diff --git a/packages/updater/src/tui/dialog.tsx b/packages/updater/src/tui/dialog.tsx index 5af07ad..0c2082b 100644 --- a/packages/updater/src/tui/dialog.tsx +++ b/packages/updater/src/tui/dialog.tsx @@ -9,7 +9,7 @@ */ import type { TuiPluginApi } from "@opencode-ai/plugin/tui" -import { useBindings } from "@opentui/keymap/solid" +import { useApiLayer } from "@opencode-cockpit/client/host" import { useTerminalDimensions } from "@opentui/solid" import { createMemo, createSignal, onCleanup } from "solid-js" import type { Readiness } from "../core/apply.ts" @@ -163,10 +163,23 @@ export function UpdaterDialog(props: UpdaterDialogProps) { ) onCleanup(release) - useBindings(() => ({ + /** Not `useBindings`: `@opentui/keymap` cannot be imported under OpenCode 2 (docs/opencode/v2.md). */ + useApiLayer(props.api, () => ({ commands: [ - { name: "cockpit.updater.down", title: "Next plugin", run: () => phase() === "list" && move(1) }, - { name: "cockpit.updater.up", title: "Previous plugin", run: () => phase() === "list" && move(-1) }, + { + name: "cockpit.updater.down", + title: "Next plugin", + run: () => { + if (phase() === "list") move(1) + }, + }, + { + name: "cockpit.updater.up", + title: "Previous plugin", + run: () => { + if (phase() === "list") move(-1) + }, + }, { name: "cockpit.updater.toggle", title: "Select plugin", run: () => phase() === "list" && toggle() }, { name: "cockpit.updater.all", diff --git a/packages/updater/src/tui/index.tsx b/packages/updater/src/tui/index.tsx index b950c08..93b2d60 100644 --- a/packages/updater/src/tui/index.tsx +++ b/packages/updater/src/tui/index.tsx @@ -3,8 +3,9 @@ import { rmSync } from "node:fs" import { homedir } from "node:os" import { basename } from "node:path" -import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" +import type { TuiPluginApi } from "@opencode-ai/plugin/tui" import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client/feature" +import { dualTui, type Host } from "@opencode-cockpit/client/host" import { type ApplyIo, applyPlan, manualSteps, readiness } from "../core/apply.ts" import { nodeDisk } from "../core/disk.ts" import { type GatherIo, gather } from "../core/gather.ts" @@ -94,11 +95,11 @@ async function announce(api: TuiPluginApi): Promise { } /** The Updater's TUI as a factory, so bundles such as `opencode-cockpit` can include it. */ -export function createUpdaterTui({ source = UPDATER_PACKAGE }: { source?: string } = {}): TuiPlugin { - return async (api, options, _meta) => { - const claim = claimFeature(api.renderer, "updater", source) +export function createUpdaterTui({ source = UPDATER_PACKAGE }: { source?: string } = {}) { + return async (host: Host, options?: unknown) => { + const claim = claimFeature(host.renderer, "updater", source) if (!claim.active) { - api.ui.toast({ + host.ui.toast({ variant: "warning", title: "opencode-cockpit", message: duplicateFeatureMessage("Updater", claim.owner, source), @@ -106,9 +107,26 @@ export function createUpdaterTui({ source = UPDATER_PACKAGE }: { source?: string }) return } - api.lifecycle.onDispose(() => claim.release()) + host.lifecycle.onDispose(() => claim.release()) - api.keymap.registerLayer({ + /** + * The updater edits OpenCode 1's files through OpenCode 1's `opencode plugin --force`; + * OpenCode 2 has neither. Its `opencode plugin update` needs its background service and was not + * measured on a pinned entry, so the commands say what is known to work: change the version in + * the entry. They stay, so the habit still lands somewhere. + */ + const api = host.v1 + const open = api + ? () => openUpdater(api) + : () => + host.ui.toast({ + title: "Plugins", + message: + "On OpenCode 2, change the version in your opencode.json plugin entry, then restart OpenCode.", + duration: 10_000, + }) + + host.keymap.registerLayer({ commands: [ { name: "cockpit.updater.open", @@ -116,7 +134,7 @@ export function createUpdaterTui({ source = UPDATER_PACKAGE }: { source?: string category: "Plugins", namespace: "palette", slashName: "plugins-update", - run: () => openUpdater(api), + run: open, }, { // Old toasts, old docs and habit all say /cockpit-update; it now opens the same screen. @@ -125,11 +143,12 @@ export function createUpdaterTui({ source = UPDATER_PACKAGE }: { source?: string category: "Plugins", namespace: "palette", slashName: "cockpit-update", - run: () => openUpdater(api), + run: open, }, ], bindings: [], }) + if (!api) return // Never in the way of starting up, and never loud about failing: offline is not news. const where = { env: process.env, home: homedir(), directory: api.state.path.directory } @@ -139,5 +158,5 @@ export function createUpdaterTui({ source = UPDATER_PACKAGE }: { source?: string } } -const plugin: TuiPluginModule & { id: string } = { id: UPDATER_PACKAGE, tui: createUpdaterTui() } -export default plugin +/** One entry for both OpenCodes: v1 calls `tui`, v2 calls `setup` (docs/opencode/v2.md). */ +export default dualTui("opencode-cockpit.updater", createUpdaterTui()) diff --git a/packages/updater/test/doctor.test.ts b/packages/updater/test/doctor.test.ts new file mode 100644 index 0000000..8f994b0 --- /dev/null +++ b/packages/updater/test/doctor.test.ts @@ -0,0 +1,354 @@ +import { describe, expect, test } from "bun:test" +import { memoryDisk } from "../src/core/disk.ts" +import type { Check } from "../src/doctor/checks.ts" +import type { DoctorIo } from "../src/doctor/gather.ts" +import { doctor } from "../src/doctor/run.ts" + +/** + * Doctor against machines made of objects — each one a setup someone actually had, or one this + * week's work ran into — and what it says about them. What matters is the verdict and the fix line: + * a doctor that says "something is wrong" without the next step is the silence it exists to end. + */ + +const HOME = "/home/me" +const CONFIG = `${HOME}/.config/opencode` + +interface Machine { + opencode?: string + files?: Record + latest?: Record + /** Paths that exist beyond the files: package directories, OpenTUI in a checkout. */ + exists?: string[] + alive?: number[] + git?: boolean +} + +async function run(machine: Machine, args: string[] = []) { + let out = "" + const files = machine.files ?? {} + const io: DoctorIo & { write(text: string): void; color: boolean } = { + env: {}, + home: HOME, + cwd: "/work/project", + worktree: "/work/project", + disk: memoryDisk(files), + exists: (path) => path in files || (machine.exists ?? []).includes(path), + run(command) { + if (command === "opencode") + return machine.opencode ? { status: 0, stdout: `${machine.opencode}\n` } : undefined + if (command === "git") return machine.git === false ? undefined : { status: 0, stdout: "git version 2" } + return { status: 0, stdout: "" } + }, + alive: (pid) => (machine.alive ?? []).includes(pid), + writable: () => true, + fetchLatest: async (names) => new Map(names.map((name) => [name, machine.latest?.[name]])), + now: Date.parse("2026-09-24T12:00:00Z"), + write: (text) => { + out += text + }, + color: false, + } + const code = await doctor(["doctor", ...args], io) + return { code, out } +} + +async function checks(machine: Machine): Promise> { + const { out } = await run(machine, ["--json"]) + const parsed = JSON.parse(out) as { checks: Check[] } + return Object.fromEntries(parsed.checks.map((check) => [check.title, check])) +} + +const json = (value: unknown) => JSON.stringify(value) + +describe("OpenCode itself", () => { + test("missing is the first thing to fix", async () => { + const found = await checks({}) + expect(found.OpenCode?.state).toBe("fail") + }) + + test("1.17 is older than Cockpit supports, and says what does", async () => { + const found = await checks({ opencode: "1.17.20" }) + expect(found.OpenCode?.state).toBe("fail") + expect(found.OpenCode?.detail?.join()).toContain("1.18") + }) + + test("1.18 and 2 are both fine", async () => { + expect((await checks({ opencode: "1.18.0" })).OpenCode?.state).toBe("ok") + expect((await checks({ opencode: "opencode v2.0.15" })).OpenCode?.state).toBe("ok") + }) +}) + +describe("the config", () => { + test("nothing configured: the install line for the OpenCode installed", async () => { + const v1 = await checks({ opencode: "1.18.32", latest: { "opencode-cockpit": "0.6.0" } }) + expect(v1.Config?.state).toBe("fail") + expect(v1.Config?.fix).toEqual(["opencode plugin opencode-cockpit@0.6.0 --global --force"]) + const v2 = await checks({ opencode: "2.0.15", latest: { "opencode-cockpit": "0.6.0" } }) + expect(v2.Config?.fix).toEqual(["opencode plugin add opencode-cockpit@0.6.0"]) + }) + + test("v2's spelling is read: an object entry under `plugins`", async () => { + const found = await checks({ + opencode: "2.0.15", + latest: { "opencode-cockpit": "0.6.0" }, + files: { + [`${CONFIG}/opencode.json`]: json({ plugins: [{ package: "opencode-cockpit@0.6.0", options: {} }] }), + }, + }) + expect(found.Config?.state).toBe("ok") + }) + + test("on OpenCode 2, a pin older than 0.6 cannot load, and the fix edits the entry", async () => { + const found = await checks({ + opencode: "2.0.15", + latest: { "opencode-cockpit": "0.6.0" }, + files: { [`${CONFIG}/opencode.json`]: json({ plugin: ["opencode-cockpit@0.5.2"] }) }, + }) + expect(found.Config?.state).toBe("fail") + expect(found.Config?.fix?.join()).toContain('change the entry to "opencode-cockpit@0.6.0"') + }) + + test("on OpenCode 1, a newer release is a warning with the updater's line", async () => { + const found = await checks({ + opencode: "1.18.32", + latest: { "opencode-cockpit": "0.6.0" }, + files: { + [`${CONFIG}/opencode.json`]: json({ plugin: ["opencode-cockpit@0.5.2"] }), + [`${CONFIG}/tui.json`]: json({ plugin: ["opencode-cockpit@0.5.2"] }), + }, + }) + expect(found.Config?.state).toBe("warn") + expect(found.Config?.fix?.join()).toContain("npx opencode-cockpit@latest update") + }) + + test("on OpenCode 1, one half missing: panels or tools that never appear", async () => { + const found = await checks({ + opencode: "1.18.32", + latest: { "opencode-cockpit": "0.6.0" }, + files: { [`${CONFIG}/opencode.json`]: json({ plugin: ["opencode-cockpit@0.6.0"] }) }, + }) + expect(found.Config?.state).toBe("warn") + expect(found.Config?.fix?.join()).toContain("not tui.json") + }) + + test("the bundle and a bay of it, both configured: one stands down", async () => { + const found = await checks({ + opencode: "2.0.15", + latest: { "opencode-cockpit": "0.6.0", "@opencode-cockpit/shell": "0.6.0" }, + files: { + [`${CONFIG}/opencode.json`]: json({ + plugins: ["opencode-cockpit@0.6.0", "@opencode-cockpit/shell@0.6.0"], + }), + }, + }) + expect(found.Config?.state).toBe("warn") + expect(found.Config?.fix?.join()).toContain("also inside opencode-cockpit") + }) + + /** 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({ + opencode: "1.18.32", + latest: { "opencode-cockpit": "0.6.0" }, + files: { + [`${CONFIG}/opencode.json`]: json({ plugin: ["opencode-cockpit@0.6.0"] }), + [`${CONFIG}/cli.json`]: json({ plugins: ["opencode-cockpit@0.6.0"] }), + }, + }) + expect(found.Config?.state).toBe("warn") + expect(found.Config?.fix?.join()).toContain("not tui.json") + expect(found.Config?.detail?.join()).toContain("not read by OpenCode 1") + }) + + /** v2 loads the interface from opencode.json too, so the bundle there and a bay in cli.json clash. */ + test("on OpenCode 2, the bundle in opencode.json and a bay in cli.json is the bay twice", async () => { + const found = await checks({ + opencode: "2.0.15", + latest: { "opencode-cockpit": "0.6.0", "@opencode-cockpit/shell": "0.6.0" }, + files: { + [`${CONFIG}/opencode.json`]: json({ plugins: ["opencode-cockpit@0.6.0"] }), + [`${CONFIG}/cli.json`]: json({ plugins: ["@opencode-cockpit/shell@0.6.0"] }), + }, + }) + expect(found.Config?.state).toBe("warn") + expect(found.Config?.fix?.join()).toContain("also inside opencode-cockpit") + }) + + test("the same package in opencode.json and cli.json on OpenCode 2 is fine: it loads once", async () => { + const found = await checks({ + opencode: "2.0.15", + latest: { "opencode-cockpit": "0.6.0" }, + files: { + [`${CONFIG}/opencode.json`]: json({ plugins: ["opencode-cockpit@0.6.0"] }), + [`${CONFIG}/cli.json`]: json({ plugins: ["opencode-cockpit@0.6.0"] }), + }, + }) + expect(found.Config?.state).toBe("ok") + }) + + test("OpenCode 2 pointed at a checkout: the OPENTUI_FORCE_WCWIDTH failure, before it happens", async () => { + const repo = "/src/opencode-cockpit" + const found = await checks({ + opencode: "2.0.15", + files: { + [`${CONFIG}/opencode.json`]: json({ plugins: [`${repo}/packages/opencode`] }), + [`${repo}/packages/opencode/package.json`]: json({ name: "opencode-cockpit" }), + }, + exists: [`${repo}/packages/opencode/node_modules/@opentui/core`], + }) + expect(found.Config?.state).toBe("fail") + expect(found.Config?.fix?.join()).toContain("bun run dev:install") + }) + + test("an install by path is fine", async () => { + const found = await checks({ + opencode: "2.0.15", + files: { + [`${CONFIG}/opencode.json`]: json({ + plugins: [`${HOME}/.cockpit-dev/node_modules/opencode-cockpit`], + }), + [`${HOME}/.cockpit-dev/node_modules/opencode-cockpit/package.json`]: json({ + name: "opencode-cockpit", + }), + }, + }) + expect(found.Config?.state).toBe("info") + }) + + test("a config that does not parse is named", async () => { + const found = await checks({ opencode: "2.0.15", files: { [`${CONFIG}/opencode.json`]: "{ nope" } }) + expect(found.Config?.state).toBe("fail") + expect(found.Config?.detail?.join()).toContain("opencode.json") + }) +}) + +describe("the logs", () => { + const log = (...lines: object[]) => lines.map((line) => JSON.stringify(line)).join("\n") + const LOG = `${HOME}/.cache/opencode-cockpit/cockpit.log` + + test("what actually ran, from the start lines", async () => { + const found = await checks({ + opencode: "2.0.15", + files: { + [LOG]: log( + { + t: "2026-09-24T11:00:00Z", + lvl: "info", + scope: "tui", + msg: "start", + entry: "opencode-cockpit", + opencode: 2, + cockpit: "0.6.0", + }, + { + t: "2026-09-24T11:00:01Z", + lvl: "info", + scope: "server", + msg: "start", + entry: "opencode-cockpit", + opencode: 2, + cockpit: "0.6.0", + }, + ), + }, + }) + expect(found["Last run"]?.state).toBe("ok") + expect(found["Last run"]?.summary).toContain("Cockpit 0.6.0 on OpenCode 2") + }) + + test("two versions loaded at once is worth saying", async () => { + const found = await checks({ + opencode: "2.0.15", + files: { + [LOG]: log( + { + t: "2026-09-24T11:00:00Z", + lvl: "info", + scope: "tui", + msg: "start", + entry: "opencode-cockpit.shell", + cockpit: "0.6.0", + }, + { + t: "2026-09-24T11:00:00Z", + lvl: "info", + scope: "tui", + msg: "start", + entry: "opencode-cockpit.review", + cockpit: "0.5.9", + }, + ), + }, + }) + expect(found["Last run"]?.state).toBe("warn") + }) + + test("errors from the last day, with their messages; older ones are history", async () => { + const found = await checks({ + opencode: "2.0.15", + files: { + [LOG]: log( + { t: "2026-09-20T11:00:00Z", lvl: "error", scope: "tui:review", msg: "old" }, + { + t: "2026-09-24T11:00:00Z", + lvl: "error", + scope: "tui:review", + msg: "review: trouble", + message: "ink.constructor.clone is not a function", + }, + ), + }, + }) + expect(found.Errors?.state).toBe("warn") + expect(found.Errors?.summary).toStartWith("1 error") + expect(found.Errors?.detail?.join()).toContain("ink.constructor.clone") + }) +}) + +describe("the rest", () => { + test("a running daemon, by its pid", async () => { + const found = await checks({ + opencode: "2.0.15", + files: { [`${HOME}/.cache/opencode-cockpit/cockpitd.pid`]: "4242" }, + alive: [4242], + }) + expect(found.Daemon?.state).toBe("ok") + }) + + test("no git is a failure with a reason", async () => { + const found = await checks({ opencode: "2.0.15", git: false }) + expect(found.Environment?.state).toBe("fail") + }) + + test("a statusline module that is not there", async () => { + const found = await checks({ + opencode: "2.0.15", + files: { + [`${HOME}/.config/opencode-cockpit/config.json`]: json({ + statusline: { modules: ["~/.config/opencode-cockpit/modules/gone.ts"] }, + }), + }, + }) + expect(found.Settings?.state).toBe("warn") + expect(found.Settings?.fix?.join()).toContain("gone.ts") + }) +}) + +describe("the command", () => { + test("exits 1 when something must be fixed, 0 otherwise", async () => { + expect((await run({})).code).toBe(1) + const fine = await run({ + opencode: "2.0.15", + latest: { "opencode-cockpit": "0.6.0" }, + files: { [`${CONFIG}/opencode.json`]: json({ plugins: ["opencode-cockpit@0.6.0"] }) }, + }) + expect(fine.code).toBe(0) + expect(fine.out).toContain("Cockpit doctor") + }) + + test("an unknown argument is refused, with the help", async () => { + const { code, out } = await run({}, ["--wat"]) + expect(code).toBe(2) + expect(out).toContain("Usage:") + }) +}) diff --git a/packages/updater/tui.js b/packages/updater/tui.js new file mode 100644 index 0000000..43232b0 --- /dev/null +++ b/packages/updater/tui.js @@ -0,0 +1,6 @@ +/** + * OpenCode 2 finds a plugin configured by *path* by the files at its root — `/tui`, + * `/server` — rather than through `exports` (docs/opencode/v2.md). A package installed by + * name resolves through `exports` as before; this file is only the door for the path case. + */ +export { default } from "./dist/tui/index.js" diff --git a/scripts/dev-install.ts b/scripts/dev-install.ts new file mode 100644 index 0000000..b372b76 --- /dev/null +++ b/scripts/dev-install.ts @@ -0,0 +1,68 @@ +/** + * Installs this checkout into `~/.cockpit-dev` exactly as a user would get it, for trying it in a real + * OpenCode: + * + * bun run build && bun scripts/dev-install.ts + * + * Why not point OpenCode at `packages/opencode`: each package in the repo has the workspace's own + * OpenTUI and Solid beside it (dev dependencies), so a plugin loaded from there brings a second copy + * next to the host's. OpenCode 2 refuses that at start — `Environment variable "OPENTUI_FORCE_WCWIDTH" + * is already registered with different configuration` — and even where it starts, two Solids do not + * share reactivity. A packed install has none of them, so every import is the host's, as it is for + * users. Run again after each build; the path stays the same, so configs need setting once. + */ + +import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync } from "node:fs" +import { homedir, tmpdir } from "node:os" +import { join } from "node:path" +import { FEATURES } from "../packages/opencode/src/features.ts" + +const root = join(import.meta.dir, "..") +const target = process.env.COCKPIT_DEV_DIR ?? join(homedir(), ".cockpit-dev") +/** + * Built beside the old one and swapped in by rename. OpenCode 2 watches a plugin's directory and + * reloads on change; clearing the old install first had a running OpenCode reload a half-written one + * and fail with `Cannot find package '@opencode-cockpit/client'`. + */ +const next = `${target}.next` +const tarballs = mkdtempSync(join(tmpdir(), "cockpit-dev-")) + +const run = (cmd: string[], cwd: string) => { + const result = Bun.spawnSync(cmd, { cwd, stdout: "pipe", stderr: "pipe" }) + if (result.exitCode !== 0) throw new Error(`$ ${cmd.join(" ")}\n${result.stdout}\n${result.stderr}`) +} + +rmSync(next, { recursive: true, force: true }) +mkdirSync(next, { recursive: true }) +const packages = ["protocol", "daemon", "client", "opencode", ...FEATURES] +for (const dir of packages) run(["bun", "pm", "pack", "--destination", tarballs], join(root, "packages", dir)) + +const names = [...new Bun.Glob("*.tgz").scanSync(tarballs)] +/** The bundle's tarball is `opencode-cockpit-`; every scoped one has its name after the dash. */ +const tarball = (dir: string) => { + const pattern = dir === "opencode" ? /^opencode-cockpit-\d/ : new RegExp(`^opencode-cockpit-${dir}-\\d`) + return `file:${join(tarballs, names.find((name) => pattern.test(name)) as string)}` +} +const scoped = (dir: string) => (dir === "opencode" ? "opencode-cockpit" : `@opencode-cockpit/${dir}`) + +await Bun.write( + join(next, "package.json"), + JSON.stringify({ + name: "cockpit-dev", + private: true, + dependencies: Object.fromEntries(["opencode", ...FEATURES].map((dir) => [scoped(dir), tarball(dir)])), + overrides: Object.fromEntries(["protocol", "daemon", "client"].map((dir) => [scoped(dir), tarball(dir)])), + }), +) +run(["npm", "install"], next) +const old = `${target}.old` +rmSync(old, { recursive: true, force: true }) +if (existsSync(target)) renameSync(target, old) +renameSync(next, target) +rmSync(old, { recursive: true, force: true }) +rmSync(tarballs, { recursive: true, force: true }) + +const bundle = join(target, "node_modules", "opencode-cockpit") +console.log(`installed into ${target}\n\nPoint both OpenCode versions at:\n ${bundle}\n`) +console.log(`v1 opencode.json + tui.json: "plugin": ["${bundle}"]`) +console.log(`v2 opencode.json + cli.json: "plugins": ["${bundle}"]`) diff --git a/scripts/pack-check.ts b/scripts/pack-check.ts index 05b153b..bbcc27a 100644 --- a/scripts/pack-check.ts +++ b/scripts/pack-check.ts @@ -136,9 +136,15 @@ try { */ function verifyRescue(dir: string, install: { name: string; packages: string[] }) { const bins = install.packages.includes("opencode-cockpit") - ? [["opencode-cockpit", "update", "--help"]] + ? [ + ["opencode-cockpit", "update", "--help"], + ["opencode-cockpit", "doctor", "--help"], + ] : install.packages.includes("@opencode-cockpit/updater") - ? [["updater", "--help"]] + ? [ + ["updater", "--help"], + ["updater", "doctor", "--help"], + ] : [] for (const [bin, ...args] of bins) { const out = run(["node", join(dir, "node_modules", ".bin", bin as string), ...args], dir) diff --git a/scripts/set-version.ts b/scripts/set-version.ts index ce22f3a..3907951 100644 --- a/scripts/set-version.ts +++ b/scripts/set-version.ts @@ -62,7 +62,9 @@ const docs = [ .map((file) => join(root, file)), join(root, "site/src/data/landing.ts"), ] -const pinned = /(opencode plugin (?:opencode-cockpit|@opencode-cockpit\/[a-z]+))@\d+\.\d+\.\d+(?:-[\w.]+)?/g +/** v1's `opencode plugin x@v`, v2's `opencode plugin add x@v`, and v2's `"package": "x@v"` entries. */ +const pinned = + /((?:opencode plugin (?:add )?|"package": ")(?:opencode-cockpit|@opencode-cockpit\/[a-z]+))@\d+\.\d+\.\d+(?:-[\w.]+)?/g for (const file of docs) { if (!existsSync(file)) continue const text = await Bun.file(file).text() diff --git a/scripts/test-env.ts b/scripts/test-env.ts new file mode 100644 index 0000000..7d5a78c --- /dev/null +++ b/scripts/test-env.ts @@ -0,0 +1,8 @@ +/** + * Preloaded by `bun test` (bunfig.toml). Features started in tests log like real ones, and without + * this their lines landed in the developer's own `~/.cache/opencode-cockpit/cockpit.log`. + */ +import { tmpdir } from "node:os" +import { join } from "node:path" + +process.env.COCKPIT_LOG_FILE ??= join(tmpdir(), `cockpit-test-${process.pid}.log`) diff --git a/scripts/tui-smoke.ts b/scripts/tui-smoke.ts index 6048e26..fb71d0d 100644 --- a/scripts/tui-smoke.ts +++ b/scripts/tui-smoke.ts @@ -16,12 +16,21 @@ import { Terminal } from "@xterm/headless" import { FEATURES } from "../packages/opencode/src/features.ts" const root = join(import.meta.dir, "..") -const opencode = Bun.which("opencode") +/** OPENCODE picks the binary, so the same test can drive v1 and v2 side by side. */ +const opencode = process.env.OPENCODE ?? Bun.which("opencode") if (!opencode) { console.error("opencode binary not found; install OpenCode to run this smoke test") process.exit(1) } +/** Which OpenCode this is decides where plugins are configured and how it is started. */ +const v2 = Bun.spawnSync([opencode, "--version"]) + .stdout.toString() + .trim() + .replace(/^opencode\s+v?/, "") + .startsWith("2") +console.log(`smoke against OpenCode ${v2 ? "2" : "1"} (${opencode})`) + const work = mkdtempSync("/tmp/ck-smoke-") const install = join(work, "install") const project = join(work, "project") @@ -34,6 +43,66 @@ const run = (cmd: string[], cwd: string) => { return result.stdout.toString() } +/** + * `AGENT=1`: one real turn, by a free OpenCode Zen model, against the server halves — the only proof + * that the tools registered and the system prompt carries the guidance, on either version. Needs the + * network and a model willing to follow instructions, so it is opt-in. + */ +function agentTurn(env: Record) { + const prompt = [ + "Call the tool shell_start with command 'echo AGENT-SHELL-OK' and description 'agent probe', then call review_list.", + "Your system prompt has a heading line starting with '## Background shells' and one starting with '## Review comments'.", + "Quote both heading lines exactly in your reply.", + ].join(" ") + const args = [ + opencode as string, + "run", + ...(v2 ? ["--standalone", "--auto"] : []), + "-m", + "opencode/space-bunny-free", + ] + const result = Bun.spawnSync([...args, "--format", "json", prompt], { + cwd: project, + env, + stdout: "pipe", + stderr: "pipe", + }) + const events = result.stdout + .toString() + .split("\n") + .filter((line) => line.startsWith("{")) + .map((line) => JSON.parse(line) as { type: string; part?: Record }) + /** v2 runs plugin tools through Code Mode: the calls are listed on its `execute` part. */ + const called = events + .filter((event) => event.type === "tool_use") + .flatMap((event) => { + const part = event.part as unknown as { + tool: string + state: { + status: string + metadata?: { metadata?: { toolCalls?: { tool: string; status: string }[] } } + } + } + return [ + { tool: part.tool, status: part.state.status }, + ...(part.state.metadata?.metadata?.toolCalls ?? []), + ] + }) + .filter((call) => call.status === "completed") + .map((call) => call.tool) + const said = events + .filter((event) => event.type === "text") + .map((event) => (event.part as unknown as { text: string }).text) + .join("\n") + const report = `${result.stdout}\n${result.stderr}`.slice(-3000) + for (const name of ["shell_start", "review_list"]) { + if (!called.includes(name)) throw new Error(`the agent never completed ${name}:\n${report}`) + } + for (const heading of ["## Background shells", "## Review comments"]) { + if (!said.includes(heading)) throw new Error(`the agent was never told "${heading}":\n${report}`) + } +} + const cols = 150 const rows = 40 const term = new Terminal({ cols, rows, allowProposedApi: true }) @@ -90,15 +159,27 @@ 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) - for (const [name, schema, plugins] of [ - ["opencode.json", "https://opencode.ai/config.json", [bay("shell")]], - [ - "tui.json", - "https://opencode.ai/tui.json", - [bay("shell"), bay("status"), bay("review"), bay("updater")], - ], - ] as const) { - await Bun.write(join(config, "opencode", name), JSON.stringify({ $schema: schema, plugin: plugins })) + const tuiBays = [bay("shell"), bay("status"), bay("review"), bay("updater")] + const serverBays = [bay("shell"), bay("review")] + /** + * v1 reads `plugin` from opencode.json and tui.json; v2 reads `plugins` from opencode.json and + * cli.json (docs/opencode/v2.md). The same packages go in either way. + */ + const files: [string, string, string, string[]][] = v2 + ? [ + ["opencode.json", "https://opencode.ai/config.json", "plugins", serverBays], + ["cli.json", "https://opencode.ai/cli.json", "plugins", tuiBays], + ] + : [ + ["opencode.json", "https://opencode.ai/config.json", "plugin", serverBays], + ["tui.json", "https://opencode.ai/tui.json", "plugin", tuiBays], + ] + for (const [name, schema, key, plugins] of files) { + /** v1's schema URLs mean nothing to v2, whose loader skipped files carrying them. */ + await Bun.write( + join(config, "opencode", name), + JSON.stringify(v2 ? { [key]: plugins } : { $schema: schema, [key]: plugins }), + ) } /** * A statusline whose value has to come from somewhere the plugin cannot fake: a literal marker @@ -119,16 +200,18 @@ try { }), ) - const proc = Bun.spawn([opencode], { + const env = { + ...process.env, + XDG_CONFIG_HOME: config, + /** OpenCode's kv lives here: without its own, a run writes plugin state into the user's real one. */ + XDG_STATE_HOME: join(work, "state"), + COCKPIT_HOME: home, + TERM: "xterm-256color", + } + /** v2 would attach to the user's background service; a private server keeps the run to itself. */ + const proc = Bun.spawn(v2 ? [opencode, "--standalone"] : [opencode], { cwd: project, - env: { - ...process.env, - XDG_CONFIG_HOME: config, - /** OpenCode's kv lives here: without its own, a run writes plugin state into the user's real one. */ - XDG_STATE_HOME: join(work, "state"), - COCKPIT_HOME: home, - TERM: "xterm-256color", - }, + env, terminal: { cols, rows, @@ -163,6 +246,21 @@ try { const consoleDetails = await screen() await type("\x1b", 800) // esc, back to the conversation + /** + * Full screen, which neither version's run opened before — so on OpenCode 2 it could draw nothing + * and still pass. `w` swaps the dialog for it and is remembered, so it is swapped back before leaving. + */ + await type("\x18i", 3000) + await type("w", 2500) + const fullScreen = await screen() + await type("w", 1500) + await type("\x1b", 800) + if (process.env.SMOKE_SHOW) console.log(fullScreen) + /** The dialog sits inside the host's frame; only full screen puts the header on the top row. */ + if (!fullScreen.split("\n")[0]?.includes("RUN") || !/^ {2}│ tick \d+/m.test(fullScreen)) { + throw new Error(`full screen never drew the console across the window:\n${fullScreen}`) + } + for (const [what, marker] of [ ["the console never drew its keys", "[?]"], ["the console's action keys never drew", "[r]"], @@ -226,11 +324,17 @@ try { ["/plugins-update", updater], ["/cockpit-update", legacy], ] as const) { - for (const marker of ["Plugins", "published", "local", "Review"]) { + /** OpenCode 2 updates plugins itself; there the commands point at it instead of opening the dialog. */ + for (const marker of v2 ? ["change the version"] : ["Plugins", "published", "local", "Review"]) { if (!text.includes(marker)) throw new Error(`${what} did not draw "${marker}":\n${text}`) } } + /** 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]) { + if (/plugins? failed/.test(text)) throw new Error(`OpenCode could not load a plugin:\n${text}`) + } + const ticks = (text: string) => [...text.matchAll(/tick (\d+)/g)].map((m) => Number(m[1])) const firstMax = Math.max(0, ...ticks(first)) const secondMax = Math.max(0, ...ticks(second)) @@ -240,9 +344,12 @@ try { `the panel froze: still at tick ${firstMax} after 4s (published JSX not Solid-compiled?)\n${second}`, ) } + if (process.env.AGENT) agentTurn(env) console.log( - `tui smoke passed: panel live, tick ${firstMax} → ${secondMax}; console and its keys drew; statusline drew; review drew its diff; updater drew from both slash names`, + `tui smoke passed: panel live, tick ${firstMax} → ${secondMax}; console and its keys drew; full screen drew; statusline drew; review drew its diff; updater answered both slash names${process.env.AGENT ? "; an agent called both bays' tools and was told about them" : ""}`, ) } finally { - rmSync(work, { recursive: true, force: true }) + /** KEEP=1 leaves the install and project behind, to inspect what a run actually loaded. */ + if (process.env.KEEP) console.log(`kept ${work}`) + else rmSync(work, { recursive: true, force: true }) } diff --git a/site/astro.config.mjs b/site/astro.config.mjs index f8f4b4b..ccb4ac0 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -33,6 +33,7 @@ export default defineConfig({ items: [ { label: "What Cockpit is", slug: "start/what-cockpit-is" }, { label: "Install", slug: "start/install" }, + { label: "OpenCode 1 and 2", slug: "start/opencode-versions" }, { label: "Your first session", slug: "start/first-session" }, ], }, @@ -81,6 +82,7 @@ export default defineConfig({ { label: "Help", items: [ + { label: "Doctor", slug: "help/doctor" }, { label: "Troubleshooting", slug: "help/troubleshooting" }, { label: "Changelog", slug: "help/changelog" }, ], diff --git a/site/src/content/docs/help/doctor.md b/site/src/content/docs/help/doctor.md new file mode 100644 index 0000000..66a4373 --- /dev/null +++ b/site/src/content/docs/help/doctor.md @@ -0,0 +1,88 @@ +--- +title: Doctor +description: One command that checks your setup and prints the fix for anything wrong — on OpenCode 1 and 2. +--- + +```sh +npx opencode-cockpit@latest doctor +``` + +Doctor checks OpenCode, its config, what Cockpit logged, the daemon and the tools Cockpit needs, and +for anything wrong prints the exact line that fixes it — spelled for the OpenCode you have. It runs +outside OpenCode, from npm, so it works when Cockpit will not load at all, and whatever version you +have installed. + +``` +Cockpit doctor + + ✓ OpenCode 2.0.15 (OpenCode 2) + ✗ Config Cockpit is configured, but will not load as written + opencode-cockpit@0.5.2 (~/.config/opencode/opencode.json) + → opencode-cockpit@0.5.2 is OpenCode 1 only (0.6 is the first for both). change the + entry to "opencode-cockpit@0.6.0" in opencode.json, then restart OpenCode + ✓ Last run Cockpit 0.6.0 on OpenCode 2, 2026-09-24T11:00:01Z + ! Errors 1 error, 0 warnings in the last day + 2026-09-24T11:00:00Z error tui:review review: trouble: … + → the full lines, with stacks: tail -100 ~/.cache/opencode-cockpit/cockpit.log + · Daemon not running — it starts with the first shell, and stops when idle + ✓ Environment git, ps, and a writable Cockpit home + ✓ Settings 1 file, 1 statusline module + +1 to fix, 1 to look at +``` + +`✓` fine · `·` for your information · `!` worth a look · `✗` must be fixed. + +## What it checks + +**OpenCode.** Whether `opencode` is installed, and whether Cockpit runs on its version: 1.18 and +newer, and 2.0.15 and newer. Anything older, it says so and what to run. + +**Config.** Every file either OpenCode reads plugins from — `opencode.json`, `tui.json`, `cli.json` +(and `.jsonc`), global and in the project — in both spellings: OpenCode 1's `"plugin"`, OpenCode 2's +`"plugins"` with its `{ "package", "options" }` entries. It flags: + +- a file that does not parse +- Cockpit not configured at all — with the install line +- a bay configured twice, as `opencode-cockpit` and on its own — one of them stands down +- on OpenCode 1, a half missing: in `opencode.json` but not `tui.json` means no panels; the reverse, + no agent tools +- on OpenCode 2, an entry pointing at a git checkout — the one that fails with + `OPENTUI_FORCE_WCWIDTH is already registered` +- an entry without an exact version, which OpenCode never updates +- a version older than the newest published, or older than 0.6 on OpenCode 2 + +**Last run.** What actually loaded, from the start lines in `cockpit.log`: each bay, its Cockpit +version, which OpenCode and when. This is the answer to "the config says one thing, but what is +running?" — and two different Cockpit versions loaded at once is flagged. + +**Errors.** Errors and warnings from the last day, in Cockpit's log and the daemon's, with the latest +messages — including the ones that were only a toast. + +**Daemon.** Whether the shell daemon is running, and the build it was started from. + +**Environment.** `git` on your PATH (Review reads branches through it), `ps` (the daemon stops a +shell's processes with it), and a Cockpit home it can write to. + +**Settings.** `~/.config/opencode-cockpit/config.json` and the project's `.cockpit.json` parse — an +invalid one is ignored whole — and every statusline module they list exists. + +## For an issue + +```sh +npx opencode-cockpit@latest doctor --json > doctor.json +``` + +Everything doctor found, as JSON: the config entries, the start and error lines it read, the checks. +It holds paths from your machine, not your code or conversation — look it over before attaching it. + +## In a script + +Doctor exits `1` when something must be fixed and `0` otherwise, so it answers "is Cockpit set up?" +for a dotfiles script or CI. + +## Not yet + +Doctor checks that statusline modules exist, not that they load; it does not yet know which cached +copy of a plugin OpenCode loaded; and it runs from a terminal, not from inside OpenCode. +[Troubleshooting](/opencode-cockpit/help/troubleshooting/) covers the rest. diff --git a/site/src/content/docs/help/troubleshooting.md b/site/src/content/docs/help/troubleshooting.md index 3cee01b..97f074e 100644 --- a/site/src/content/docs/help/troubleshooting.md +++ b/site/src/content/docs/help/troubleshooting.md @@ -1,8 +1,71 @@ --- title: Troubleshooting -description: The failures people actually hit, and where to look. +description: Where Cockpit writes down what went wrong, and the failures people actually hit. --- +## Run the doctor + +```sh +npx opencode-cockpit@latest doctor +``` + +It checks OpenCode, its config, what Cockpit logged, the daemon and the tools it needs, and prints +the fix for anything wrong — even when Cockpit will not load at all. What it checks is in +[Doctor](/opencode-cockpit/help/doctor/). + +## Where to look first + +Cockpit writes what it does inside OpenCode to **one file**, whichever OpenCode you run: + +```sh +tail -50 ~/.cache/opencode-cockpit/cockpit.log +``` + +One JSON line per event. Every start says which OpenCode (1 or 2) loaded which bay, and every error +is there with its stack — including the ones that were only a toast for a few seconds. `scope` says +where it came from: `tui:shell` is Shell's panel, `server:review` Review's agent tools. + +To see *everything* — console actions, each tool call and how long it took — start OpenCode with: + +```sh +COCKPIT_DEBUG=1 opencode +``` + +The other places, in the order they help: + +| What | Where | +| --- | --- | +| The shell daemon | `~/.cache/opencode-cockpit/cockpitd.log` | +| Review's full reports (timings, layout) | `~/.local/share/opencode-cockpit/review//trouble.log` | +| A plugin OpenCode 2 would not load | `/plugins` in OpenCode — press space on the failed one for its error | +| A plugin OpenCode would not load at all | OpenCode's own log, newest file in `~/.local/share/opencode/log/` | + +### Opening an issue + +Attach what the doctor found and the end of the log: + +```sh +npx opencode-cockpit@latest doctor --json > doctor.json +tail -200 ~/.cache/opencode-cockpit/cockpit.log > cockpit.log +``` + +Better still, reproduce it once with `COCKPIT_DEBUG=1` first. The log holds paths and shell +commands from your machine, but never your code or conversation — look it over before posting. + +## OpenCode 2 lists Cockpit as failed + +Cockpit before 0.6 runs on OpenCode 1 only. Change the version in your `opencode.json` entry to +the newest release, then restart: + +```json title="~/.config/opencode/opencode.json" +{ "plugin": ["opencode-cockpit@"] } +``` + +Edit the entry you have rather than running `opencode plugin add` beside it: `add` writes a second +entry under `"plugins"` and leaves the old one, and two copies of a bay mean one of them stands down. + +Which version runs where is in [OpenCode 1 and 2](/opencode-cockpit/start/opencode-versions/). + ## Tools fail with "did not start" The daemon could not spawn. Its log is the first place to look: @@ -13,13 +76,37 @@ tail -40 ~/.cache/opencode-cockpit/cockpitd.log ## The plugin doesn't load at all -Check OpenCode's own log — newest file in `~/.local/share/opencode/log/`. A plugin that fails to -import is reported there. +On OpenCode 2, `/plugins` lists it as failed and space shows why. On either, OpenCode's own log — +newest file in `~/.local/share/opencode/log/` — reports a plugin that fails to import. If nothing +names Cockpit there, check it is in the config the OpenCode you run reads: `"plugin"` for 1, +`"plugins"` for 2. + +## "Environment variable OPENTUI_FORCE_WCWIDTH is already registered" + +OpenCode 2 is loading Cockpit from a git checkout. A checkout carries its own copy of the libraries +OpenCode draws with, and two copies cannot load at once. Install a package instead — from a +checkout, `bun run dev:install` installs one to `~/.cockpit-dev` to point OpenCode at. + +## A statusline module says "Cannot find package '@opencode-cockpit/status'" + +A module outside a project (in `~/.config/opencode-cockpit/`, say) imports +`@opencode-cockpit/status/segment`, which only resolves where Status is installed. Cockpit rewrites +that import for you; before 0.6 it did not recognise OpenCode 2's wording of the error. Update. ## "Shell is configured twice" -The bay is installed both through `opencode-cockpit` and on its own. Remove one of the entries from -**both** `opencode.json` and `tui.json`. +The bay is installed both through `opencode-cockpit` and on its own. Remove one of the entries — +from **both** `opencode.json` and `tui.json` on OpenCode 1, from `opencode.json` on OpenCode 2. + +## The full-screen console shows the conversation through it + +A theme with a transparent background (OpenCode's "system" theme shows the terminal's own) left the +full-screen console and the Review pane see-through before 0.6. Update. + +## An agent starts shells without asking, on OpenCode 2 + +Known: OpenCode 2 gives a plugin tool no documented way to ask for permission, so `shell_start` is +not held by your `bash` rules there. We are working on making it ask, as it does on OpenCode 1. See [what differs](/opencode-cockpit/start/opencode-versions/#what-differs-on-opencode-2). ## The panel says the daemon runs older code @@ -29,18 +116,23 @@ A newer plugin connected, but shells were running so the daemon was kept. Run ## I updated but nothing changed OpenCode resolves a plugin spec once and caches it for ever, so `@latest` — or no version at all — -stays on the release it first installed. Run `/plugins-update`, or from a shell, whatever version you -are on: +stays on the release it first installed. + +On **OpenCode 1**, run `/plugins-update`, or from a shell, whatever version you are on: ```sh npx opencode-cockpit@latest update ``` -It pins the newest version, clears the stale cache, and checks both files; then restart. To check -which build is actually running: +It pins the newest version, clears the stale cache, and checks both files; then restart. + +On **OpenCode 2**, change the version in your `opencode.json` entry and restart — the updater above +edits OpenCode 1's files only. + +To check which build is actually running, the newest start line says it: ```sh -grep '"daemon started"' ~/.cache/opencode-cockpit/cockpitd.log | tail -1 +grep '"msg":"start"' ~/.cache/opencode-cockpit/cockpit.log | tail -1 ``` ## A setting seems to do nothing diff --git a/site/src/content/docs/start/install.md b/site/src/content/docs/start/install.md index c92c071..24a87df 100644 --- a/site/src/content/docs/start/install.md +++ b/site/src/content/docs/start/install.md @@ -3,21 +3,35 @@ title: Install description: Two commands, both config files, and what happens on first run. --- -Requires **OpenCode 1.18+** on macOS or Linux. +Requires **OpenCode 1.18+ or 2.0.15+** on macOS or Linux — one package runs on both. Which version +you have, and what differs, is in [OpenCode 1 and 2](/opencode-cockpit/start/opencode-versions/). ## Everything +On **OpenCode 1**: + ```sh opencode plugin opencode-cockpit@0.5.2 --global --force ``` This writes the plugin entry into **both** `opencode.json` and `tui.json` — the agent half and the -interface half. Restart OpenCode afterwards. +interface half. + +On **OpenCode 2**: + +```sh +opencode plugin add opencode-cockpit@0.5.2 +``` + +This writes `"plugins"` in `opencode.json`, and OpenCode 2 loads both halves from there. + +Restart OpenCode afterwards. ## A single bay ```sh -opencode plugin @opencode-cockpit/shell@0.5.2 --global --force +opencode plugin @opencode-cockpit/shell@0.5.2 --global --force # OpenCode 1 +opencode plugin add @opencode-cockpit/shell@0.5.2 # OpenCode 2 ``` Same daemon, same config file, same interface slots. Add other bays later without changing anything @@ -30,7 +44,7 @@ wins and Cockpit warns you which entry to remove. ## Turning bays off -```json title="opencode.json and tui.json" +```json title="OpenCode 1 — opencode.json and tui.json" { "plugin": [ ["opencode-cockpit", { "features": { "shell": true } }] @@ -38,6 +52,14 @@ wins and Cockpit warns you which entry to remove. } ``` +```json title="OpenCode 2 — opencode.json" +{ + "plugins": [ + { "package": "opencode-cockpit@0.5.2", "options": { "features": { "shell": true } } } + ] +} +``` + ## What happens on first run 1. The plugin connects to `cockpitd`, starting it if it isn't running. @@ -52,7 +74,8 @@ OpenCode resolves a plugin spec **once** and caches it for ever, so a bare `open `@latest` means the release that was newest the day you first installed it. That is why the commands above pin a version, and why `--force` is there: run the same line with a newer version to move. -You rarely need to. Once a day Cockpit checks every plugin you have — not just its own — and says so +On OpenCode 2, change the version in your `opencode.json` entry — Cockpit's updater edits OpenCode 1's +files only. On OpenCode 1 you rarely need to: once a day Cockpit checks every plugin you have — not just its own — and says so when something is behind. `/plugins-update` shows what runs beside what your config says and what is published, and updates what you pick: it pins the new version through OpenCode's own `opencode plugin`, removes the stale cache, and reads every file back before calling it done. diff --git a/site/src/content/docs/start/opencode-versions.md b/site/src/content/docs/start/opencode-versions.md new file mode 100644 index 0000000..4efc90b --- /dev/null +++ b/site/src/content/docs/start/opencode-versions.md @@ -0,0 +1,88 @@ +--- +title: OpenCode 1 and 2 +description: Which Cockpit runs on which OpenCode, what moving to OpenCode 2 changes, and what it can't do there yet. +--- + +OpenCode 2 replaced the plugin API: a plugin written for OpenCode 1 does not run on it. From **0.6.0**, +Cockpit ships **one package for both** — each entry carries a v1 half and a v2 half, and whichever OpenCode loads it +picks its own. Nothing to choose at install time. + +## Which Cockpit for which OpenCode + +| Your OpenCode | Cockpit | | +| --- | --- | --- | +| 2.0.15 or newer | **0.6.0 or newer** | everything, with the [differences below](#what-differs-on-opencode-2) | +| 1.18.x | **0.6.0 or newer** | everything — tested on 1.18.0, 1.18.28 and 1.18.32 | +| older than 1.18 | not supported | upgrade OpenCode — 1.17 loads the panels but never draws full screen | + +`opencode --version` says which you have. + +## Installing on OpenCode 2 + +```sh +opencode plugin add opencode-cockpit@0.5.2 +``` + +That writes `"plugins"` in `opencode.json`, and OpenCode 2 loads **both halves** from there — the agent +tools and the interface. A single bay works the same way: + +```sh +opencode plugin add @opencode-cockpit/shell@0.5.2 +``` + +Options go in the entry as an object — the v2 spelling of v1's `[name, options]` pair: + +```json title="~/.config/opencode/opencode.json" +{ + "plugins": [ + { "package": "opencode-cockpit@0.5.2", "options": { "features": { "status": false } } } + ] +} +``` + +## Moving from OpenCode 1 to 2 + +Nothing to change. OpenCode 2 reads an existing v1 `opencode.json` — `"plugin"` and all — and copies a +global `tui.json` into its own `cli.json` on first start. Cockpit's own settings +(`~/.config/opencode-cockpit/config.json`, `.cockpit.json`) and your statusline modules are the same +files on both. + +What does not carry over is **Cockpit before 0.6**: it runs on OpenCode 1 only. If OpenCode 2 lists +Cockpit under `/plugins` as failed, change the version in your `opencode.json` entry to the newest +release and restart. Edit that entry rather than running `opencode plugin add` beside it — `add` +writes a second one and leaves the old. + +## Running both side by side + +The two can share one machine and one config. Install OpenCode 1 under another name (it is the +`opencode-ai` package on npm), and point both at the same Cockpit: + +```sh +mkdir -p ~/.opencode-v1 && cd ~/.opencode-v1 && npm i opencode-ai@1.18.32 +# then run it as ~/.opencode-v1/node_modules/.bin/opencode, or alias it +``` + +## What differs on OpenCode 2 + +Everything Cockpit does works on both. Where OpenCode 2 gives a plugin less to work with, this is +what you will notice: + +| | OpenCode 1 | OpenCode 2 | +| --- | --- | --- | +| **`shell_start` permission** | asks with your `bash` permission rules | runs without asking — v2 gives a plugin tool no way to ask | +| **Tool calls in the chat** | `shell_start`, `review_list`… | `execute`, calling them in Code Mode — same tools, same results | +| **Updating** | `/plugins-update`, or `npx opencode-cockpit@latest update` | change the version in `opencode.json`; Cockpit's updater edits OpenCode 1's files only | +| **Statusline `lsp` segment** | language servers | empty — v2 does not expose them to plugins | +| **Colours** | the theme | the same theme, except the subtle border grey, one shade lighter | + +:::caution[The permission difference] +On OpenCode 1, an agent starting a background shell asks first unless your rules allow it. On +OpenCode 2 it does not. If you rely on `bash` permission rules to keep an agent from running +commands, know that `shell_start` is not held by them there. + +We are working on this: finding how a plugin tool can ask on OpenCode 2 — its tools accept a +permission setting that is not documented yet — so `shell_start` respects your `bash` rules there +the way it does on OpenCode 1. +::: + +These track what OpenCode 2 exposes; each is revisited as it grows. diff --git a/site/src/content/docs/start/what-cockpit-is.md b/site/src/content/docs/start/what-cockpit-is.md index 855300e..8587973 100644 --- a/site/src/content/docs/start/what-cockpit-is.md +++ b/site/src/content/docs/start/what-cockpit-is.md @@ -42,8 +42,8 @@ nothing needs it. ## One config for both halves -Agent plugins are configured in `opencode.json`, interface plugins in `tui.json`. Without help, every -setting has to be written twice. +On OpenCode 1, agent plugins are configured in `opencode.json` and interface plugins in `tui.json`. +Without help, every setting has to be written twice. Cockpit reads **one file**, merging global → project → plugin entry, and hands the result to both halves. See [Configuration](/opencode-cockpit/configuration/). diff --git a/site/src/data/landing.ts b/site/src/data/landing.ts index 01dca01..557eea2 100644 --- a/site/src/data/landing.ts +++ b/site/src/data/landing.ts @@ -77,7 +77,7 @@ export const platform = { icon: "config", title: "One config for both halves", body: - "Agent plugins are configured in opencode.json, interface plugins in tui.json. " + + "On OpenCode 1, agent plugins are configured in opencode.json, interface plugins in tui.json. " + "Cockpit reads a single file — global, then project, then plugin entry — and ignores a broken one " + "rather than failing.", }, @@ -255,12 +255,13 @@ export const next = { "time, shipped before the next is announced.", items: [ { - name: "Doctor", + name: "Review and the console in OpenCode 2's panel", state: "next", blurb: - "One command that checks your setup and says how to fix it: which halves are loaded, which " + - "keys collide, whether a daemon is running code older than the plugin that is talking to it.", - why: "Every answer it needs is already on disk or on the wire; nothing new has to be exposed.", + "OpenCode 2 has a side panel of its own — with focus, a width that follows the window, and a " + + "full-screen toggle. Review and the full-screen console draw their own today; on OpenCode 2 " + + "they can live in the host's, and behave like the rest of its interface.", + why: "OpenCode 2's plugin API offers the panel to plugins, with focus and a full-screen toggle built in.", }, ], } @@ -287,15 +288,24 @@ export const install = { "Just this bay. Same daemon, same config file, same interface slots — add the rest later without " + "changing anything you already set up.", }, + { + id: "v2", + label: "OpenCode 2", + command: "opencode plugin add opencode-cockpit@0.5.2", + note: + "The same package — it carries a half for each OpenCode. One entry in opencode.json " + + 'loads both halves. What differs on OpenCode 2.', + }, ], steps: [ - "Run the command above — it writes both plugin entries for you.", + "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.", + "Something off? npx opencode-cockpit@latest doctor checks your setup and prints the fix.", ], } export const closing = { title: "Stop babysitting your terminal.", - body: "OpenCode 1.18+ on macOS and Linux. MIT licensed, every bay its own package.", + body: "OpenCode 1.18+ and 2.0.15+ on macOS and Linux. MIT licensed, every bay its own package.", }