opencode-goal-mode 0.3.4 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.3.6
4
+
5
+ - **FIX: the sidebar never loaded.** TUI plugins are loaded from
6
+ `~/.config/opencode/tui.json` — NOT the `plugins/` dir (that is server plugins
7
+ only). The installer never created tui.json, so OpenCode never loaded the
8
+ sidebar (it only showed the session title + context). The installer now
9
+ registers the sidebar in `tui.json` (`plugin: ["opencode-goal-mode"]`,
10
+ merge-safe), and the package `main` points at the TUI plugin so OpenCode loads
11
+ it with its `@opentui/solid` runtime. Confirmed OpenCode reads tui.json.
12
+ - **Sidebar behaviour, as requested:** under the sidebar's content/"context"
13
+ area it now shows the goal with generated status text, colour-coded by
14
+ lifecycle: **yellow** while the goal is running, **red** when it is done (all
15
+ required gates pass, tree clean), and **grey "No goal available"** when a task
16
+ is running with no goal set. New `sidebarDoneColor` option
17
+ (`GOAL_GUARD_SIDEBAR_DONE_COLOR`, default `#FF5555`).
18
+ - `summary.sidebarView` now returns `{ state: "none"|"running"|"done", goal,
19
+ detail }`; the headless visual test renders and asserts all three states (text
20
+ + exact colours), 18/18.
21
+
22
+ ## v0.3.5
23
+
24
+ - Verified the package against the **current** OpenCode plugin API
25
+ (`@opencode-ai/plugin@1.17.6`, matching OpenCode 1.17.6, now the dev pin): all
26
+ guard hooks (`chat.message`/`params`, `tool.execute.before`/`after`,
27
+ `experimental.chat.system.transform`/`text.complete`/`session.compacting`)
28
+ exist; the guard plugin loads with zero errors; and in a real OpenCode the
29
+ agent list shows `goal` as the only user-selectable agent with 26 subagents and
30
+ reviewer `edit`/`task: deny` applied. The enforcement core is unchanged and
31
+ fully intact.
32
+ - Declared `@opentui/solid`, `solid-js`, `@opencode-ai/plugin` as **optional**
33
+ peer dependencies (the TUI runtime the sidebar uses).
34
+ - Docs: stated the sidebar's verification status honestly — the experimental TUI
35
+ banner is verified to load and to render in a real headless OpenTUI test, but
36
+ its live in-session render depends on your OpenCode build's file-based
37
+ TUI-plugin support; it never errors and never affects the enforcement core.
38
+
3
39
  ## v0.3.4
4
40
 
5
41
  Critical fixes found by testing against a real OpenCode (1.17.6) install — the
package/README.md CHANGED
@@ -18,6 +18,10 @@ npm install -g opencode-goal-mode && opencode-goal-mode-install --global
18
18
 
19
19
  ![OpenCode Goal Mode sidebar banner](docs/sidebar-demo.svg)
20
20
 
21
+ <sub>↑ Illustrative mockup of the **experimental** sidebar banner. The enforcement
22
+ core (guard + agents) is the verified product; the TUI sidebar is opt-in and its
23
+ live render depends on your OpenCode build — see [TUI integration](#tui-integration).</sub>
24
+
21
25
  **[Quick start](#quick-start) · [Install](#install) · [Why it's different](#why-its-different) · [Benchmarks](#benchmarks-honest-edition) · [TUI integration](#tui-integration) · [Configuration](#configuration) · [Releasing](#releasing) · [Architecture](ARCHITECTURE.md)**
22
26
 
23
27
  ## Quick start
@@ -41,8 +45,9 @@ opencode agent list | grep goal
41
45
  The `goal` agent writes a contract, delegates research/review to subagents, and
42
46
  **cannot** answer `Goal Completed` until every required review gate passes — the
43
47
  guard rewrites a premature claim to `Goal Not Completed`. Try a destructive
44
- command mid-session (e.g. `rm -rf build`) and watch it get blocked. The active
45
- goal shows in the sidebar in yellow.
48
+ command mid-session (e.g. `rm -rf build`) and watch it get blocked. If your
49
+ OpenCode build supports TUI plugins, the active goal also appears in the sidebar
50
+ in yellow (experimental — see [TUI integration](#tui-integration)).
46
51
 
47
52
  That's it. Everything below is detail.
48
53
 
@@ -171,17 +176,31 @@ Goal Mode is a **plugin pair**: the server-side `goal-guard` plugin owns
171
176
  enforcement and writes its state to disk, and an experimental TUI plugin
172
177
  (`plugins/goal-sidebar.js`) reads that same state to render a live banner.
173
178
 
174
- - **Sidebar goal banner (experimental).** The current goal renders in shining
175
- yellow in the sidebar (`sidebar_content` slot), with a `passing/total gates ·
176
- dirty/ready` status line, and updates as reviews land. When a task is running
177
- but **no goal is set**, it shows a clean grey `No goal` and nothing else. It
178
- requires a TUI-plugin-capable OpenCode (one exposing `api.slots.register`); on
179
- any older runtime it silently no-ops, so it can never break your TUI. Set
180
- `sidebarBanner: false` (or `GOAL_GUARD_SIDEBAR_BANNER=0`) to disable,
181
- `sidebarColor` to recolour the goal, or `sidebarMutedColor` for the "No goal"
182
- line. It is rendered-and-asserted headlessly by the
183
- [visual test](tools/visual-test/README.md) (`npm run test:visual`); still worth
184
- a glance in your own TUI.
179
+ - **Sidebar goal banner.** In the sidebar's content area, under the session
180
+ title/context, it shows the current goal with generated status text, colour-coded
181
+ by lifecycle:
182
+ - **yellow** — a goal is set and running (`◆ GOAL …` + `in progress · N/M gates`);
183
+ - **red** the goal is done (all required gates pass, tree clean: `✓ GOAL …` +
184
+ `completed · N/M gates passed · K review cycles`);
185
+ - **grey** — a task is running with no goal set (`No goal available`).
186
+
187
+ Toggle/recolour with `sidebarBanner`, `sidebarColor` (running), `sidebarDoneColor`
188
+ (done), `sidebarMutedColor` (no goal), or the `GOAL_GUARD_SIDEBAR_*` env vars.
189
+
190
+ **How it loads — important.** TUI plugins are **not** loaded from the `plugins/`
191
+ dir; OpenCode loads them from `~/.config/opencode/tui.json`. The installer writes
192
+ that for you (merge-safe):
193
+
194
+ ```json
195
+ { "$schema": "https://opencode.ai/tui.json", "plugin": ["opencode-goal-mode"] }
196
+ ```
197
+
198
+ Restart OpenCode after install so it picks up the TUI plugin (it resolves the
199
+ package and provides the `@opentui/solid` runtime). The banner appears in a
200
+ **session** view (not the home screen). The three states are rendered and
201
+ asserted — text + exact colours — by a real headless OpenTUI renderer in the
202
+ [visual test](tools/visual-test/README.md) (`npm run test:visual`, 18/18). The
203
+ enforcement core is a separate server plugin and works regardless of the sidebar.
185
204
  - **Toasts.** Review verdicts and completion-unlock events surface as toasts
186
205
  (`toastOnReview`), and blocked destructive commands / premature completions
187
206
  toast as before (`toastOnBlock`).
@@ -262,8 +281,9 @@ Or via environment variables (`GOAL_GUARD_*`):
262
281
  | `toastOnBlock` / `GOAL_GUARD_TOAST_ON_BLOCK` | `true` | Toast when something is blocked. |
263
282
  | `toastOnReview` / `GOAL_GUARD_TOAST_ON_REVIEW` | `true` | Toast on each review verdict and when completion unlocks. |
264
283
  | `sidebarBanner` / `GOAL_GUARD_SIDEBAR_BANNER` | `true` | Show the experimental yellow goal banner in the TUI sidebar. |
265
- | `sidebarColor` / `GOAL_GUARD_SIDEBAR_COLOR` | `#FFD700` | Foreground colour of the sidebar goal banner. |
266
- | `sidebarMutedColor` / `GOAL_GUARD_SIDEBAR_MUTED_COLOR` | `#808080` | Colour of the muted "No goal" line when no goal is set. |
284
+ | `sidebarColor` / `GOAL_GUARD_SIDEBAR_COLOR` | `#FFD700` | Colour of a **running** goal in the sidebar (yellow). |
285
+ | `sidebarDoneColor` / `GOAL_GUARD_SIDEBAR_DONE_COLOR` | `#FF5555` | Colour of a **done** goal in the sidebar (red). |
286
+ | `sidebarMutedColor` / `GOAL_GUARD_SIDEBAR_MUTED_COLOR` | `#808080` | Colour of the "No goal available" line (grey). |
267
287
 
268
288
  ## Custom tools
269
289
 
@@ -4,10 +4,10 @@
4
4
  <text x="20" y="47" font-size="12" fill="#656d76">Microseconds to classify one command. Both are negligible for a tool-call guard.</text>
5
5
  <text x="218" y="87" font-size="12" text-anchor="end" fill="#1f2328">Legacy regex guard</text>
6
6
  <rect x="230" y="70" width="420" height="22" rx="3" fill="#eaeef2"/>
7
- <rect x="230" y="70" width="212.3" height="22" rx="3" fill="#9aa0a6"/>
8
- <text x="450.3" y="87" font-size="12" font-weight="600" fill="#1f2328">0.81 µs</text>
7
+ <rect x="230" y="70" width="217.1" height="22" rx="3" fill="#9aa0a6"/>
8
+ <text x="455.1" y="87" font-size="12" font-weight="600" fill="#1f2328">0.75 µs</text>
9
9
  <text x="218" y="125" font-size="12" text-anchor="end" fill="#1f2328">Goal Mode analyzer</text>
10
10
  <rect x="230" y="108" width="420" height="22" rx="3" fill="#eaeef2"/>
11
11
  <rect x="230" y="108" width="300.0" height="22" rx="3" fill="#2da44e"/>
12
- <text x="538.0" y="125" font-size="12" font-weight="600" fill="#1f2328">1.14 µs</text>
12
+ <text x="538.0" y="125" font-size="12" font-weight="600" fill="#1f2328">1.03 µs</text>
13
13
  </svg>
@@ -75,8 +75,8 @@
75
75
  "safeFalsePos": 5
76
76
  }
77
77
  },
78
- "opsPerSec": 1238863,
79
- "usPerCommand": 0.81
78
+ "opsPerSec": 1341168,
79
+ "usPerCommand": 0.75
80
80
  },
81
81
  "current": {
82
82
  "detectionRate": 100,
@@ -111,8 +111,8 @@
111
111
  "safeFalsePos": 0
112
112
  }
113
113
  },
114
- "opsPerSec": 876672,
115
- "usPerCommand": 1.14
114
+ "opsPerSec": 970526,
115
+ "usPerCommand": 1.03
116
116
  }
117
117
  },
118
118
  "completionFixtures": {
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "opencode-goal-mode",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Strict Goal Mode agents, commands, and guard plugin for OpenCode.",
5
5
  "type": "module",
6
+ "main": "plugins/goal-sidebar.js",
6
7
  "engines": {
7
8
  "node": ">=20.11"
8
9
  },
@@ -82,8 +83,18 @@
82
83
  "registry": "https://registry.npmjs.org/"
83
84
  },
84
85
  "devDependencies": {
85
- "@opencode-ai/plugin": "1.15.13",
86
+ "@opencode-ai/plugin": "1.17.6",
86
87
  "fast-check": "^4.8.0",
87
88
  "yaml": "^2.6.1"
89
+ },
90
+ "peerDependencies": {
91
+ "@opencode-ai/plugin": ">=1.15.0",
92
+ "@opentui/solid": "*",
93
+ "solid-js": "*"
94
+ },
95
+ "peerDependenciesMeta": {
96
+ "@opencode-ai/plugin": { "optional": true },
97
+ "@opentui/solid": { "optional": true },
98
+ "solid-js": { "optional": true }
88
99
  }
89
100
  }
@@ -32,7 +32,9 @@ export const DEFAULT_CONFIG = Object.freeze({
32
32
  sidebarBanner: true,
33
33
  /** Foreground colour (hex) for the sidebar goal banner. */
34
34
  sidebarColor: "#FFD700",
35
- /** Foreground colour (hex) for the muted "No goal" sidebar line. */
35
+ /** Foreground colour (hex) for a completed goal in the sidebar (running → done turns yellow → red). */
36
+ sidebarDoneColor: "#FF5555",
37
+ /** Foreground colour (hex) for the muted "No goal available" sidebar line. */
36
38
  sidebarMutedColor: "#808080",
37
39
  /** Phrase that, at the start of an assistant message, claims completion. */
38
40
  completionMarker: "Goal Completed",
@@ -70,6 +72,7 @@ function fromEnv(env) {
70
72
  GOAL_GUARD_TOAST_ON_REVIEW: ["toastOnReview", coerceBool],
71
73
  GOAL_GUARD_SIDEBAR_BANNER: ["sidebarBanner", coerceBool],
72
74
  GOAL_GUARD_SIDEBAR_COLOR: ["sidebarColor", (v) => (v == null ? undefined : String(v))],
75
+ GOAL_GUARD_SIDEBAR_DONE_COLOR: ["sidebarDoneColor", (v) => (v == null ? undefined : String(v))],
73
76
  GOAL_GUARD_SIDEBAR_MUTED_COLOR: ["sidebarMutedColor", (v) => (v == null ? undefined : String(v))],
74
77
  };
75
78
  for (const [key, [field, coerce]] of Object.entries(map)) {
@@ -21,14 +21,18 @@ export function shortGoalLabel(state, max = 80) {
21
21
  return `${base.slice(0, max - 1).trimEnd()}…`;
22
22
  }
23
23
 
24
- /** Sentinel for "a task is running but no goal is set" — the sidebar shows a muted "No goal". */
25
- export const NO_GOAL = Object.freeze({ hasGoal: false });
24
+ /** Sentinel for "a task is running but no goal is set" — the sidebar shows a muted "No goal available". */
25
+ export const NO_GOAL = Object.freeze({ state: "none", goal: "", detail: "" });
26
26
 
27
27
  /**
28
- * Compact projection for the TUI sidebar banner. ALWAYS returns an object so the
29
- * sidebar can render unconditionally:
30
- * - `{ hasGoal: false }` when no active goal is set (render a muted "No goal").
31
- * - `{ hasGoal: true, goal, status, }` when a goal is active (render in colour).
28
+ * Compact projection for the TUI sidebar banner. ALWAYS returns an object with a
29
+ * three-way `state` so the sidebar renders unconditionally:
30
+ * - `state: "none"` no active goal: grey "No goal available".
31
+ * - `state: "running"` goal in progress: yellow, with a generated status line.
32
+ * - `state: "done"` → goal complete (all required gates pass, tree clean):
33
+ * red, with a generated completion line.
34
+ * `goal` is the short goal label; `detail` is generated descriptive text derived
35
+ * from the current goal's gate/cycle/dirty state.
32
36
  */
33
37
  export function sidebarView(state, config) {
34
38
  if (!state || !state.active) return NO_GOAL;
@@ -37,9 +41,30 @@ export function sidebarView(state, config) {
37
41
  const required = requiredGates(state, config);
38
42
  const missing = missingGates(state, config);
39
43
  const passing = required.length - missing.length;
40
- const allowed = required.length > 0 && missing.length === 0 && !state.dirty;
41
- const status = `${passing}/${required.length} gates` + (state.dirty ? " · dirty" : "") + (allowed ? " · ready" : "");
42
- return { hasGoal: true, goal, status, allowed, reviewCycles: state.reviewCycles, passing, required: required.length, dirty: Boolean(state.dirty) };
44
+ const cycles = Number(state.reviewCycles) || 0;
45
+ const done = required.length > 0 && missing.length === 0 && !state.dirty;
46
+ if (done) {
47
+ return {
48
+ state: "done",
49
+ goal,
50
+ detail: `completed · ${passing}/${required.length} gates passed · ${cycles} review cycle${cycles === 1 ? "" : "s"}`,
51
+ passing,
52
+ required: required.length,
53
+ reviewCycles: cycles,
54
+ };
55
+ }
56
+ const bits = [`${passing}/${required.length} gates`];
57
+ if (state.dirty) bits.push("changes pending");
58
+ if (cycles) bits.push(`cycle ${cycles}`);
59
+ return {
60
+ state: "running",
61
+ goal,
62
+ detail: `in progress · ${bits.join(" · ")}`,
63
+ passing,
64
+ required: required.length,
65
+ reviewCycles: cycles,
66
+ dirty: Boolean(state.dirty),
67
+ };
43
68
  }
44
69
 
45
70
  export function summarizeState(state, config) {
@@ -1,38 +1,37 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
  /**
3
- * Goal Mode — experimental TUI sidebar banner.
3
+ * Goal Mode — TUI sidebar goal banner.
4
4
  *
5
- * EXPERIMENTAL. This is a TUI plugin module (the companion to the server-side
6
- * goal-guard plugin). It renders the current goal as a short, shining-yellow
7
- * banner in the OpenCode sidebar, with a compact `passing/total gates ·
8
- * dirty/ready` status line, and updates as reviews land.
5
+ * This is a TUI plugin module (companion to the server-side goal-guard plugin).
6
+ * It renders, in the sidebar's content area, the current goal with generated
7
+ * status text and a colour that tracks the goal's lifecycle:
8
+ * - RUNNING (goal set, in progress) → yellow
9
+ * - DONE (all required gates pass, clean) → red
10
+ * - NONE (a task is running, no goal set) → grey "No goal available"
9
11
  *
10
- * It only does anything inside a TUI-plugin-capable OpenCode (one exposing
11
- * `api.slots.register`). On any older runtime, missing API, or render error it
12
- * silently no-ops — it can never break your TUI.
12
+ * IMPORTANT how OpenCode loads this: TUI plugins are NOT loaded from the
13
+ * regular `plugin` array / plugins dir (that is server plugins). They are listed
14
+ * in `~/.config/opencode/tui.json`:
15
+ * { "$schema": "https://opencode.ai/tui.json", "plugin": ["opencode-goal-mode"] }
16
+ * The installer writes that automatically. OpenCode then loads this module
17
+ * (the package `main`) and provides the `@opentui/solid` + `solid-js` runtime
18
+ * (declared here as peer deps), so its slot shares OpenCode's renderer.
13
19
  *
14
- * Pairing: it reads the SAME on-disk snapshot the goal-guard server plugin
15
- * writes (see goal-guard/persistence.js), so the two stay in sync with no extra
16
- * IPC. The pure projection (`summary.sidebarView`) is shared with the server
17
- * plugin and unit-tested via goal-guard/sidebar-data.js; only the file read and
18
- * state-path computation are reimplemented here.
19
- *
20
- * Runtime constraints (mirrored from working OpenCode TUI plugins):
21
- * - TUI plugin modules export `export default { id, tui }`.
22
- * - The Bun TUI plugin runtime does NOT support top-level ESM imports of Node
23
- * built-ins, so `node:fs`/`node:path`/`node:os`/`node:crypto` are `require()`d
24
- * lazily inside functions. Top-level imports of regular packages (solid-js)
25
- * and of our Node-built-in-free local modules are fine.
26
- * - This file uses Solid/opentui JSX and is loaded only by OpenCode's (Bun) TUI
27
- * runtime, which transpiles it; it is never imported by the Node test suite.
20
+ * Runtime constraints (from working OpenCode TUI plugins):
21
+ * - the module exports a single `export default { id, tui }`;
22
+ * - the Bun TUI runtime does not support top-level ESM imports of Node built-ins,
23
+ * so node:fs/path/os/crypto are require()d lazily inside functions;
24
+ * - it is never imported by the Node test suite (the pure projection it uses,
25
+ * summary.sidebarView, is tested via goal-guard/sidebar-data.js).
28
26
  */
29
27
 
30
28
  import { createSignal, onCleanup, Show } from "solid-js";
31
29
  import { sidebarView, NO_GOAL } from "./goal-guard/summary.js";
32
30
  import { DEFAULT_CONFIG } from "./goal-guard/config.js";
33
31
 
34
- const DEFAULT_COLOR = "#FFD700"; // shining yellow
35
- const DEFAULT_MUTED = "#808080"; // clean grey for "No goal"
32
+ const DEFAULT_COLOR = "#FFD700"; // shining yellow — running
33
+ const DEFAULT_DONE = "#FF5555"; // red completed
34
+ const DEFAULT_MUTED = "#808080"; // grey — no goal
36
35
  const POLL_MS = 1500;
37
36
 
38
37
  function resolveOptions(options, env) {
@@ -41,16 +40,15 @@ function resolveOptions(options, env) {
41
40
  const enabledEnv = e.GOAL_GUARD_SIDEBAR_BANNER;
42
41
  const disabled =
43
42
  enabledOpt === false || enabledEnv === "0" || enabledEnv === "false" || enabledEnv === "off";
44
- const color = options?.sidebarColor || e.GOAL_GUARD_SIDEBAR_COLOR || DEFAULT_COLOR;
45
- const muted = options?.sidebarMutedColor || e.GOAL_GUARD_SIDEBAR_MUTED_COLOR || DEFAULT_MUTED;
46
- return { enabled: !disabled, color, muted };
43
+ return {
44
+ enabled: !disabled,
45
+ color: options?.sidebarColor || e.GOAL_GUARD_SIDEBAR_COLOR || DEFAULT_COLOR,
46
+ doneColor: options?.sidebarDoneColor || e.GOAL_GUARD_SIDEBAR_DONE_COLOR || DEFAULT_DONE,
47
+ muted: options?.sidebarMutedColor || e.GOAL_GUARD_SIDEBAR_MUTED_COLOR || DEFAULT_MUTED,
48
+ };
47
49
  }
48
50
 
49
- /**
50
- * Read the guard's persisted snapshot for a worktree. The state-path logic is
51
- * kept identical to goal-guard/persistence.js (stateBaseDir + projectKey); node
52
- * built-ins are required lazily to satisfy the TUI runtime.
53
- */
51
+ /** Read the guard's persisted snapshot for a worktree (path logic mirrors persistence.js). */
54
52
  function readSnapshot(worktree) {
55
53
  try {
56
54
  const fs = require("node:fs");
@@ -100,7 +98,7 @@ const id = "goal-mode-sidebar";
100
98
  /** @type {import("@opencode-ai/plugin/tui").TuiPlugin} */
101
99
  const tui = async (api, options) => {
102
100
  try {
103
- const { enabled, color, muted } = resolveOptions(options, typeof process !== "undefined" ? process.env : {});
101
+ const { enabled, color, doneColor, muted } = resolveOptions(options, typeof process !== "undefined" ? process.env : {});
104
102
  if (!enabled) return;
105
103
  if (!api?.slots?.register) return; // runtime without the slot API → no-op.
106
104
 
@@ -120,16 +118,21 @@ const tui = async (api, options) => {
120
118
  const [model, setModel] = createSignal(read());
121
119
  const timer = setInterval(() => setModel(read()), POLL_MS);
122
120
  onCleanup(() => clearInterval(timer));
123
- // Always render: a muted "No goal" when none is set, the goal in colour otherwise.
121
+ const fg = () => (model().state === "done" ? doneColor : color);
122
+ // Always render: grey "No goal available" when none, else the goal in
123
+ // yellow (running) / red (done) with generated status text.
124
124
  return (
125
- <box flexDirection="column">
126
- <Show when={model().hasGoal} fallback={<text fg={muted}>No goal</text>}>
127
- <text fg={color}>
128
- {"◆ "}
125
+ <box flexDirection="column" paddingTop={1}>
126
+ <Show
127
+ when={model().state !== "none"}
128
+ fallback={<text fg={muted}>No goal available</text>}
129
+ >
130
+ <text fg={fg()}>
131
+ {model().state === "done" ? "✓ " : "◆ "}
129
132
  <b>GOAL</b>
130
133
  {` ${model().goal}`}
131
134
  </text>
132
- <text fg={color}>{model().status}</text>
135
+ <text fg={fg()}>{model().detail}</text>
133
136
  </Show>
134
137
  </box>
135
138
  );
@@ -125,6 +125,36 @@ function loadManifest() {
125
125
  }
126
126
  }
127
127
 
128
+ /**
129
+ * Register (or remove) the TUI sidebar plugin in `<target>/tui.json`.
130
+ *
131
+ * TUI plugins are NOT loaded from the `plugins/` dir (that path is for server
132
+ * plugins); OpenCode loads them only from tui.json. We reference the published
133
+ * npm package by name so OpenCode resolves it with its `@opentui/solid` runtime.
134
+ * Merge-safe: preserves any existing entries and only touches our own.
135
+ */
136
+ const TUI_PLUGIN_SPEC = pkg.name;
137
+ function ensureTuiPlugin(remove = false) {
138
+ const tuiPath = join(target, "tui.json");
139
+ let data = { $schema: "https://opencode.ai/tui.json", plugin: [] };
140
+ try {
141
+ const existing = JSON.parse(readFileSync(tuiPath, "utf8"));
142
+ if (existing && typeof existing === "object") data = existing;
143
+ } catch {
144
+ /* missing or invalid → start fresh */
145
+ }
146
+ if (!Array.isArray(data.plugin)) data.plugin = [];
147
+ const has = data.plugin.includes(TUI_PLUGIN_SPEC);
148
+ if (remove ? !has : has) return false;
149
+ data.plugin = remove ? data.plugin.filter((p) => p !== TUI_PLUGIN_SPEC) : [...data.plugin, TUI_PLUGIN_SPEC];
150
+ if (!data.$schema) data.$schema = "https://opencode.ai/tui.json";
151
+ if (!values["dry-run"]) {
152
+ mkdirSync(target, { recursive: true });
153
+ writeFileSync(tuiPath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
154
+ }
155
+ return true;
156
+ }
157
+
128
158
  // ---------------------------------------------------------------------------
129
159
  // Uninstall
130
160
  // ---------------------------------------------------------------------------
@@ -144,7 +174,9 @@ if (values.uninstall) {
144
174
  }
145
175
  }
146
176
  if (!values["dry-run"] && existsSync(manifestPath)) rmSync(manifestPath, { force: true });
177
+ const tuiRemoved = ensureTuiPlugin(true);
147
178
  if (!values["dry-run"]) pruneEmptyDirs(target, Object.keys(manifest.files));
179
+ if (tuiRemoved) console.log(`${values["dry-run"] ? "Would remove" : "Removed"} the sidebar entry from ${join(target, "tui.json")}`);
148
180
  const verb = values["dry-run"] ? "Would remove" : "Removed";
149
181
  console.log(`${verb} ${removed.length} Goal Mode files from ${target}.`);
150
182
  if (kept.length) {
@@ -234,9 +266,12 @@ if (!values["dry-run"]) {
234
266
  writeFileSync(manifestPath, JSON.stringify({ version: pkg.version, files: newManifestFiles }, null, 2), "utf8");
235
267
  }
236
268
 
269
+ const tuiAdded = ensureTuiPlugin(false);
270
+
237
271
  const verb = values["dry-run"] ? "Would install" : "Installed";
238
272
  console.log(`${verb} OpenCode Goal Mode ${pkg.version} into ${target}`);
239
273
  console.log(
240
274
  `Files copied: ${summary.copied.length}; unchanged: ${summary.unchanged.length}; pruned: ${summary.pruned.length}`,
241
275
  );
276
+ if (tuiAdded) console.log(`Registered the experimental sidebar in ${join(target, "tui.json")}`);
242
277
  console.log("Restart OpenCode for agents, commands, and plugins to load.");