opencode-goal-mode 0.3.0 → 0.3.2

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,32 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.3.2
4
+
5
+ - Only the `goal` agent is user-selectable. The structural validator now requires
6
+ every other agent to be `mode: subagent` (no `all`/extra `primary`), so the
7
+ specialist reviewers can only be invoked by the Goal agent via the task tool,
8
+ never picked by the user.
9
+ - Friendlier subagent names in the TUI: review-verdict toasts now read
10
+ "Security Reviewer → PASS" / "API Reviewer → PASS" instead of raw hyphenated ids
11
+ (`prettyAgentName` drops the `goal-` prefix, de-hyphenates, keeps acronyms).
12
+ - Release pipeline: a single `vX.Y.Z` tag push now publishes to npm AND creates
13
+ the matching GitHub Release (versions stay in sync). `publish:check` fails the
14
+ release if the tag does not match `package.json` or the version already exists.
15
+ - README: npm-first install instructions and a documented release flow.
16
+
17
+ ## v0.3.1
18
+
19
+ - Sidebar: when a task is running but no goal is set, show a clean grey `No goal`
20
+ (nothing else) instead of a blank/absent banner. New `sidebarMutedColor` option
21
+ (`GOAL_GUARD_SIDEBAR_MUTED_COLOR`, default `#808080`).
22
+ - `summary.sidebarView` now always returns a model (`{ hasGoal: false }` vs
23
+ `{ hasGoal: true, … }`) so the sidebar renders unconditionally.
24
+ - Add a headless visual test (`npm run test:visual`, `tools/visual-test/`) that
25
+ renders the real component with @opentui/solid and asserts text + exact colours
26
+ + bold attributes across goal / no-goal / ready / custom-colour / truncation /
27
+ disabled / no-API / resize scenarios. Excluded from the npm package and CI.
28
+ - Hardened the sidebar projection against malformed/partial persisted state.
29
+
3
30
  ## v0.3.0
4
31
 
5
32
  - Honest benchmarks: add an EXTERNAL corpus of 704 real third-party commands from
package/README.md CHANGED
@@ -90,7 +90,11 @@ second) — negligible for a per-tool-call guard:
90
90
  ## What it adds
91
91
 
92
92
  - A primary `goal` agent that owns implementation but delegates research,
93
- discovery, verification planning, and reviews to subagents.
93
+ discovery, verification planning, and reviews to subagents. **`goal` is the only
94
+ user-selectable agent** — every specialist (security, diff, verifier, …) is a
95
+ `mode: subagent` that the Goal agent invokes via the task tool; the user never
96
+ picks one directly. They surface with friendly names (e.g. "Security Reviewer",
97
+ "API Reviewer") rather than raw ids.
94
98
  - Strict review gates for prompt compliance, diff review, verification, security,
95
99
  UX, operations, data, API, performance, tests, docs, quality, and final audit.
96
100
  - Slash commands: `/goal`, `/goal-contract`, `/goal-review`,
@@ -111,8 +115,9 @@ second) — negligible for a per-tool-call guard:
111
115
  `goal_reviewer_memory`, `goal_status`, `goal_reset`.
112
116
  - **Live state injection** into the system prompt so the model always knows
113
117
  what the guard requires.
114
- - **TUI toasts**: a toast on each review verdict (PASS/FAIL) and a single
115
- "completion unlocked" toast the moment the last required gate clears.
118
+ - **TUI toasts**: a toast on each review verdict (PASS/FAIL), with the
119
+ reviewer's friendly name, and a single "completion unlocked" toast the moment
120
+ the last required gate clears.
116
121
  - An **experimental** companion TUI plugin (`plugins/goal-sidebar.js`) that shows
117
122
  the active goal as a shining-yellow banner in the sidebar with a compact gate
118
123
  status line. See [TUI integration](#tui-integration).
@@ -127,38 +132,53 @@ enforcement and writes its state to disk, and an experimental TUI plugin
127
132
 
128
133
  - **Sidebar goal banner (experimental).** The current goal renders in shining
129
134
  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.
135
+ dirty/ready` status line, and updates as reviews land. When a task is running
136
+ but **no goal is set**, it shows a clean grey `No goal` and nothing else. It
137
+ requires a TUI-plugin-capable OpenCode (one exposing `api.slots.register`); on
138
+ any older runtime it silently no-ops, so it can never break your TUI. Set
139
+ `sidebarBanner: false` (or `GOAL_GUARD_SIDEBAR_BANNER=0`) to disable,
140
+ `sidebarColor` to recolour the goal, or `sidebarMutedColor` for the "No goal"
141
+ line. It is rendered-and-asserted headlessly by the
142
+ [visual test](tools/visual-test/README.md) (`npm run test:visual`); still worth
143
+ a glance in your own TUI.
137
144
  - **Toasts.** Review verdicts and completion-unlock events surface as toasts
138
145
  (`toastOnReview`), and blocked destructive commands / premature completions
139
146
  toast as before (`toastOnBlock`).
140
147
 
141
- ## Install globally
148
+ ## Install
149
+
150
+ ### From npm (recommended)
142
151
 
143
152
  ```bash
144
- npm ci
145
- npm run validate
146
- npm run install:global
153
+ npm install -g opencode-goal-mode
154
+ opencode-goal-mode-install --global # installs into ~/.config/opencode
147
155
  ```
148
156
 
149
- Restart OpenCode after installation. OpenCode loads agents, commands, and
150
- plugins at startup.
157
+ Then restart OpenCode (it loads agents, commands, and plugins at startup). In the
158
+ agent picker you will see **only the `goal` agent** — the specialist reviewers are
159
+ subagents the Goal agent drives for you; they are never selectable by the user.
151
160
 
152
- ## Install into one project
161
+ Install into a single project instead of globally:
153
162
 
154
163
  ```bash
164
+ npm install -D opencode-goal-mode
165
+ npx opencode-goal-mode-install # writes to ./.opencode
166
+ ```
167
+
168
+ Upgrade later by re-running the same install command after `npm install -g
169
+ opencode-goal-mode@latest`; the installer replaces only the files it owns and
170
+ leaves your local edits alone (see [Installer options](#installer-options)).
171
+
172
+ ### From source
173
+
174
+ ```bash
175
+ git clone https://github.com/devinoldenburg/opencode-goal-mode
176
+ cd opencode-goal-mode
155
177
  npm ci
156
178
  npm run validate
157
- npm run install:local
179
+ npm run install:global # or: npm run install:local
158
180
  ```
159
181
 
160
- This writes to `./.opencode` in the current project.
161
-
162
182
  ## Installer options
163
183
 
164
184
  ```bash
@@ -202,6 +222,7 @@ Or via environment variables (`GOAL_GUARD_*`):
202
222
  | `toastOnReview` / `GOAL_GUARD_TOAST_ON_REVIEW` | `true` | Toast on each review verdict and when completion unlocks. |
203
223
  | `sidebarBanner` / `GOAL_GUARD_SIDEBAR_BANNER` | `true` | Show the experimental yellow goal banner in the TUI sidebar. |
204
224
  | `sidebarColor` / `GOAL_GUARD_SIDEBAR_COLOR` | `#FFD700` | Foreground colour of the sidebar goal banner. |
225
+ | `sidebarMutedColor` / `GOAL_GUARD_SIDEBAR_MUTED_COLOR` | `#808080` | Colour of the muted "No goal" line when no goal is set. |
205
226
 
206
227
  ## Custom tools
207
228
 
@@ -250,32 +271,34 @@ keeps read-only inspection from dirtying the session, preserves goal state durin
250
271
  compaction and across restarts, and blocks premature `Goal Completed` responses
251
272
  when review gates are missing or stale.
252
273
 
253
- ## npm publishing
274
+ ## Releasing
254
275
 
255
- Install from npm after the first publish:
276
+ Releases are fully automated and **version-synced**: one pushed tag publishes to
277
+ npm *and* creates the matching GitHub Release. The pipeline lives in
278
+ [`.github/workflows/publish.yml`](.github/workflows/publish.yml) (Node 24).
256
279
 
257
280
  ```bash
258
- npm install -g opencode-goal-mode
259
- opencode-goal-mode-install --global
281
+ npm version patch # bumps package.json + package-lock.json and creates the vX.Y.Z tag
282
+ git push --follow-tags # pushes main + the tag → the Release workflow runs
260
283
  ```
261
284
 
262
- Publishing is handled by `.github/workflows/publish.yml`, which runs on Node 24
263
- and publishes with the `NPM_TOKEN` repository secret. The workflow validates the
264
- package, checks the tag matches `package.json`, verifies the version is not
265
- already on npm, then publishes. Manual workflow dispatch defaults to
266
- `npm publish --dry-run`.
285
+ On a `vX.Y.Z` tag push the workflow:
267
286
 
268
- Release flow for a new version:
287
+ 1. installs and runs the full CI gate (`npm run ci` — tests, audit, structural
288
+ validation, `npm pack --dry-run`);
289
+ 2. runs `npm run publish:check`, which **fails if the tag does not match
290
+ `package.json`** or if that version already exists on npm;
291
+ 3. publishes with `npm publish --access public` using the `NPM_TOKEN` repository
292
+ secret;
293
+ 4. creates the GitHub Release for the tag with auto-generated notes.
269
294
 
270
- ```bash
271
- npm version patch
272
- git push --follow-tags
273
- ```
295
+ So the git tag, the `package.json` version, the npm version, and the GitHub
296
+ Release version are always identical. A manual `workflow_dispatch` is available
297
+ and defaults to a safe `npm publish --dry-run`.
274
298
 
275
- For a version that is already bumped and reviewed, commit the current tree, tag
276
- the reviewed version (for example `v0.2.4`), push the branch and tag, then create
277
- the GitHub Release. Ensure `NPM_TOKEN` has npm publish rights before publishing
278
- the release.
299
+ **One-time setup:** add a publish-scoped npm token as the `NPM_TOKEN` repository
300
+ secret (`gh secret set NPM_TOKEN`). Treat that token as sensitive never commit
301
+ it.
279
302
 
280
303
  ## Goal Completion Contract
281
304
 
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.2",
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",
@@ -130,3 +130,23 @@ export const CONTEXTUAL_GATES = Object.freeze({
130
130
 
131
131
  /** The reviewer that, when it returns a verdict, closes one review cycle. */
132
132
  export const CYCLE_CLOSING_AGENT = "goal-final-auditor";
133
+
134
+ /** Acronyms that should stay upper-case in display names. */
135
+ const ACRONYMS = new Set(["api", "ux", "ui", "sql", "ops", "qa"]);
136
+
137
+ /**
138
+ * Human-friendly display name for an agent id: drops the `goal-` namespace
139
+ * prefix, turns hyphens into spaces, Title-Cases words, and keeps known acronyms
140
+ * upper-case. e.g. "goal-security-reviewer" → "Security Reviewer",
141
+ * "goal-api-reviewer" → "API Reviewer", "goal-final-auditor" → "Final Auditor".
142
+ */
143
+ export function prettyAgentName(id) {
144
+ const raw = String(id || "").trim();
145
+ if (!raw) return "";
146
+ return raw
147
+ .replace(/^goal-/, "")
148
+ .split(/[-_\s]+/)
149
+ .filter(Boolean)
150
+ .map((w) => (ACRONYMS.has(w.toLowerCase()) ? w.toUpperCase() : w.charAt(0).toUpperCase() + w.slice(1)))
151
+ .join(" ");
152
+ }
@@ -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) {
@@ -23,7 +23,7 @@ import { createStore, createState } from "./goal-guard/state.js";
23
23
  import { createPersistence } from "./goal-guard/persistence.js";
24
24
  import { createLogger } from "./goal-guard/logger.js";
25
25
  import { analyzeCommand, looksLikeDestructiveBash, looksLikeMutatingBash, isVerification } from "./goal-guard/shell.js";
26
- import { isPrimaryAgent, isReviewAgent, CYCLE_CLOSING_AGENT } from "./goal-guard/agents.js";
26
+ import { isPrimaryAgent, isReviewAgent, CYCLE_CLOSING_AGENT, prettyAgentName } from "./goal-guard/agents.js";
27
27
  import { textOf, parseVerdict, recordVerdict } from "./goal-guard/verdicts.js";
28
28
  import { completionAllowed, missingGates, refreshStickyGates } from "./goal-guard/gates.js";
29
29
  import { evaluateCompletionClaim } from "./goal-guard/completion.js";
@@ -210,9 +210,9 @@ export function createGuard(input = {}, options = {}, overrides = {}) {
210
210
  // Surface review progress in the TUI: a toast per recorded verdict, and a
211
211
  // single celebratory toast the moment the last required gate clears.
212
212
  if (recordedAgent && recordedVerdict && config.toastOnReview) {
213
- logger.toast(`Goal Guard: ${recordedAgent} → ${recordedVerdict}`, recordedVerdict === "PASS" ? "success" : "warning");
213
+ logger.toast(`${prettyAgentName(recordedAgent)} → ${recordedVerdict}`, recordedVerdict === "PASS" ? "success" : "warning");
214
214
  if (!wasAllowed && completionAllowed(state, config)) {
215
- logger.toast("Goal Guard: all required gates passed — completion unlocked", "success");
215
+ logger.toast("All required gates passed — completion unlocked", "success");
216
216
  }
217
217
  }
218
218
  persist();
@@ -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
  },