From 48f07f13f31f905b9076b0ced34556025017138c Mon Sep 17 00:00:00 2001 From: d3cker Date: Sun, 20 Sep 2026 21:21:14 +0200 Subject: [PATCH] fix: support OpenCode 2.0.10 sidebar theme tokens --- CHANGELOG.md | 6 ++ docs/installation.md | 9 ++- docs/runtime.md | 6 +- package-lock.json | 13 ++++ package.json | 1 + src/sidebar.ts | 22 +++++- test/fixtures/sidebar-render.ts | 124 ++++++++++++++++++++++++-------- 7 files changed, 146 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad35b50..fe930ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ include the full version, for example `## 0.7.0-beta.1`. ## Unreleased +### Fixed + +- Prevent the runtime sidebar from crashing on OpenCode 2.0.10 after semantic + theme tokens changed, including in folders without initialized automation. + Retain 2.0.6 theme support and test both SDK themes in the native renderer. + ## 0.6.6 ### Documentation diff --git a/docs/installation.md b/docs/installation.md index b063bdd..4dd3152 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -99,6 +99,8 @@ No package is published to npm; `private: true` blocks accidental publication. ## TUI rendering and source validation The runtime sidebar uses the host's OpenTUI/Solid APIs and semantic theme tokens. +It accepts both the 2.0.6 text tokens and the renamed 2.0.10 tokens, where +`base`/`muted` replace `default`/`subdued` and `text.status` is absent. The package declares OpenTUI and Solid peer dependencies; server entrypoint imports stay independent of renderer initialization. A sidebar update requires both the owner plugin's monitor RPC and an updated/reopened TUI. An older or unavailable @@ -107,9 +109,10 @@ the [OpenCode CLI plugin API](https://opencode.ai/v2/docs/build/plugins/cli/). For development, `npm ci` installs pinned renderer, theme and Bun test dependencies. `npm run check` includes `npm run test:tui`, which renders the actual sidebar using -Bun's native OpenTUI support and checks live updates, stale readings, tab selection -and a narrow layout. Bun is needed for this native renderer test, not for the -Node-based management CLI. Package validation still imports the server and TUI +Bun's native OpenTUI support with resolved themes from both OpenCode 2.0.6 and +2.0.10. It checks startup without a configured owner, unavailable status, live +updates, stale readings, tab selection and a narrow layout. Bun is needed for this +native renderer test, not for the Node-based management CLI. Package validation still imports the server and TUI entrypoints in an isolated Node installation without initializing a renderer. ## Testing and migration diff --git a/docs/runtime.md b/docs/runtime.md index 30b3329..f6bfd8f 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -430,8 +430,10 @@ scheduler jobs and truncates long labels/errors. `/bot` manages task sessions; The TUI and owner plugin must both contain the monitor API. With an older server, an unloaded/unconfigured owner, a direct worktree-only launch, or a failed RPC, -the panel reports unavailable status. Load the configured owner and update both -sides as needed; reopen TUI clients after installation. Monitoring uses the +the panel reports unavailable status. Opening an unconfigured folder does not +initialize automation. The panel supports both OpenCode 2.0.6 and 2.0.10 theme +structures, including while connecting or displaying unavailable status. Load the +configured owner and update both sides as needed; reopen TUI clients after installation. Monitoring uses the connected OpenCode client, so it also works with a remote service when the correct owner location and updated plugin are available there. Live worker/scan diagnostics reset when the owner is recreated; task checkpoints and scheduler history remain diff --git a/package-lock.json b/package-lock.json index ba864bc..81736bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@opencode/theme": "2.0.6", + "@opencode/theme-current": "npm:@opencode/theme@2.0.10", "@opentui/core": "0.5.10", "@opentui/solid": "0.5.10", "@types/node": "^22.0.0", @@ -2278,6 +2279,18 @@ "effect": "4.0.0-rc.112" } }, + "node_modules/@opencode/theme-current": { + "name": "@opencode/theme", + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@opencode/theme/-/theme-2.0.10.tgz", + "integrity": "sha512-gFh8TGM2oI2lllqc0g3pyEDNZNTll8+lYzWzncIZocoADcep6cmsRE3Lndx6biGvCfdtzL02akImttdbAGJ+Yw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@opentui/core": "0.5.10", + "effect": "4.0.0-rc.112" + } + }, "node_modules/@opencode/util": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@opencode/util/-/util-2.0.6.tgz", diff --git a/package.json b/package.json index 46a8da6..8bafd1d 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@opencode/theme": "2.0.6", + "@opencode/theme-current": "npm:@opencode/theme@2.0.10", "@opentui/core": "0.5.10", "@opentui/solid": "0.5.10", "@types/node": "^22.0.0", diff --git a/src/sidebar.ts b/src/sidebar.ts index 59c3771..a6c260c 100644 --- a/src/sidebar.ts +++ b/src/sidebar.ts @@ -1,11 +1,29 @@ +import type { RGBA } from "@opentui/core"; import type { Plugin } from "@opencode/plugin/tui"; import { jsx } from "@opentui/solid/jsx-runtime"; import { createSignal } from "solid-js"; import { GithubRpc, SchedulerRpc } from "./rpc.js"; import { RuntimePoller, runtimeLines, selectedTask, type RuntimeSnapshot } from "./runtime-panel.js"; +// OpenCode 2.0.10 renamed semantic text tokens and removed text.status. +// Keep the adapter structural so the pinned 2.0.6 SDK remains supported. +type SidebarTheme = { text: { + base?: RGBA; default?: RGBA; muted?: RGBA; subdued?: RGBA; + status?: { running?: RGBA }; + action?: { primary?: { base?: RGBA; default?: RGBA } }; + feedback?: { error?: { base?: RGBA; default?: RGBA }; warning?: { base?: RGBA; default?: RGBA } }; +} }; + +function sidebarColor(theme: SidebarTheme, tone: string | undefined) { + const text = theme.text; + const base = text.base ?? text.default; + if (tone === "error" || tone === "warning") return text.feedback?.[tone]?.base ?? text.feedback?.[tone]?.default ?? base; + if (tone === "heading") return text.action?.primary?.base ?? text.status?.running ?? base; + if (tone === "muted") return text.muted ?? text.subdued ?? base; + return base; +} + export function RuntimeSidebar(props: { context: Plugin.Context; snapshot: () => RuntimeSnapshot; now: () => number; sessionID: string }) { - const theme = props.context.theme; return jsx("box", { flexDirection: "column", marginTop: 1, flexShrink: 0, get children() { const snapshot = props.snapshot(); @@ -14,7 +32,7 @@ export function RuntimeSidebar(props: { context: Plugin.Context; snapshot: () => ? props.context.data.session.status(task.sessionID) : undefined; return runtimeLines(snapshot, props.now(), props.sessionID, sessionStatus).map(line => jsx("text", { content: line.text, wrapMode: "word", marginTop: line.tone === "heading" ? 1 : 0, - fg: line.tone === "error" ? theme.text.feedback.error.default : line.tone === "warning" ? theme.text.feedback.warning.default : line.tone === "heading" ? theme.text.status.running : line.tone === "muted" ? theme.text.subdued : theme.text.default, + fg: sidebarColor(props.context.theme, line.tone), })); }, }); diff --git a/test/fixtures/sidebar-render.ts b/test/fixtures/sidebar-render.ts index b5ff059..287a33d 100644 --- a/test/fixtures/sidebar-render.ts +++ b/test/fixtures/sidebar-render.ts @@ -3,33 +3,101 @@ import { writeFile } from "node:fs/promises"; import type { Plugin } from "@opencode/plugin/tui"; import { testRender } from "@opentui/solid"; import { createSignal } from "solid-js"; -import { RGBA } from "@opentui/core"; +import { DEFAULT_THEME, resolveThemeDocument } from "@opencode/theme/tui"; +import { migrateV1, resolveThemeDocument as resolveCurrentTheme } from "@opencode/theme-current/tui"; import { RuntimeSidebar } from "../../src/sidebar.js"; import type { RuntimeSnapshot } from "../../src/runtime-panel.js"; -const color = (hex: string) => RGBA.fromHex(hex); -const theme = { text: { default: color("#eeeeee"), subdued: color("#999999"), status: { running: color("#00d7af") }, feedback: { warning: { default: color("#ffaf00") }, error: { default: color("#ff5f5f") } } } }; -const snapshot: RuntimeSnapshot = { dispatcherAt: 10000, schedulerAt: 10000, - dispatcher: { ownerDirectory: "/repo", worker: "executing", activeTask: "owner/repo#18", scanning: true, - tasks: [{ key: "owner/repo#18", repo: "owner/repo", issueNumber: 18, round: 4, phase: "running", status: "ready", sessionReady: true, sessionID: "s", branch: "thirst-for-levels", baseBranch: "main", model: "deepseek/deepseek-v4", prNumber: 19, prState: "open", pendingFeedback: 1 }] }, - scheduler: [{ id: "github-issues", running: true, paused: false, nextAt: 12000, failures: 0 }], -}; -const context = { theme, data: { session: { get: () => ({}), status: () => "running" } } } as unknown as Plugin.Context; -const [state, setState] = createSignal(snapshot); -const [sessionID, setSession] = createSignal("s"); -const view = await testRender(() => RuntimeSidebar({ context, snapshot: state, now: () => 10000, get sessionID() { return sessionID(); } }), { width: 36, height: 38 }); -try { - await view.renderOnce(); - const frame = view.captureCharFrame(); - assert.match(frame, /BOT RUNTIME/); assert.match(frame, /Session execution/); - assert.match(frame, /Scheduler: Running/); assert.match(frame, /PR #19/); assert.match(frame, /\/botstatus/); - await writeFile(process.env.PANEL_FRAME ?? "/tmp/opencode-sidebar-frame.txt", frame); - setState({ ...snapshot, dispatcherError: "Disconnected", schedulerError: "Disconnected" }); - await view.renderOnce(); - assert.match(view.captureCharFrame(), /STALE \/ partial data/); - setState({ ...snapshot, dispatcher: { ...snapshot.dispatcher!, tasks: [...snapshot.dispatcher!.tasks, { key: "owner/repo#22", repo: "owner/repo", issueNumber: 22, round: 1, status: "waiting", phase: "running", sessionID: "other", sessionReady: true, question: "permission" }] } }); - setSession("other"); await view.renderOnce(); - assert.match(view.captureCharFrame(), /Waiting for permission/); - view.resize(28, 45); await view.renderOnce(); - assert.match(view.captureCharFrame(), /\/botstatus/); - console.log("Native sidebar render: running, stale, session switch and narrow layout passed"); -} finally { view.renderer.destroy(); } +const legacyTheme = resolveThemeDocument(DEFAULT_THEME, "dark"); +// Resolve through each real SDK instead of mocking its output token structure. +const currentTheme = resolveCurrentTheme(migrateV1({ theme: { + primary: "#aabbcc", + secondary: "#aabbcc", + accent: "#aabbcc", + error: "#aabbcc", + warning: "#aabbcc", + success: "#aabbcc", + info: "#aabbcc", + text: "#aabbcc", + textMuted: "#aabbcc", + background: "#aabbcc", + backgroundPanel: "#aabbcc", + backgroundElement: "#aabbcc", + border: "#aabbcc", + borderActive: "#aabbcc", + borderSubtle: "#aabbcc", + diffAdded: "#aabbcc", + diffRemoved: "#aabbcc", + diffContext: "#aabbcc", + diffHunkHeader: "#aabbcc", + diffHighlightAdded: "#aabbcc", + diffHighlightRemoved: "#aabbcc", + diffAddedBg: "#aabbcc", + diffRemovedBg: "#aabbcc", + diffContextBg: "#aabbcc", + diffLineNumber: "#aabbcc", + diffAddedLineNumberBg: "#aabbcc", + diffRemovedLineNumberBg: "#aabbcc", + markdownText: "#aabbcc", + markdownHeading: "#aabbcc", + markdownLink: "#aabbcc", + markdownLinkText: "#aabbcc", + markdownCode: "#aabbcc", + markdownBlockQuote: "#aabbcc", + markdownEmph: "#aabbcc", + markdownStrong: "#aabbcc", + markdownHorizontalRule: "#aabbcc", + markdownListItem: "#aabbcc", + markdownListEnumeration: "#aabbcc", + markdownImage: "#aabbcc", + markdownImageText: "#aabbcc", + markdownCodeBlock: "#aabbcc", + syntaxComment: "#aabbcc", + syntaxKeyword: "#aabbcc", + syntaxFunction: "#aabbcc", + syntaxVariable: "#aabbcc", + syntaxString: "#aabbcc", + syntaxNumber: "#aabbcc", + syntaxType: "#aabbcc", + syntaxOperator: "#aabbcc", + syntaxPunctuation: "#aabbcc", + } }), "dark"); +// OpenTUI may shut down the process after a render error. Never report success +// unless both complete render scenarios reached their final assertions. +let completed = 0; +process.on("exit", () => { if (completed !== 2) process.exitCode = 1; }); +for (const theme of [legacyTheme, currentTheme]) { + const snapshot: RuntimeSnapshot = { dispatcherAt: 10000, schedulerAt: 10000, + dispatcher: { ownerDirectory: "/repo", worker: "executing", activeTask: "owner/repo#18", scanning: true, + tasks: [{ key: "owner/repo#18", repo: "owner/repo", issueNumber: 18, round: 4, phase: "running", status: "ready", sessionReady: true, sessionID: "s", branch: "thirst-for-levels", baseBranch: "main", model: "deepseek/deepseek-v4", prNumber: 19, prState: "open", pendingFeedback: 1 }] }, + scheduler: [{ id: "github-issues", running: true, paused: false, nextAt: 12000, failures: 0 }], + }; + const context = { theme, data: { session: { get: () => ({}), status: () => "running" } } } as unknown as Plugin.Context; + const [state, setState] = createSignal({}); + const [sessionID, setSession] = createSignal("s"); + const view = await testRender(() => RuntimeSidebar({ context, snapshot: state, now: () => 10000, get sessionID() { return sessionID(); } }), { width: 36, height: 38 }); + try { + await view.renderOnce(); + assert.match(view.captureCharFrame(), /Connecting to owner/); + setState({ dispatcherError: "Unconfigured owner", schedulerError: "Unconfigured owner" }); + await view.renderOnce(); + assert.match(view.captureCharFrame(), /Dispatcher unavailable/); + assert.match(view.captureCharFrame(), /Scheduler unavailable/); + setState(snapshot); + await view.renderOnce(); + const frame = view.captureCharFrame(); + assert.match(frame, /BOT RUNTIME/); assert.match(frame, /Session execution/); + assert.match(frame, /Scheduler: Running/); assert.match(frame, /PR #19/); assert.match(frame, /\/botstatus/); + await writeFile(process.env.PANEL_FRAME ?? "/tmp/opencode-sidebar-frame.txt", frame); + setState({ ...snapshot, dispatcherError: "Disconnected", schedulerError: "Disconnected" }); + await view.renderOnce(); + assert.match(view.captureCharFrame(), /STALE \/ partial data/); + setState({ ...snapshot, dispatcher: { ...snapshot.dispatcher!, tasks: [...snapshot.dispatcher!.tasks, { key: "owner/repo#22", repo: "owner/repo", issueNumber: 22, round: 1, status: "waiting", phase: "running", sessionID: "other", sessionReady: true, question: "permission" }] } }); + setSession("other"); await view.renderOnce(); + assert.match(view.captureCharFrame(), /Waiting for permission/); + view.resize(28, 45); await view.renderOnce(); + assert.match(view.captureCharFrame(), /\/botstatus/); + completed++; + console.log("Native sidebar render: unconfigured, running, stale, session switch and narrow layout passed"); + } finally { view.renderer.destroy(); } + +}