Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 4 additions & 2 deletions docs/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
22 changes: 20 additions & 2 deletions src/sidebar.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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),
}));
},
});
Expand Down
124 changes: 96 additions & 28 deletions test/fixtures/sidebar-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RuntimeSnapshot>({});
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(); }

}
Loading