From a8b20df337094c6a6114bff114576a16a7b29b3e Mon Sep 17 00:00:00 2001 From: Codestz Date: Wed, 23 Sep 2026 21:12:24 -0500 Subject: [PATCH 1/5] Opaque surfaces on transparent themes; OpenCode v2 findings The full-screen shell console and the Review pane painted with the theme's background, which the "system" theme leaves transparent, so the conversation showed through. Both now use the first opaque background, or a solid fallback. docs/opencode/v2.md records what was measured on 1.18.32 and 2.0.15. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 7 ++++ packages/review/LICENSE | 2 +- packages/review/src/tui/panel/paint.ts | 4 +- packages/review/src/tui/view/pool.ts | 22 +++++++++++ packages/review/test/watch/surface.test.ts | 45 ++++++++++++++++++++++ packages/shell/src/tui/panel/paint.ts | 4 +- packages/shell/src/tui/view/pool.ts | 22 +++++++++++ packages/status/README.md | 2 +- packages/status/examples/sidebar.ts | 2 +- 9 files changed, 105 insertions(+), 5 deletions(-) create mode 100644 packages/review/test/watch/surface.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index af3795b..47f3429 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +### 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/packages/review/LICENSE b/packages/review/LICENSE index e48d0fa..a7f72f2 100644 --- a/packages/review/LICENSE +++ b/packages/review/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 Codestz +Copyright (c) 2026 Codestz. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/review/src/tui/panel/paint.ts b/packages/review/src/tui/panel/paint.ts index 61c021f..9300f14 100644 --- a/packages/review/src/tui/panel/paint.ts +++ b/packages/review/src/tui/panel/paint.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" @@ -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/view/pool.ts b/packages/review/src/tui/view/pool.ts index 2fd6166..b53bb70 100644 --- a/packages/review/src/tui/view/pool.ts +++ b/packages/review/src/tui/view/pool.ts @@ -225,6 +225,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/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/shell/src/tui/panel/paint.ts b/packages/shell/src/tui/panel/paint.ts index 0dfc15b..40d51a1 100644 --- a/packages/shell/src/tui/panel/paint.ts +++ b/packages/shell/src/tui/panel/paint.ts @@ -3,7 +3,7 @@ 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" @@ -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/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/status/README.md b/packages/status/README.md index e67e952..9010695 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) 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 From 37ca3e2bafa5a83b6799dda4a0048aab5fb7a196 Mon Sep 17 00:00:00 2001 From: Codestz Date: Wed, 23 Sep 2026 21:14:30 -0500 Subject: [PATCH 2/5] client/host: one Host interface for OpenCode v1 and v2 Bays will talk to Host, the ~30 calls they use named as v1 names them. fromV1 wraps the v1 API; fromV2 builds the same shape on the v2 context (theme tokens under v1 names, slot paths, keymap layers, storage-backed kv, renderer key stream for intercept). dualTui makes one entry both load. Co-Authored-By: Claude Opus 5.5 (1M context) --- bun.lock | 5 + packages/client/package.json | 13 +- packages/client/src/host.ts | 552 ++++++++++++++++++++++++++++++ packages/client/test/host.test.ts | 201 +++++++++++ 4 files changed, 769 insertions(+), 2 deletions(-) create mode 100644 packages/client/src/host.ts create mode 100644 packages/client/test/host.test.ts diff --git a/bun.lock b/bun.lock index a968bc7..ef81025 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": { diff --git a/packages/client/package.json b/packages/client/package.json index b3216bf..e20c36b 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -27,6 +27,10 @@ "./feature": { "types": "./types/feature.d.ts", "default": "./dist/feature.js" + }, + "./host": { + "types": "./types/host.d.ts", + "default": "./dist/host.js" } }, "files": [ @@ -39,10 +43,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..12106eb --- /dev/null +++ b/packages/client/src/host.ts @@ -0,0 +1,552 @@ +/** + * 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 { useBindings } from "@opentui/keymap/solid" +import { createRoot, type JSX } from "solid-js" + +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 + prompt(options: { + title: string + description?: string + 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 } + /** 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 +} + +/* ─── v1 ─────────────────────────────────────────────────────────────────────────────────────── */ + +export function fromV1(api: TuiPluginApi): 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( + () => + api.ui.DialogSelect({ + title: options.title, + ...(options.placeholder ? { placeholder: options.placeholder } : {}), + ...(options.current !== undefined ? { current: options.current } : {}), + options: options.options as TuiDialogSelectOption[], + onSelect: (option: TuiDialogSelectOption) => { + api.ui.dialog.clear() + finish(option.value) + }, + } 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( + () => + api.ui.DialogPrompt({ + title: options.title, + ...(options.description ? { description: () => options.description } : {}), + placeholder: options.placeholder ?? "", + value: options.value ?? "", + onConfirm: (text: string) => { + api.ui.dialog.clear() + resolve(text) + }, + onCancel: () => { + api.ui.dialog.clear() + resolve(undefined) + }, + } as never), + () => resolve(undefined), + ) + }), + confirm: (options) => + new Promise((resolve) => { + api.ui.dialog.replace( + () => + api.ui.DialogConfirm({ + title: options.title, + message: options.message, + onConfirm: () => { + api.ui.dialog.clear() + resolve(true) + }, + onCancel: () => { + api.ui.dialog.clear() + resolve(false) + }, + } as never), + () => resolve(false), + ) + }), + }, + keymap: { + registerLayer: (layer) => api.keymap.registerLayer(layer), + useLayer: (layer) => useBindings(layer as never), + 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, + 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 session: { prompt?(input: unknown): 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"] +interface V2Theme { + text: { + base: Colour + muted: Colour + action: { primary: Colour; secondary: Colour } + feedback: { error: Colour; warning: Colour; success: Colour; info: Colour } + } + background: { base: Colour; raised: { base: Colour; high: Colour; max: Colour } } + border: { 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: Colour } + } + 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. + */ +export function themeFromV2(theme: () => V2Theme): Theme { + const map: Record Colour> = { + text: (t) => t.text.base, + textMuted: (t) => t.text.muted, + primary: (t) => t.text.action.primary, + secondary: (t) => t.text.action.secondary, + accent: (t) => t.text.action.primary, + 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, + borderSubtle: (t) => t.border.base, + borderActive: (t) => t.text.action.primary, + 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, + diffAddedLineNumberBg: (t) => t.diff.highlight.added, + diffRemovedLineNumberBg: (t) => t.diff.highlight.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, + } + return new Proxy({} as Theme, { + get: (_target, key) => { + if (typeof key !== "string") return undefined + const read = map[key] + if (read) return read(theme()) + /** markdownText, markdownHeading…: v2 keeps them under `markdown`. */ + if (key.startsWith("markdown")) { + const name = key.slice(8, 9).toLowerCase() + key.slice(9) + return theme().markdown[name] ?? theme().text.base + } + 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): 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 owns layers through the component that creates them: a root stands in, so it can be disposed. */ + const ownedLayer = (layer: Layer): (() => void) => + createRoot((dispose: () => void) => { + ctx.keymap.layer(() => layerToV2(layer)) + return 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: (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 }, + promptSession: async (sessionID, text) => { + await ctx.data.session.prompt?.({ sessionID, parts: [{ type: "text", text }] }) + }, + } +} + +/* ─── 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) { + return { + id, + tui: async (api: TuiPluginApi, options?: unknown) => { + await start(fromV1(api), options as Record | undefined) + }, + setup: async (ctx: V2Context) => { + const cleanups: (() => void)[] = [] + await start( + fromV2(ctx, (fn) => cleanups.push(fn)), + ctx.options as Record, + ) + return () => { + for (const fn of cleanups.reverse()) { + try { + fn() + } catch { + // one bay's cleanup failing must not keep the others from running + } + } + } + }, + } +} diff --git a/packages/client/test/host.test.ts b/packages/client/test/host.test.ts new file mode 100644 index 0000000..2310779 --- /dev/null +++ b/packages/client/test/host.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, test } from "bun:test" +import { dualTui, 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. + */ + +const colour = (name: string) => ({ name }) as never +const theme = { + text: { + base: colour("text"), + muted: colour("muted"), + action: { primary: colour("primary"), secondary: colour("secondary") }, + feedback: { + error: colour("error"), + warning: colour("warning"), + success: colour("success"), + info: colour("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") + expect(current.accent?.name).toBe("primary") + 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") + }) + 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 } }) + expect(calls.slot.map((claim) => (claim as { append: string }).append)).toEqual([ + "app", + "sidebar.content", + ]) + expect(cleanups).toHaveLength(2) + }) +}) + +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"]) + }) +}) From 7ad9c138a1d611ab8bbe61cdf7bd2e4f2e73985b Mon Sep 17 00:00:00 2001 From: Codestz Date: Wed, 23 Sep 2026 21:25:12 -0500 Subject: [PATCH 3/5] Every TUI bay runs on Host: one entry for OpenCode v1 and v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shell, Review, Statusline, Updater and the bundle start through dualTui. Component dialogs became host prompt/confirm/select; the console's keys a host-owned layer; Review's submit and Statusline's brief go through promptSession; Statusline reads v2 session data (cost, tokens, model limit). The Updater points at OpenCode 2's own plugin update. v1 answers a dialog before clearing it — clearing fires the close handler, which settled every prompt as cancelled. The smoke test takes OPENCODE. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/client/src/host.ts | 114 +++++++++----- packages/client/test/host.test.ts | 67 +++++++- packages/opencode/package.json | 3 +- packages/opencode/src/tui.ts | 18 +-- packages/review/src/tui/data/changes.ts | 4 +- packages/review/src/tui/index.tsx | 14 +- packages/review/src/tui/panel/actions.ts | 13 +- packages/review/src/tui/panel/keys.ts | 4 +- packages/review/src/tui/panel/paint.ts | 4 +- packages/review/src/tui/panel/queries.ts | 4 +- packages/review/src/tui/panel/trouble.ts | 4 +- packages/review/src/tui/view/dialogs.tsx | 146 +++++++++--------- packages/review/src/tui/view/overlay.tsx | 4 +- packages/shell/src/tui/components/badge.tsx | 4 +- packages/shell/src/tui/components/console.tsx | 25 +-- packages/shell/src/tui/components/dock.tsx | 4 +- packages/shell/src/tui/components/sidebar.tsx | 4 +- packages/shell/src/tui/dialogs.tsx | 138 ++++++++--------- packages/shell/src/tui/index.tsx | 33 ++-- packages/shell/src/tui/panel/keys.ts | 4 +- packages/shell/src/tui/panel/paint.ts | 4 +- packages/shell/src/tui/state/store.ts | 8 +- packages/shell/src/tui/view/overlay.tsx | 4 +- .../status/src/tui/components/statusline.tsx | 5 +- packages/status/src/tui/index.tsx | 44 +++--- packages/status/src/tui/state/snapshot.ts | 98 +++++++++++- packages/status/src/tui/state/store.ts | 10 +- packages/status/test/snapshot.test.ts | 10 +- packages/updater/src/tui/index.tsx | 40 +++-- scripts/tui-smoke.ts | 3 +- 30 files changed, 506 insertions(+), 331 deletions(-) diff --git a/packages/client/src/host.ts b/packages/client/src/host.ts index 12106eb..c4a23b5 100644 --- a/packages/client/src/host.ts +++ b/packages/client/src/host.ts @@ -14,7 +14,7 @@ import type { TuiDialogSelectOption, TuiPluginApi, TuiThemeCurrent } from "@opencode-ai/plugin/tui" import type { CliRenderer, KeyEvent } from "@opentui/core" import { useBindings } from "@opentui/keymap/solid" -import { createRoot, type JSX } from "solid-js" +import { createComponent, createRoot, type JSX } from "solid-js" type V1Layer = Parameters[0] export type Layer = V1Layer @@ -72,9 +72,14 @@ export interface Host { 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 @@ -98,6 +103,8 @@ export interface Host { 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 } /* ─── v1 ─────────────────────────────────────────────────────────────────────────────────────── */ @@ -115,16 +122,20 @@ export function fromV1(api: TuiPluginApi): Host { } api.ui.dialog.replace( () => - api.ui.DialogSelect({ - title: options.title, - ...(options.placeholder ? { placeholder: options.placeholder } : {}), - ...(options.current !== undefined ? { current: options.current } : {}), - options: options.options as TuiDialogSelectOption[], - onSelect: (option: TuiDialogSelectOption) => { - api.ui.dialog.clear() - finish(option.value) - }, - } as never), + 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), ) }) @@ -144,20 +155,28 @@ export function fromV1(api: TuiPluginApi): Host { new Promise((resolve) => { api.ui.dialog.replace( () => - api.ui.DialogPrompt({ - title: options.title, - ...(options.description ? { description: () => options.description } : {}), - placeholder: options.placeholder ?? "", - value: options.value ?? "", - onConfirm: (text: string) => { - api.ui.dialog.clear() - resolve(text) - }, - onCancel: () => { - api.ui.dialog.clear() - resolve(undefined) - }, - } as never), + 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), ) }), @@ -165,18 +184,21 @@ export function fromV1(api: TuiPluginApi): Host { new Promise((resolve) => { api.ui.dialog.replace( () => - api.ui.DialogConfirm({ - title: options.title, - message: options.message, - onConfirm: () => { - api.ui.dialog.clear() - resolve(true) - }, - onCancel: () => { - api.ui.dialog.clear() - resolve(false) - }, - } as never), + 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), ) }), @@ -215,8 +237,19 @@ export interface V2Context { 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 session: { prompt?(input: unknown): Promise } } readonly keymap: { layer(input: () => V2Layer): void @@ -477,7 +510,7 @@ export function fromV2(ctx: V2Context, onCleanup: (fn: () => void) => void): Hos }, }, select: (options) => ctx.ui.dialog.select(options), - prompt: (options) => ctx.ui.dialog.prompt(options), + prompt: ({ rich: _rich, ...options }) => ctx.ui.dialog.prompt(options), confirm: async (options) => (await ctx.ui.dialog.confirm(options)) === true, }, keymap: { @@ -513,8 +546,9 @@ export function fromV2(ctx: V2Context, onCleanup: (fn: () => void) => void): Hos }, lifecycle: { onDispose: onCleanup }, promptSession: async (sessionID, text) => { - await ctx.data.session.prompt?.({ sessionID, parts: [{ type: "text", text }] }) + await ctx.data.session.prompt?.({ sessionID, text }) }, + v2: ctx, } } diff --git a/packages/client/test/host.test.ts b/packages/client/test/host.test.ts index 2310779..51909de 100644 --- a/packages/client/test/host.test.ts +++ b/packages/client/test/host.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { dualTui, fromV2, layerToV2, themeFromV2, type V2Context } from "../src/host.ts" +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). @@ -199,3 +199,68 @@ describe("one entry for both", () => { 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() + }) +}) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 1e1e783..1159ac0 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -49,9 +49,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/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/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..91cd639 100644 --- a/packages/review/src/tui/index.tsx +++ b/packages/review/src/tui/index.tsx @@ -1,7 +1,8 @@ /** @jsxImportSource @opentui/solid */ -import { createBindingLookup, type TuiPlugin, type TuiPluginModule } from "@opencode-ai/plugin/tui" +import { createBindingLookup } from "@opencode-ai/plugin/tui" import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client/feature" +import { 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 +60,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) { @@ -310,8 +311,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 9300f14..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" @@ -29,7 +29,7 @@ export interface Boxes { } export interface PaintDeps { - api: TuiPluginApi + api: Host surface: Surface store: Store guard: Guard 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..31ae3ba 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 { 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..b9116ba 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. */ 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..c58f525 100644 --- a/packages/shell/src/tui/dialogs.tsx +++ b/packages/shell/src/tui/dialogs.tsx @@ -1,48 +1,41 @@ -/** @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((err) => { + api.ui.toast({ + variant: "error", + title: "Shell", + message: err instanceof Error ? err.message : String(err), + }) + }) + }) } -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() @@ -59,22 +52,21 @@ export function restartDaemon(api: TuiPluginApi, store: ShellStore) { .catch((err) => api.ui.toast({ variant: "error", title: "Shells", message: String(err) })) } 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 +95,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 +114,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 +136,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..0f66b2d 100644 --- a/packages/shell/src/tui/index.tsx +++ b/packages/shell/src/tui/index.tsx @@ -1,7 +1,8 @@ /** @jsxImportSource @opentui/solid */ -import { createBindingLookup, type TuiPlugin, type TuiPluginModule } from "@opencode-ai/plugin/tui" +import { createBindingLookup } from "@opencode-ai/plugin/tui" import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client" +import { dualTui, type Host } from "@opencode-cockpit/client/host" import type { BoxRenderable } from "@opentui/core" import { createSignal } from "solid-js" import { createClient } from "../connect.ts" @@ -33,8 +34,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,11 +48,11 @@ 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 config = loadConfig(api.state.path.directory, rawOptions) const options: ShellTuiOptions = config.ui ?? {} @@ -69,10 +70,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 +149,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. */ @@ -225,7 +219,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 @@ -434,5 +427,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/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 40d51a1..5bc0050 100644 --- a/packages/shell/src/tui/panel/paint.ts +++ b/packages/shell/src/tui/panel/paint.ts @@ -1,4 +1,4 @@ -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" @@ -14,7 +14,7 @@ export interface Boxes { } export interface PaintDeps { - api: TuiPluginApi + api: Host store: ShellStore surface: Surface feed: Feed 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/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..83d8c7b 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) { @@ -53,7 +53,7 @@ export function createStatusTui({ source = STATUS_PACKAGE }: { source?: string } moduleErrors.push(...loaded.errors) for (const error of loaded.errors) { 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 +148,23 @@ 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.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 +198,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(STATUS_PACKAGE, 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/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/updater/src/tui/index.tsx b/packages/updater/src/tui/index.tsx index b950c08..9ec972c 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,25 @@ export function createUpdaterTui({ source = UPDATER_PACKAGE }: { source?: string }) return } - api.lifecycle.onDispose(() => claim.release()) + host.lifecycle.onDispose(() => claim.release()) - api.keymap.registerLayer({ + /** + * OpenCode 2 checks and updates plugins itself (`opencode plugin check|update`), and resolves + * unpinned ones on start — the freeze this bay exists for does not happen there. The commands stay, + * so the habit still lands somewhere, and point at the host's own. + */ + const api = host.v1 + const open = api + ? () => openUpdater(api) + : () => + host.ui.toast({ + title: "Plugins", + message: + "OpenCode 2 updates plugins itself: run `opencode plugin check`, then `opencode plugin update`.", + duration: 10_000, + }) + + host.keymap.registerLayer({ commands: [ { name: "cockpit.updater.open", @@ -116,7 +133,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 +142,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 +157,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(UPDATER_PACKAGE, createUpdaterTui()) diff --git a/scripts/tui-smoke.ts b/scripts/tui-smoke.ts index 6048e26..b5918fc 100644 --- a/scripts/tui-smoke.ts +++ b/scripts/tui-smoke.ts @@ -16,7 +16,8 @@ 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) From c5c8bc20e35b0eb29375d94d3144b042f9073a05 Mon Sep 17 00:00:00 2001 From: Codestz Date: Thu, 24 Sep 2026 09:12:36 -0500 Subject: [PATCH 4/5] OpenCode 2: agent tools, logging, doctor and docs; one package for 1 and 2 Agent side - client/server: ServerHost, dualServer, composeParts. Shell and Review write their tools and hooks once; v1 gets hooks, v2 gets tool.transform, the "context" hook and the event stream. Tools keep v1's tool(); v2 is given their input-side JSON Schema and arguments are parsed here, so defaults apply on both. v1's preview call to setup is skipped. - Measured end to end: a real agent turn on v1 1.18.32 and v2 2.0.15 calls shell_start and review_list and is told the guidance. Fixes found on v2 - Review drew nothing: a dead titleColor prop blanked the overlay. - Theme: action and feedback tokens are a colour per state; accent and primary are palettes, not text.action (which is white). Mapping checked against both default themes, captured as fixtures. - Statusline modules outside a project: v2 words the resolve error differently, so the fallback never ran. Logging - client/log: one cockpit.log for both halves of every bay, levels, rotation, COCKPIT_DEBUG=1 (daemon too). Starts, errors with stacks, failed tools. First lines were lost before the cockpit home existed; tests no longer write the developer's log. Doctor - npx opencode-cockpit doctor: OpenCode version, config in both spellings, what last ran, recent errors, daemon, git/ps, settings. Prints the fix for the OpenCode installed; --json; exit 1 on failure. Node only. Docs and floor - Site: OpenCode 1 and 2 page, install for both, doctor page, troubleshooting around the log; landing install tab for v2. - Floor stays OpenCode 1.18.0: the full smoke passes on 1.18.0 and 1.18.28. - v2 known limits documented, shell_start permission first among them. - dev:install for testing a checkout; smoke covers full screen, plugin failures and (AGENT=1) a real agent turn. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 29 + README.md | 36 +- bun.lock | 4 + bunfig.toml | 1 + package.json | 3 +- packages/client/README.md | 9 + packages/client/package.json | 8 + packages/client/src/host.ts | 197 ++- packages/client/src/log.ts | 135 ++ packages/client/src/server.ts | 372 +++++ .../test/fixtures/v1-default-theme.json | 54 + .../test/fixtures/v2-default-theme.json | 1279 +++++++++++++++++ packages/client/test/host.test.ts | 69 +- packages/client/test/log.test.ts | 88 ++ packages/client/test/server.test.ts | 177 +++ packages/daemon/src/main.ts | 6 +- packages/opencode/README.md | 36 +- packages/opencode/package.json | 2 + packages/opencode/server.js | 6 + packages/opencode/src/bin.ts | 6 +- packages/opencode/src/compose.ts | 43 - packages/opencode/src/server.ts | 18 +- packages/opencode/test/bundle.test.ts | 33 - packages/opencode/tui.js | 6 + packages/review/LICENSE | 2 +- packages/review/README.md | 20 + packages/review/package.json | 2 + packages/review/server.js | 6 + packages/review/src/agent/plugin.ts | 56 +- packages/review/src/agent/tools/shared.ts | 2 - packages/review/src/server.ts | 2 +- packages/review/src/tui/index.tsx | 5 +- packages/review/src/tui/panel/trouble.ts | 9 +- packages/review/src/tui/view/overlay.tsx | 1 - packages/review/src/tui/view/pool.ts | 4 +- packages/review/test/agent/plugin.test.ts | 27 +- packages/review/tui.js | 6 + packages/shell/README.md | 11 +- packages/shell/package.json | 2 + packages/shell/server.js | 6 + packages/shell/src/agent/plugin.ts | 80 +- packages/shell/src/tui/dialogs.tsx | 10 +- packages/shell/src/tui/index.tsx | 20 +- packages/shell/src/tui/lib/trace.ts | 34 - packages/shell/tui.js | 6 + packages/status/README.md | 22 +- packages/status/package.json | 1 + packages/status/src/core/custom.ts | 8 +- packages/status/src/tui/index.tsx | 7 +- packages/status/test/custom.test.ts | 18 + packages/status/tui.js | 6 + packages/updater/README.md | 22 + packages/updater/package.json | 1 + packages/updater/src/cli/main.ts | 50 +- packages/updater/src/doctor/checks.ts | 364 +++++ packages/updater/src/doctor/gather.ts | 268 ++++ packages/updater/src/doctor/run.ts | 82 ++ packages/updater/src/tui/dialog.tsx | 21 +- packages/updater/src/tui/index.tsx | 11 +- packages/updater/test/doctor.test.ts | 313 ++++ packages/updater/tui.js | 6 + scripts/dev-install.ts | 68 + scripts/pack-check.ts | 10 +- scripts/set-version.ts | 4 +- scripts/test-env.ts | 8 + scripts/tui-smoke.ts | 148 +- site/astro.config.mjs | 2 + site/src/content/docs/help/doctor.md | 88 ++ site/src/content/docs/help/troubleshooting.md | 112 +- site/src/content/docs/start/install.md | 33 +- .../content/docs/start/opencode-versions.md | 88 ++ .../src/content/docs/start/what-cockpit-is.md | 4 +- site/src/data/landing.ts | 24 +- 73 files changed, 4371 insertions(+), 346 deletions(-) create mode 100644 packages/client/src/log.ts create mode 100644 packages/client/src/server.ts create mode 100644 packages/client/test/fixtures/v1-default-theme.json create mode 100644 packages/client/test/fixtures/v2-default-theme.json create mode 100644 packages/client/test/log.test.ts create mode 100644 packages/client/test/server.test.ts create mode 100644 packages/opencode/server.js delete mode 100644 packages/opencode/src/compose.ts create mode 100644 packages/opencode/tui.js create mode 100644 packages/review/server.js create mode 100644 packages/review/tui.js create mode 100644 packages/shell/server.js delete mode 100644 packages/shell/src/tui/lib/trace.ts create mode 100644 packages/shell/tui.js create mode 100644 packages/status/tui.js create mode 100644 packages/updater/src/doctor/checks.ts create mode 100644 packages/updater/src/doctor/gather.ts create mode 100644 packages/updater/src/doctor/run.ts create mode 100644 packages/updater/test/doctor.test.ts create mode 100644 packages/updater/tui.js create mode 100644 scripts/dev-install.ts create mode 100644 scripts/test-env.ts create mode 100644 site/src/content/docs/help/doctor.md create mode 100644 site/src/content/docs/start/opencode-versions.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 47f3429..ede3afb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,35 @@ 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 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 ef81025..50ec626 100644 --- a/bun.lock +++ b/bun.lock @@ -41,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 e20c36b..39b3a05 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -31,6 +31,14 @@ "./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": [ diff --git a/packages/client/src/host.ts b/packages/client/src/host.ts index c4a23b5..a9404db 100644 --- a/packages/client/src/host.ts +++ b/packages/client/src/host.ts @@ -13,8 +13,8 @@ import type { TuiDialogSelectOption, TuiPluginApi, TuiThemeCurrent } from "@opencode-ai/plugin/tui" import type { CliRenderer, KeyEvent } from "@opentui/core" -import { useBindings } from "@opentui/keymap/solid" -import { createComponent, createRoot, type JSX } from "solid-js" +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 @@ -99,6 +99,8 @@ export interface Host { 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. */ @@ -107,9 +109,47 @@ export interface Host { 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): Host { +export function fromV1(api: TuiPluginApi, log: Log = silentLog): Host { const pick = ( options: Parameters[0] & { options: SelectOption[] }, ): Promise => @@ -205,7 +245,7 @@ export function fromV1(api: TuiPluginApi): Host { }, keymap: { registerLayer: (layer) => api.keymap.registerLayer(layer), - useLayer: (layer) => useBindings(layer as never), + 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] }) @@ -214,6 +254,7 @@ export function fromV1(api: TuiPluginApi): Host { }, 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 }] }) }, @@ -289,21 +330,28 @@ export interface V2Context { } type Colour = TuiThemeCurrent["text"] +type States = { base: Colour } & Record interface V2Theme { text: { base: Colour muted: Colour - action: { primary: Colour; secondary: Colour } - feedback: { error: Colour; warning: Colour; success: Colour; info: 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: 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" @@ -341,13 +389,29 @@ interface V2Layer { * 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 { - const map: Record Colour> = { + /** + * 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) => t.text.action.primary, - secondary: (t) => t.text.action.secondary, - accent: (t) => t.text.action.primary, + 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, @@ -357,8 +421,11 @@ export function themeFromV2(theme: () => V2Theme): Theme { 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, - borderActive: (t) => t.text.action.primary, + /** 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, @@ -369,8 +436,9 @@ export function themeFromV2(theme: () => V2Theme): Theme { diffRemovedBg: (t) => t.diff.background.removed, diffContextBg: (t) => t.diff.background.context, diffLineNumber: (t) => t.diff.lineNumber.text, - diffAddedLineNumberBg: (t) => t.diff.highlight.added, - diffRemovedLineNumberBg: (t) => t.diff.highlight.removed, + /** 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, @@ -381,15 +449,31 @@ export function themeFromV2(theme: () => V2Theme): Theme { 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 read(theme()) + if (read) return colour(read(theme())) /** markdownText, markdownHeading…: v2 keeps them under `markdown`. */ if (key.startsWith("markdown")) { - const name = key.slice(8, 9).toLowerCase() + key.slice(9) - return theme().markdown[name] ?? theme().text.base + const name = MARKDOWN[key] ?? key.slice(8, 9).toLowerCase() + key.slice(9) + return colour(theme().markdown[name]) } return theme().text.base }, @@ -445,7 +529,7 @@ export function layerToV2(layer: Layer): V2Layer { } } -export function fromV2(ctx: V2Context, onCleanup: (fn: () => void) => void): Host { +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", { @@ -453,12 +537,42 @@ export function fromV2(ctx: V2Context, onCleanup: (fn: () => void) => void): Hos }) let depth = 0 - /** v2 owns layers through the component that creates them: a root stands in, so it can be disposed. */ - const ownedLayer = (layer: Layer): (() => void) => - createRoot((dispose: () => void) => { - ctx.keymap.layer(() => layerToV2(layer)) - return dispose - }) + /** + * 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, @@ -545,6 +659,7 @@ export function fromV2(ctx: V2Context, onCleanup: (fn: () => void) => void): Hos }, }, lifecycle: { onDispose: onCleanup }, + log, promptSession: async (sessionID, text) => { await ctx.data.session.prompt?.({ sessionID, text }) }, @@ -561,23 +676,45 @@ export type Start = (host: Host, options: Record | undefined) = * 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) => { - await start(fromV1(api), options as Record | undefined) + const host = fromV1(api, createLog("tui")) + await run(host, options as Record | undefined, api.app?.version) }, setup: async (ctx: V2Context) => { const cleanups: (() => void)[] = [] - await start( - fromV2(ctx, (fn) => cleanups.push(fn)), - ctx.options as Record, - ) + 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 { + } 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..74ecbfd --- /dev/null +++ b/packages/client/src/log.ts @@ -0,0 +1,135 @@ +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 +} + +/** Files already prepared in this process: once per file is enough. */ +const checked = new Set() + +/** + * 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 { + if (checked.has(file)) return + checked.add(file) + 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. + } + } + 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 index 51909de..482395f 100644 --- a/packages/client/test/host.test.ts +++ b/packages/client/test/host.test.ts @@ -7,17 +7,20 @@ import { dualTui, fromV1, fromV2, layerToV2, themeFromV2, type V2Context } from * OpenCode cannot show going wrong until someone's key does nothing. */ -const colour = (name: string) => ({ name }) as never +/** 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: colour("primary"), secondary: colour("secondary") }, + action: { primary: states("primary"), secondary: states("secondary") }, feedback: { - error: colour("error"), - warning: colour("warning"), - success: colour("success"), - info: colour("info"), + error: states("error"), + warning: states("warning"), + success: { base: colour("success"), muted: colour("success:muted") }, + info: states("info"), }, }, background: { @@ -137,13 +140,21 @@ describe("v2's token theme under v1's names", () => { test("maps each name a bay reads", () => { expect(current.text?.name).toBe("text") expect(current.textMuted?.name).toBe("muted") - expect(current.accent?.name).toBe("primary") + /** 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") }) @@ -174,11 +185,13 @@ describe("the v2 host", () => { 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(2) + expect(cleanups).toHaveLength(3) }) }) @@ -264,3 +277,43 @@ describe("the v1 host's dialogs", () => { 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..a745185 --- /dev/null +++ b/packages/client/test/log.test.ts @@ -0,0 +1,88 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { 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) + }) + + /** 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 1159ac0..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", 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/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/LICENSE b/packages/review/LICENSE index a7f72f2..e48d0fa 100644 --- a/packages/review/LICENSE +++ b/packages/review/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 Codestz. +Copyright (c) 2026 Codestz Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal 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/index.tsx b/packages/review/src/tui/index.tsx index 91cd639..1a2965e 100644 --- a/packages/review/src/tui/index.tsx +++ b/packages/review/src/tui/index.tsx @@ -1,8 +1,7 @@ /** @jsxImportSource @opentui/solid */ -import { createBindingLookup } from "@opencode-ai/plugin/tui" import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client/feature" -import { dualTui, type Host } from "@opencode-cockpit/client/host" +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" @@ -76,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") diff --git a/packages/review/src/tui/panel/trouble.ts b/packages/review/src/tui/panel/trouble.ts index 31ae3ba..aa84fc7 100644 --- a/packages/review/src/tui/panel/trouble.ts +++ b/packages/review/src/tui/panel/trouble.ts @@ -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/overlay.tsx b/packages/review/src/tui/view/overlay.tsx index b9116ba..f1c83ae 100644 --- a/packages/review/src/tui/view/overlay.tsx +++ b/packages/review/src/tui/view/overlay.tsx @@ -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 b53bb70..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 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/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/dialogs.tsx b/packages/shell/src/tui/dialogs.tsx index c58f525..49838a6 100644 --- a/packages/shell/src/tui/dialogs.tsx +++ b/packages/shell/src/tui/dialogs.tsx @@ -25,11 +25,12 @@ export function newShell(api: Host, store: ShellStore, open: (id?: string) => vo void store.refresh() open(info.id) }) - .catch((err) => { + .catch((error) => { + api.log.error("shell: start failed", { error }) api.ui.toast({ variant: "error", title: "Shell", - message: err instanceof Error ? err.message : String(err), + message: error instanceof Error ? error.message : String(error), }) }) }) @@ -49,7 +50,10 @@ export function restartDaemon(api: Host, 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) void api.ui diff --git a/packages/shell/src/tui/index.tsx b/packages/shell/src/tui/index.tsx index 0f66b2d..c480e6f 100644 --- a/packages/shell/src/tui/index.tsx +++ b/packages/shell/src/tui/index.tsx @@ -1,8 +1,7 @@ /** @jsxImportSource @opentui/solid */ -import { createBindingLookup } from "@opencode-ai/plugin/tui" import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client" -import { dualTui, type Host } from "@opencode-cockpit/client/host" +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" @@ -13,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" @@ -54,11 +52,12 @@ export function createShellTui({ source = SHELL_PACKAGE }: { source?: string } = const shellTui = async (api: Host, rawOptions?: unknown) => { // Settings come from the shared config file; plugin-entry options still win, flat or under "ui". + 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. @@ -168,7 +167,7 @@ const shellTui = async (api: Host, rawOptions?: unknown) => { 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 @@ -191,7 +190,7 @@ const shellTui = async (api: Host, rawOptions?: unknown) => { 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)) @@ -252,7 +251,7 @@ const shellTui = async (api: Host, rawOptions?: unknown) => { 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, @@ -333,7 +332,10 @@ const shellTui = async (api: Host, rawOptions?: unknown) => { 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) }) + }) }, }, { @@ -382,7 +384,7 @@ const shellTui = async (api: Host, rawOptions?: unknown) => { onReady={(parts) => { backdrop = parts.backdrop pool = createRowPool(parts.lines) - trace("full: mounted") + log.debug("full: mounted") draw() }} onScroll={(delta) => actions.scroll(delta)} 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/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 9010695..21147d9 100644 --- a/packages/status/README.md +++ b/packages/status/README.md @@ -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/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/index.tsx b/packages/status/src/tui/index.tsx index 83d8c7b..693b377 100644 --- a/packages/status/src/tui/index.tsx +++ b/packages/status/src/tui/index.tsx @@ -52,6 +52,7 @@ 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.v1?.client.app .log({ @@ -148,8 +149,10 @@ export function createStatusTui({ source = STATUS_PACKAGE }: { source?: string } * like a command that did nothing. */ setTimeout(() => { - const failed = () => + 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 @@ -199,4 +202,4 @@ export function createStatusTui({ source = STATUS_PACKAGE }: { source?: string } } /** One entry for both OpenCodes: v1 calls `tui`, v2 calls `setup` (docs/opencode/v2.md). */ -export default dualTui(STATUS_PACKAGE, createStatusTui()) +export default dualTui("opencode-cockpit.status", createStatusTui()) 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/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..8b4c286 --- /dev/null +++ b/packages/updater/src/doctor/checks.ts @@ -0,0 +1,364 @@ +/** + * 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)) + +// --------------------------------------------------------------------------------------------------- + +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) detail.push(`${entry.raw} (${entry.file})`) + + // The same bay twice: the bundle and a standalone package, in the same half. + for (const half of ["server", "tui"] as const) { + const here = found.filter((entry) => entry.half === half) + const bundle = here.some((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} is also inside ${BUNDLE}: remove one of them from ${entry.file}`) + } + } + } + + // OpenCode 1 loads each half from its own file; OpenCode 2 loads both from opencode.json. + if (!v2) { + const halves = (bay: string) => ({ + server: found.some((entry) => entry.half === "server" && bayOf(entry.name) === bay), + tui: found.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 9ec972c..93b2d60 100644 --- a/packages/updater/src/tui/index.tsx +++ b/packages/updater/src/tui/index.tsx @@ -110,9 +110,10 @@ export function createUpdaterTui({ source = UPDATER_PACKAGE }: { source?: string host.lifecycle.onDispose(() => claim.release()) /** - * OpenCode 2 checks and updates plugins itself (`opencode plugin check|update`), and resolves - * unpinned ones on start — the freeze this bay exists for does not happen there. The commands stay, - * so the habit still lands somewhere, and point at the host's own. + * 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 @@ -121,7 +122,7 @@ export function createUpdaterTui({ source = UPDATER_PACKAGE }: { source?: string host.ui.toast({ title: "Plugins", message: - "OpenCode 2 updates plugins itself: run `opencode plugin check`, then `opencode plugin update`.", + "On OpenCode 2, change the version in your opencode.json plugin entry, then restart OpenCode.", duration: 10_000, }) @@ -158,4 +159,4 @@ export function createUpdaterTui({ source = UPDATER_PACKAGE }: { source?: string } /** One entry for both OpenCodes: v1 calls `tui`, v2 calls `setup` (docs/opencode/v2.md). */ -export default dualTui(UPDATER_PACKAGE, createUpdaterTui()) +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..833967c --- /dev/null +++ b/packages/updater/test/doctor.test.ts @@ -0,0 +1,313 @@ +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") + }) + + 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 b5918fc..fb71d0d 100644 --- a/scripts/tui-smoke.ts +++ b/scripts/tui-smoke.ts @@ -23,6 +23,14 @@ if (!opencode) { 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") @@ -35,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 }) @@ -91,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 @@ -120,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, @@ -164,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]"], @@ -227,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)) @@ -241,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.", } From 47634bdd9a06112129cf6f58d40f7a42543a452c Mon Sep 17 00:00:00 2001 From: Codestz Date: Thu, 24 Sep 2026 12:29:59 -0500 Subject: [PATCH 5/5] Log rechecks its size while running; doctor reads files per OpenCode - log: the size and directory were checked once per process, so a long debug session grew the file without bound, and a cache cleared under a running OpenCode stopped the log until restart. Rechecked every 256 lines, and after a failed write. - doctor: OpenCode 1 never reads cli.json, so panels configured only there are a missing half; OpenCode 2 loads the interface from opencode.json too, so the bundle there and a bay in cli.json is the bay loaded twice. Entries in a file the installed OpenCode ignores are marked as such. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/client/src/log.ts | 18 ++++++++---- packages/client/test/log.test.ts | 23 ++++++++++++++- packages/updater/src/doctor/checks.ts | 36 +++++++++++++++++------ packages/updater/test/doctor.test.ts | 41 +++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 14 deletions(-) diff --git a/packages/client/src/log.ts b/packages/client/src/log.ts index 74ecbfd..0794ca7 100644 --- a/packages/client/src/log.ts +++ b/packages/client/src/log.ts @@ -62,8 +62,13 @@ function serialise(fields: Record | undefined): Record() +/** + * 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 @@ -71,8 +76,9 @@ const checked = new Set() * written to a directory that was not there yet, and lost. */ function prepare(file: string): void { - if (checked.has(file)) return - checked.add(file) + 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 { @@ -100,7 +106,9 @@ export function createLog(scope: string, options: LogOptions = {}): Log { 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. + // 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 { diff --git a/packages/client/test/log.test.ts b/packages/client/test/log.test.ts index a745185..523f557 100644 --- a/packages/client/test/log.test.ts +++ b/packages/client/test/log.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" -import { join } from "node:path" +import { dirname, join } from "node:path" import { createLog, levelFrom } from "../src/log.ts" /** @@ -63,6 +63,27 @@ describe("the shared log", () => { 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") diff --git a/packages/updater/src/doctor/checks.ts b/packages/updater/src/doctor/checks.ts index 8b4c286..8bff4a7 100644 --- a/packages/updater/src/doctor/checks.ts +++ b/packages/updater/src/doctor/checks.ts @@ -94,6 +94,18 @@ export function bayOf(name: string | undefined): Bay | "bundle" | 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 { @@ -158,17 +170,25 @@ export function checkConfig(facts: Facts): Check { } } - for (const entry of found) detail.push(`${entry.raw} (${entry.file})`) + 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, in the same half. - for (const half of ["server", "tui"] as const) { - const here = found.filter((entry) => entry.half === half) - const bundle = here.some((entry) => bayOf(entry.name) === "bundle") + /** + * 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} is also inside ${BUNDLE}: remove one of them from ${entry.file}`) + fix.push(`${entry.raw} (${entry.file}) is also inside ${BUNDLE} (${bundle.file}): remove one of them`) } } } @@ -176,8 +196,8 @@ export function checkConfig(facts: Facts): Check { // OpenCode 1 loads each half from its own file; OpenCode 2 loads both from opencode.json. if (!v2) { const halves = (bay: string) => ({ - server: found.some((entry) => entry.half === "server" && bayOf(entry.name) === bay), - tui: found.some((entry) => entry.half === "tui" && bayOf(entry.name) === bay), + 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) diff --git a/packages/updater/test/doctor.test.ts b/packages/updater/test/doctor.test.ts index 833967c..8f994b0 100644 --- a/packages/updater/test/doctor.test.ts +++ b/packages/updater/test/doctor.test.ts @@ -145,6 +145,47 @@ describe("the config", () => { 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({