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
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ include the full version, for example `## 0.7.0-beta.1`.

## Unreleased

### Documentation

- Record OpenCode 2.0.15 as the last tested version in README, with the verified
compatibility scope and the distinction between devel and stable v0.6.6.

### 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 Expand Up @@ -160,4 +171,4 @@ include the full version, for example `## 0.7.0-beta.1`.
- Add detailed bot workflow diagrams and a documentation map in `AGENTS.md`.
- Expand bundled bot instructions for planning, delegation, verification, and
handing publication back to the dispatcher.
- Add the README banner showing an OpenCode2 agent executing a task.
- Add the README banner showing an OpenCode2 agent executing a task.
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,19 @@ titles, and can merge after an authorized approval. The TUI is optional.

## Requirements

- OpenCode **2.0.6** with a working model; client/plugin SDK pinned to `2.0.6`.
- OpenCode **2**, last tested with **2.0.15**; a working model is required.
Client/plugin SDK dependencies remain pinned to `2.0.6`.
- Node.js 22+, npm, and Git on macOS/Linux.
- GitHub authentication (`gh auth login` and `gh auth setup-git`, or a token in
the service environment) and permission to comment, push, and create PRs.
- When configuring a project: a primary Git checkout with a GitHub `origin`,
a pushed commit, and issues enabled.

Compatibility checks for the current `devel` build covered SDK type checking,
native sidebar rendering with the 2.0.15 theme, and live session/status RPCs and
GitHub polling. They did not include a new issue-to-PR execution. The sidebar fix
required by newer themes is on `devel` and is not included in stable `v0.6.6`.

Choose **one** installation method below. No target repository is needed yet.
Nothing needs to be published to npm. `$HOME` expands to your home directory.

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