opencode-goal-mode 0.3.0 → 0.3.1

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/ARCHITECTURE.md CHANGED
@@ -170,7 +170,9 @@ hooks still load.
170
170
  `{ tui }` module, mutually exclusive with `{ server }`). It registers a
171
171
  `sidebar_content` slot via `api.slots.register({ slots: { sidebar_content } })`
172
172
  and renders, in the configured colour (`#FFD700` by default), the short goal
173
- label plus a `passing/total gates · dirty/ready` line.
173
+ label plus a `passing/total gates · dirty/ready` line. It renders
174
+ unconditionally: when a task is running with no goal set, it shows a muted grey
175
+ `No goal` (`sidebarView` returns `{ hasGoal: false }`) rather than a blank slot.
174
176
 
175
177
  It is *paired* with the server plugin only through the persisted state file:
176
178
  `sidebar-data.js` recomputes the same `stateBaseDir`/`projectKey` path the guard
@@ -182,6 +184,12 @@ error degrades to rendering nothing — it can never break the TUI. The server p
182
184
  also emits review-verdict and completion-unlock toasts (`toastOnReview`) so review
183
185
  progress is visible even without the banner.
184
186
 
187
+ The JSX renderer is verified headlessly with `@opentui/solid`'s `testRender` in
188
+ `tools/visual-test/sidebar-visual.jsx` (`npm run test:visual`, needs Bun + the
189
+ OpenTUI stack): it asserts the rendered text, the exact foreground colours, and
190
+ the bold attribute for goal / "No goal" / ready states. That tool is excluded from
191
+ the npm package and from `node --test`/CI.
192
+
185
193
  ## Configuration
186
194
 
187
195
  `config.js` merges, in increasing precedence: built-in defaults, environment
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.3.1
4
+
5
+ - Sidebar: when a task is running but no goal is set, show a clean grey `No goal`
6
+ (nothing else) instead of a blank/absent banner. New `sidebarMutedColor` option
7
+ (`GOAL_GUARD_SIDEBAR_MUTED_COLOR`, default `#808080`).
8
+ - `summary.sidebarView` now always returns a model (`{ hasGoal: false }` vs
9
+ `{ hasGoal: true, … }`) so the sidebar renders unconditionally.
10
+ - Add a headless visual test (`npm run test:visual`, `tools/visual-test/`) that
11
+ renders the real component with @opentui/solid and asserts text + exact colours
12
+ + bold attributes across goal / no-goal / ready / custom-colour / truncation /
13
+ disabled / no-API / resize scenarios. Excluded from the npm package and CI.
14
+ - Hardened the sidebar projection against malformed/partial persisted state.
15
+
3
16
  ## v0.3.0
4
17
 
5
18
  - Honest benchmarks: add an EXTERNAL corpus of 704 real third-party commands from
package/README.md CHANGED
@@ -127,13 +127,15 @@ enforcement and writes its state to disk, and an experimental TUI plugin
127
127
 
128
128
  - **Sidebar goal banner (experimental).** The current goal renders in shining
129
129
  yellow in the sidebar (`sidebar_content` slot), with a `passing/total gates ·
130
- dirty/ready` status line, and updates as reviews land. It requires a
131
- TUI-plugin-capable OpenCode (one exposing `api.slots.register`); on any older
132
- runtime it silently no-ops, so it can never break your TUI. Set
133
- `sidebarBanner: false` (or `GOAL_GUARD_SIDEBAR_BANNER=0`) to disable, or
134
- `sidebarColor` to recolour it. Because no local environment can run OpenCode's
135
- TUI runtime, this banner is shipped best-effort and should be verified in your
136
- own TUI.
130
+ dirty/ready` status line, and updates as reviews land. When a task is running
131
+ but **no goal is set**, it shows a clean grey `No goal` and nothing else. It
132
+ requires a TUI-plugin-capable OpenCode (one exposing `api.slots.register`); on
133
+ any older runtime it silently no-ops, so it can never break your TUI. Set
134
+ `sidebarBanner: false` (or `GOAL_GUARD_SIDEBAR_BANNER=0`) to disable,
135
+ `sidebarColor` to recolour the goal, or `sidebarMutedColor` for the "No goal"
136
+ line. It is rendered-and-asserted headlessly by the
137
+ [visual test](tools/visual-test/README.md) (`npm run test:visual`); still worth
138
+ a glance in your own TUI.
137
139
  - **Toasts.** Review verdicts and completion-unlock events surface as toasts
138
140
  (`toastOnReview`), and blocked destructive commands / premature completions
139
141
  toast as before (`toastOnBlock`).
@@ -202,6 +204,7 @@ Or via environment variables (`GOAL_GUARD_*`):
202
204
  | `toastOnReview` / `GOAL_GUARD_TOAST_ON_REVIEW` | `true` | Toast on each review verdict and when completion unlocks. |
203
205
  | `sidebarBanner` / `GOAL_GUARD_SIDEBAR_BANNER` | `true` | Show the experimental yellow goal banner in the TUI sidebar. |
204
206
  | `sidebarColor` / `GOAL_GUARD_SIDEBAR_COLOR` | `#FFD700` | Foreground colour of the sidebar goal banner. |
207
+ | `sidebarMutedColor` / `GOAL_GUARD_SIDEBAR_MUTED_COLOR` | `#808080` | Colour of the muted "No goal" line when no goal is set. |
205
208
 
206
209
  ## Custom tools
207
210
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-goal-mode",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Strict Goal Mode agents, commands, and guard plugin for OpenCode.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -31,6 +31,7 @@
31
31
  "test:unit": "node --test tests/state.test.mjs tests/gates.test.mjs tests/verdicts.test.mjs tests/config.test.mjs tests/persistence.test.mjs",
32
32
  "test:agents": "node --test tests/agents.test.mjs tests/commands.test.mjs",
33
33
  "test:install": "node --test tests/install.test.mjs",
34
+ "test:visual": "bun tools/visual-test/sidebar-visual.jsx",
34
35
  "bench": "node benchmarks/run.mjs",
35
36
  "bench:external": "node benchmarks/external.mjs",
36
37
  "bench:corpus": "node benchmarks/build-external-corpus.mjs",
@@ -32,6 +32,8 @@ 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. */
36
+ sidebarMutedColor: "#808080",
35
37
  /** Phrase that, at the start of an assistant message, claims completion. */
36
38
  completionMarker: "Goal Completed",
37
39
  /** Replacement marker when completion is blocked. */
@@ -68,6 +70,7 @@ function fromEnv(env) {
68
70
  GOAL_GUARD_TOAST_ON_REVIEW: ["toastOnReview", coerceBool],
69
71
  GOAL_GUARD_SIDEBAR_BANNER: ["sidebarBanner", coerceBool],
70
72
  GOAL_GUARD_SIDEBAR_COLOR: ["sidebarColor", (v) => (v == null ? undefined : String(v))],
73
+ GOAL_GUARD_SIDEBAR_MUTED_COLOR: ["sidebarMutedColor", (v) => (v == null ? undefined : String(v))],
71
74
  };
72
75
  for (const [key, [field, coerce]] of Object.entries(map)) {
73
76
  if (env[key] !== undefined) out[field] = coerce(env[key], DEFAULT_CONFIG[field]);
@@ -13,7 +13,7 @@ import { readFileSync } from "node:fs";
13
13
  import { join } from "node:path";
14
14
  import { stateBaseDir, projectKey } from "./persistence.js";
15
15
  import { DEFAULT_CONFIG } from "./config.js";
16
- import { sidebarView } from "./summary.js";
16
+ import { sidebarView, NO_GOAL } from "./summary.js";
17
17
 
18
18
  /** Absolute path of the guard's state file for a given worktree. */
19
19
  export function sidebarStateFile(worktree, env = process.env) {
@@ -49,8 +49,10 @@ export function pickSession(snapshot, sessionId) {
49
49
  }
50
50
 
51
51
  /**
52
- * Build the sidebar banner model for a worktree, or null if there is nothing to
53
- * show. Returns { goal, status, allowed, } (see summary.sidebarView).
52
+ * Build the sidebar banner model for a worktree. ALWAYS returns an object so the
53
+ * sidebar renders unconditionally: `{ hasGoal: false }` when there is no state,
54
+ * no active session, or no goal (render a muted "No goal"); otherwise
55
+ * `{ hasGoal: true, goal, status, … }` (see summary.sidebarView).
54
56
  *
55
57
  * @param {object} opts
56
58
  * @param {string} opts.worktree Project worktree root (same key the guard uses).
@@ -63,9 +65,9 @@ export function readSidebarModel({ worktree, sessionId, config = DEFAULT_CONFIG,
63
65
  try {
64
66
  snapshot = JSON.parse(readFileSync(sidebarStateFile(worktree, env), "utf8"));
65
67
  } catch {
66
- return null; // no state yet, or unreadable — show nothing.
68
+ return NO_GOAL; // no state yet, or unreadable.
67
69
  }
68
70
  const record = pickSession(snapshot, sessionId);
69
- if (!record) return null;
71
+ if (!record) return NO_GOAL;
70
72
  return sidebarView(record, config);
71
73
  }
@@ -21,21 +21,25 @@ 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 });
26
+
24
27
  /**
25
- * Compact projection for the TUI sidebar banner: the short goal label, a
26
- * one-line gate/dirty status, and whether completion is currently allowed.
27
- * Returns null when there is no active goal worth showing.
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
32
  */
29
33
  export function sidebarView(state, config) {
30
- if (!state || !state.active) return null;
34
+ if (!state || !state.active) return NO_GOAL;
31
35
  const goal = shortGoalLabel(state);
32
- if (!goal) return null;
36
+ if (!goal) return NO_GOAL;
33
37
  const required = requiredGates(state, config);
34
38
  const missing = missingGates(state, config);
35
39
  const passing = required.length - missing.length;
36
40
  const allowed = required.length > 0 && missing.length === 0 && !state.dirty;
37
41
  const status = `${passing}/${required.length} gates` + (state.dirty ? " · dirty" : "") + (allowed ? " · ready" : "");
38
- return { goal, status, allowed, reviewCycles: state.reviewCycles, passing, required: required.length, dirty: Boolean(state.dirty) };
42
+ return { hasGoal: true, goal, status, allowed, reviewCycles: state.reviewCycles, passing, required: required.length, dirty: Boolean(state.dirty) };
39
43
  }
40
44
 
41
45
  export function summarizeState(state, config) {
@@ -28,10 +28,11 @@
28
28
  */
29
29
 
30
30
  import { createSignal, onCleanup, Show } from "solid-js";
31
- import { sidebarView } from "./goal-guard/summary.js";
31
+ import { sidebarView, NO_GOAL } from "./goal-guard/summary.js";
32
32
  import { DEFAULT_CONFIG } from "./goal-guard/config.js";
33
33
 
34
34
  const DEFAULT_COLOR = "#FFD700"; // shining yellow
35
+ const DEFAULT_MUTED = "#808080"; // clean grey for "No goal"
35
36
  const POLL_MS = 1500;
36
37
 
37
38
  function resolveOptions(options, env) {
@@ -41,7 +42,8 @@ function resolveOptions(options, env) {
41
42
  const disabled =
42
43
  enabledOpt === false || enabledEnv === "0" || enabledEnv === "false" || enabledEnv === "off";
43
44
  const color = options?.sidebarColor || e.GOAL_GUARD_SIDEBAR_COLOR || DEFAULT_COLOR;
44
- return { enabled: !disabled, color };
45
+ const muted = options?.sidebarMutedColor || e.GOAL_GUARD_SIDEBAR_MUTED_COLOR || DEFAULT_MUTED;
46
+ return { enabled: !disabled, color, muted };
45
47
  }
46
48
 
47
49
  /**
@@ -82,14 +84,14 @@ function pickSession(snapshot, sessionId) {
82
84
  }
83
85
 
84
86
  function readModel(worktree, sessionId) {
85
- const snapshot = readSnapshot(worktree);
86
- if (!snapshot) return null;
87
- const record = pickSession(snapshot, sessionId);
88
- if (!record) return null;
89
87
  try {
88
+ const snapshot = readSnapshot(worktree);
89
+ if (!snapshot) return NO_GOAL;
90
+ const record = pickSession(snapshot, sessionId);
91
+ if (!record) return NO_GOAL;
90
92
  return sidebarView(record, DEFAULT_CONFIG);
91
93
  } catch {
92
- return null;
94
+ return NO_GOAL;
93
95
  }
94
96
  }
95
97
 
@@ -98,7 +100,7 @@ export const id = "goal-mode-sidebar";
98
100
  /** @type {import("@opencode-ai/plugin/tui").TuiPlugin} */
99
101
  export const tui = async (api, options) => {
100
102
  try {
101
- const { enabled, color } = resolveOptions(options, typeof process !== "undefined" ? process.env : {});
103
+ const { enabled, color, muted } = resolveOptions(options, typeof process !== "undefined" ? process.env : {});
102
104
  if (!enabled) return;
103
105
  if (!api?.slots?.register) return; // runtime without the slot API → no-op.
104
106
 
@@ -110,25 +112,26 @@ export const tui = async (api, options) => {
110
112
  sidebar_content(_ctx, props) {
111
113
  const read = () => {
112
114
  try {
113
- return readModel(worktree, props?.session_id);
115
+ return readModel(worktree, props?.session_id) || NO_GOAL;
114
116
  } catch {
115
- return null;
117
+ return NO_GOAL;
116
118
  }
117
119
  };
118
120
  const [model, setModel] = createSignal(read());
119
121
  const timer = setInterval(() => setModel(read()), POLL_MS);
120
122
  onCleanup(() => clearInterval(timer));
123
+ // Always render: a muted "No goal" when none is set, the goal in colour otherwise.
121
124
  return (
122
- <Show when={model()}>
123
- <box flexDirection="column">
125
+ <box flexDirection="column">
126
+ <Show when={model().hasGoal} fallback={<text fg={muted}>No goal</text>}>
124
127
  <text fg={color}>
125
128
  {"◆ "}
126
129
  <b>GOAL</b>
127
130
  {` ${model().goal}`}
128
131
  </text>
129
132
  <text fg={color}>{model().status}</text>
130
- </box>
131
- </Show>
133
+ </Show>
134
+ </box>
132
135
  );
133
136
  },
134
137
  },