@prevalentware/opencode-goal-plugin 0.1.3 → 0.1.4

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/README.md CHANGED
@@ -7,8 +7,10 @@ This plugin adds:
7
7
  - `/goal` in the OpenCode TUI.
8
8
  - A sidebar goal indicator with status, elapsed time, token usage, remaining budget, and objective.
9
9
  - Agent tools: `get_goal`, `create_goal`, `update_goal`, and `clear_goal`.
10
+ - Goal close evidence: `complete` requires verified evidence, and `unmet` requires a concrete blocker.
10
11
  - Persistent per-session goal state.
11
12
  - Optional automatic continuation on `session.idle`.
13
+ - Compaction context so active goals are preserved when OpenCode summarizes a long session.
12
14
 
13
15
  ## Install
14
16
 
@@ -71,6 +73,25 @@ Defaults:
71
73
  - `max_auto_turns`: `25`
72
74
  - `min_continue_interval_seconds`: `3`
73
75
 
76
+ ## Goal Workflow
77
+
78
+ Use `/goal` from an OpenCode TUI session to set, refresh, or clear the goal. New goals support budget presets:
79
+
80
+ - No budget
81
+ - `250K`
82
+ - `1M`
83
+ - `2M`
84
+ - Custom positive integer
85
+
86
+ When setting the objective, include the scope, non-goals, and verification path when they matter. The agent is reminded to audit real files, command output, tests, or PR state before closing the goal.
87
+
88
+ The `update_goal` tool can close a goal in two ways:
89
+
90
+ - `status: "complete"` with `evidence` when every requirement is actually achieved.
91
+ - `status: "unmet"` with `blocker` when the objective cannot be achieved or is blocked by missing external input.
92
+
93
+ Budget exhaustion does not close a goal by itself. It only marks the goal `budgetLimited` and asks the agent to wrap up with remaining work or blockers.
94
+
74
95
  ## State
75
96
 
76
97
  Goal state is stored at:
@@ -124,4 +145,4 @@ OpenCode plugin modules are target-specific. This package exports separate modul
124
145
  }
125
146
  ```
126
147
 
127
- Codex goal mode has deeper runtime integration for exact token accounting and thread lifecycle control. This plugin implements the same workflow using OpenCode plugin hooks, so token usage is estimated from message text and continuation is driven by OpenCode's `session.idle` event.
148
+ Codex goal mode has deeper runtime integration for thread lifecycle control. This plugin implements the same workflow using OpenCode plugin hooks. Token usage is read from OpenCode step-finish usage when available and falls back to message token metadata or text estimation when exact usage is unavailable. Continuation is driven by OpenCode's `session.idle` event.
package/dist/server.js CHANGED
@@ -60,6 +60,17 @@ function validateBudget(tokenBudget) {
60
60
  }
61
61
  return tokenBudget;
62
62
  }
63
+ function validateEvidence(evidence, label) {
64
+ const value = evidence?.trim();
65
+ if (!value)
66
+ throw new Error(`${label} must not be empty`);
67
+ if ([...value].length > 4000)
68
+ throw new Error(`${label} must be at most 4000 characters`);
69
+ return value;
70
+ }
71
+ function isClosed(status) {
72
+ return status === "complete" || status === "unmet";
73
+ }
63
74
  function snapshot(goal) {
64
75
  const activeSeconds = goal.status === "active" && goal.lastAccountedAt != null ? Math.max(0, nowSeconds() - goal.lastAccountedAt) : 0;
65
76
  const timeUsedSeconds = goal.timeUsedSeconds + activeSeconds;
@@ -72,6 +83,9 @@ function snapshot(goal) {
72
83
  timeUsedSeconds,
73
84
  createdAt: goal.createdAt,
74
85
  updatedAt: goal.updatedAt,
86
+ completionEvidence: goal.completionEvidence ?? null,
87
+ blocker: goal.blocker ?? null,
88
+ closedAt: goal.closedAt ?? null,
75
89
  remainingTokens: goal.tokenBudget == null ? null : Math.max(0, goal.tokenBudget - goal.tokensUsed)
76
90
  };
77
91
  }
@@ -85,8 +99,8 @@ async function createGoal(sessionID, objective, tokenBudget) {
85
99
  const budget = validateBudget(tokenBudget);
86
100
  return mutate((state) => {
87
101
  const existing = state.goals[sessionID];
88
- if (existing && existing.status !== "complete") {
89
- throw new Error("cannot create a new goal because this session already has a non-complete goal");
102
+ if (existing && !isClosed(existing.status)) {
103
+ throw new Error("cannot create a new goal because this session already has a non-closed goal");
90
104
  }
91
105
  const now = nowSeconds();
92
106
  const goal = {
@@ -98,6 +112,9 @@ async function createGoal(sessionID, objective, tokenBudget) {
98
112
  timeUsedSeconds: 0,
99
113
  createdAt: now,
100
114
  updatedAt: now,
115
+ completionEvidence: null,
116
+ blocker: null,
117
+ closedAt: null,
101
118
  lastAccountedAt: now,
102
119
  autoTurns: 0,
103
120
  lastContinuationAt: null
@@ -106,20 +123,32 @@ async function createGoal(sessionID, objective, tokenBudget) {
106
123
  return snapshot(goal);
107
124
  });
108
125
  }
109
- async function setGoalStatus(sessionID, status) {
126
+ async function closeGoal(sessionID, input) {
110
127
  return mutate((state) => {
111
128
  const goal = state.goals[sessionID];
112
129
  if (!goal)
113
130
  throw new Error("cannot update goal because this session has no goal");
114
131
  accountWallClock(goal);
115
- goal.status = status;
116
- goal.updatedAt = nowSeconds();
117
- goal.lastAccountedAt = status === "active" ? goal.updatedAt : null;
132
+ const now = nowSeconds();
133
+ goal.status = input.status;
134
+ goal.updatedAt = now;
135
+ goal.closedAt = now;
136
+ goal.lastAccountedAt = null;
137
+ if (input.status === "complete") {
138
+ goal.completionEvidence = validateEvidence(input.evidence, "completion evidence");
139
+ goal.blocker = null;
140
+ } else {
141
+ goal.blocker = validateEvidence(input.blocker, "blocker");
142
+ goal.completionEvidence = null;
143
+ }
118
144
  return snapshot(goal);
119
145
  });
120
146
  }
121
- async function completeGoal(sessionID) {
122
- return setGoalStatus(sessionID, "complete");
147
+ async function completeGoal(sessionID, evidence) {
148
+ return closeGoal(sessionID, { status: "complete", evidence });
149
+ }
150
+ async function markGoalUnmet(sessionID, blocker) {
151
+ return closeGoal(sessionID, { status: "unmet", blocker });
123
152
  }
124
153
  async function clearGoal(sessionID) {
125
154
  return mutate((state) => {
@@ -182,13 +211,18 @@ function formatGoal(goal) {
182
211
  if (!goal)
183
212
  return "No goal is set for this session.";
184
213
  const budget = goal.tokenBudget == null ? "none" : `${goal.tokensUsed} / ${goal.tokenBudget}`;
185
- return [
214
+ const lines = [
186
215
  `Objective: ${goal.objective}`,
187
216
  `Status: ${goal.status}`,
188
217
  `Tokens: ${budget}`,
189
218
  `Remaining tokens: ${goal.remainingTokens ?? "n/a"}`,
190
219
  `Time used: ${goal.timeUsedSeconds}s`
191
- ].join(`
220
+ ];
221
+ if (goal.completionEvidence)
222
+ lines.push(`Completion evidence: ${goal.completionEvidence}`);
223
+ if (goal.blocker)
224
+ lines.push(`Blocker: ${goal.blocker}`);
225
+ return lines.join(`
192
226
  `);
193
227
  }
194
228
 
@@ -218,7 +252,7 @@ Before deciding that the goal is achieved, perform a completion audit against th
218
252
  - Identify any missing, incomplete, weakly verified, or uncovered requirement.
219
253
  - Treat uncertainty as not achieved; do more verification or continue the work.
220
254
 
221
- Do not rely on intent, partial progress, elapsed effort, memory of earlier work, or a plausible final answer as proof of completion. Only call update_goal with status "complete" when the objective has actually been achieved and no required work remains.`;
255
+ Do not rely on intent, partial progress, elapsed effort, memory of earlier work, or a plausible final answer as proof of completion. Only call update_goal with status "complete" when the objective has actually been achieved and no required work remains, and include concise evidence. If the objective is impossible or blocked by missing external input, call update_goal with status "unmet" and include the blocker.`;
222
256
  }
223
257
  function budgetLimitedPrompt(goal) {
224
258
  return `The active session goal has reached its token budget.
@@ -234,13 +268,13 @@ Budget:
234
268
  - Tokens used: ${goal.tokensUsed}
235
269
  - Token budget: ${goal.tokenBudget ?? "none"}
236
270
 
237
- Goal mode has marked the goal as budgetLimited, so do not start new substantive work for this goal. Wrap up soon with useful progress, remaining work or blockers, and a clear next step. Do not call update_goal unless the goal is actually complete.`;
271
+ Goal mode has marked the goal as budgetLimited, so do not start new substantive work for this goal. Wrap up soon with useful progress, remaining work or blockers, and a clear next step. Do not call update_goal unless the goal is actually complete or objectively unmet.`;
238
272
  }
239
273
  function systemReminder(goal) {
240
274
  if (!goal) {
241
275
  return `OpenCode goal mode is available through get_goal, create_goal, and update_goal tools.
242
276
 
243
- Create a goal only when explicitly requested by the user or system/developer instructions. Do not infer goals from ordinary tasks.`;
277
+ Create a goal only when explicitly requested by the user or system/developer instructions. Do not infer goals from ordinary tasks. When closing a goal, update_goal requires evidence for status "complete" or a blocker for status "unmet".`;
244
278
  }
245
279
  if (goal.status === "active")
246
280
  return continuationPrompt(goal);
@@ -252,6 +286,13 @@ ${formatGoal(goal)}
252
286
 
253
287
  If the user resumes the goal, continue from the objective and current evidence.`;
254
288
  }
289
+ function compactionContext(goal) {
290
+ return `OpenCode goal mode is tracking this session goal across compaction.
291
+
292
+ ${formatGoal(goal)}
293
+
294
+ Preserve the goal objective, status, budget, elapsed time, token count, and any completion evidence or blocker in the compacted context. After compaction, continue from the next concrete unfinished step. Before closing the goal, audit real artifacts and command outputs; close with update_goal status "complete" only with evidence, or status "unmet" only with a concrete blocker.`;
295
+ }
255
296
 
256
297
  // src/server.ts
257
298
  var DEFAULT_MAX_AUTO_TURNS = 25;
@@ -271,6 +312,39 @@ function estimateMessages(messages) {
271
312
  return sum + (message.parts ?? []).reduce((partSum, part) => partSum + estimateTokensFromText(textFromPart(part)), 0);
272
313
  }, 0);
273
314
  }
315
+ function tokensFromRecord(value) {
316
+ if (!value || typeof value !== "object")
317
+ return;
318
+ const tokens = value;
319
+ if (typeof tokens.total === "number")
320
+ return tokens.total;
321
+ const cache = tokens.cache && typeof tokens.cache === "object" ? tokens.cache : {};
322
+ const fields = [tokens.input, tokens.output, tokens.reasoning, cache.read, cache.write];
323
+ if (!fields.some((field) => typeof field === "number"))
324
+ return;
325
+ return fields.reduce((sum, field) => sum + (typeof field === "number" && Number.isFinite(field) ? field : 0), 0);
326
+ }
327
+ function exactTokensFromPart(part) {
328
+ if (!part || typeof part !== "object")
329
+ return;
330
+ const value = part;
331
+ if (value.type !== "step-finish")
332
+ return;
333
+ return tokensFromRecord(value.tokens);
334
+ }
335
+ function exactTokensFromMessage(message) {
336
+ const partTotal = (message.parts ?? []).reduce((sum, part) => sum + (exactTokensFromPart(part) ?? 0), 0);
337
+ if (partTotal > 0)
338
+ return partTotal;
339
+ if (message.info && typeof message.info === "object") {
340
+ return tokensFromRecord(message.info.tokens);
341
+ }
342
+ return;
343
+ }
344
+ function tokensFromMessages(messages) {
345
+ const exactTotal = messages.reduce((sum, message) => sum + (exactTokensFromMessage(message) ?? 0), 0);
346
+ return exactTotal > 0 ? exactTotal : estimateMessages(messages);
347
+ }
274
348
  async function sendContinuation(client, sessionID, prompt) {
275
349
  await client.session.promptAsync({
276
350
  path: { id: sessionID },
@@ -305,14 +379,22 @@ var server = async ({ client }, options) => {
305
379
  }
306
380
  },
307
381
  update_goal: {
308
- description: "Use this tool only to mark the existing goal achieved. Set status to complete only when the objective is achieved and no required work remains. Do not mark complete merely because the budget is exhausted or because work is stopping.",
382
+ description: "Close the existing goal only after an audit against real evidence. Use status complete only when the objective is achieved and no required work remains, and include evidence. Use status unmet only when the objective cannot be achieved or is blocked, and include the blocker. Do not close a goal merely because the budget is exhausted or because work is stopping.",
309
383
  args: {
310
- status: z.enum(["complete"]).describe("Required. The only model-controlled status is complete.")
384
+ status: z.enum(["complete", "unmet"]).describe("Required. complete means achieved; unmet means blocked or impossible."),
385
+ evidence: z.string().min(1).max(4000).optional().describe("Required when status is complete. Summarize the concrete evidence verified."),
386
+ blocker: z.string().min(1).max(4000).optional().describe("Required when status is unmet. Explain the concrete blocker or impossibility.")
311
387
  },
312
- async execute(_args, context) {
313
- const goal = await completeGoal(context.sessionID);
314
- const report = goal.tokenBudget == null ? `Goal achieved. Time used: ${goal.timeUsedSeconds} seconds.` : `Goal achieved. Tokens used: ${goal.tokensUsed} of ${goal.tokenBudget}; time used: ${goal.timeUsedSeconds} seconds.`;
315
- return JSON.stringify({ goal, remaining_tokens: goal.remainingTokens, completion_budget_report: report }, null, 2);
388
+ async execute(args, context) {
389
+ const input = args;
390
+ if (input.status === "complete") {
391
+ const goal2 = await completeGoal(context.sessionID, input.evidence ?? "");
392
+ const report2 = goal2.tokenBudget == null ? `Goal achieved. Time used: ${goal2.timeUsedSeconds} seconds. Evidence: ${goal2.completionEvidence}.` : `Goal achieved. Tokens used: ${goal2.tokensUsed} of ${goal2.tokenBudget}; time used: ${goal2.timeUsedSeconds} seconds. Evidence: ${goal2.completionEvidence}.`;
393
+ return JSON.stringify({ goal: goal2, remaining_tokens: goal2.remainingTokens, completion_budget_report: report2 }, null, 2);
394
+ }
395
+ const goal = await markGoalUnmet(context.sessionID, input.blocker ?? "");
396
+ const report = goal.tokenBudget == null ? `Goal unmet. Time used: ${goal.timeUsedSeconds} seconds. Blocker: ${goal.blocker}.` : `Goal unmet. Tokens used: ${goal.tokensUsed} of ${goal.tokenBudget}; time used: ${goal.timeUsedSeconds} seconds. Blocker: ${goal.blocker}.`;
397
+ return JSON.stringify({ goal, remaining_tokens: goal.remainingTokens, unmet_report: report }, null, 2);
316
398
  }
317
399
  },
318
400
  clear_goal: {
@@ -327,13 +409,19 @@ var server = async ({ client }, options) => {
327
409
  const sessionID = "sessionID" in input && typeof input.sessionID === "string" ? input.sessionID : output.messages.find((message) => typeof message.info.sessionID === "string")?.info.sessionID;
328
410
  if (!sessionID)
329
411
  return;
330
- await accountUsage(sessionID, estimateMessages(output.messages));
412
+ await accountUsage(sessionID, tokensFromMessages(output.messages));
331
413
  },
332
414
  async "experimental.chat.system.transform"(input, output) {
333
415
  if (typeof input.sessionID !== "string")
334
416
  return;
335
417
  output.system.push(systemReminder(await getGoal(input.sessionID)));
336
418
  },
419
+ async "experimental.session.compacting"(input, output) {
420
+ const goal = await getGoal(input.sessionID);
421
+ if (!goal)
422
+ return;
423
+ output.context.push(compactionContext(goal));
424
+ },
337
425
  async event({ event }) {
338
426
  if (!autoContinue || event.type !== "session.idle")
339
427
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prevalentware/opencode-goal-plugin",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Codex-style long-running goal mode for OpenCode.",
5
5
  "keywords": [
6
6
  "opencode",
package/src/tui.tsx CHANGED
@@ -5,12 +5,15 @@ import { createMemo, Show } from "solid-js"
5
5
  type GoalSnapshot = {
6
6
  sessionID: string
7
7
  objective: string
8
- status: "active" | "paused" | "budgetLimited" | "complete"
8
+ status: "active" | "paused" | "budgetLimited" | "complete" | "unmet"
9
9
  tokenBudget: number | null
10
10
  tokensUsed: number
11
11
  timeUsedSeconds: number
12
12
  createdAt: number
13
13
  updatedAt: number
14
+ completionEvidence?: string | null
15
+ blocker?: string | null
16
+ closedAt?: number | null
14
17
  remainingTokens: number | null
15
18
  }
16
19
 
@@ -58,42 +61,84 @@ function clearGoalPrompt() {
58
61
  return "Clear the current session goal by calling clear_goal. Report whether a goal was cleared."
59
62
  }
60
63
 
64
+ function showCustomBudget(api: TuiPluginApi, sessionID: string, objective: string) {
65
+ const DialogPrompt = api.ui.DialogPrompt
66
+ api.ui.dialog.replace(() =>
67
+ DialogPrompt({
68
+ title: "Custom budget",
69
+ placeholder: "Positive integer",
70
+ onConfirm(rawBudget) {
71
+ const value = rawBudget.trim()
72
+ const budget = Number(value)
73
+ if (!Number.isInteger(budget) || budget <= 0) {
74
+ toast(api, "Token budget must be a positive integer.", "warning")
75
+ return
76
+ }
77
+ void sendGoalPrompt(api, sessionID, createGoalPrompt(objective, budget))
78
+ .then(() => {
79
+ api.ui.dialog.clear()
80
+ toast(api, "Goal request sent.", "success")
81
+ })
82
+ .catch((error) => toast(api, error instanceof Error ? error.message : String(error), "error"))
83
+ },
84
+ onCancel() {
85
+ api.ui.dialog.clear()
86
+ },
87
+ }),
88
+ )
89
+ }
90
+
91
+ function showBudgetSelect(api: TuiPluginApi, sessionID: string, objective: string) {
92
+ const DialogSelect = api.ui.DialogSelect
93
+ const budgets = [
94
+ { title: "No budget", value: "none", budget: null, description: "Track progress without a token limit" },
95
+ { title: "250K", value: "250k", budget: 250_000, description: "Short focused goal" },
96
+ { title: "1M", value: "1m", budget: 1_000_000, description: "Default long-running goal" },
97
+ { title: "2M", value: "2m", budget: 2_000_000, description: "Large investigation or migration" },
98
+ { title: "Custom", value: "custom", budget: undefined, description: "Enter an exact token budget" },
99
+ ]
100
+ api.ui.dialog.replace(() =>
101
+ DialogSelect({
102
+ title: "Token budget",
103
+ placeholder: "Choose a budget",
104
+ options: budgets.map((item) => ({
105
+ title: item.title,
106
+ value: item.value,
107
+ description: item.description,
108
+ onSelect: () => {
109
+ if (item.budget === undefined) {
110
+ showCustomBudget(api, sessionID, objective)
111
+ return
112
+ }
113
+ void sendGoalPrompt(api, sessionID, createGoalPrompt(objective, item.budget))
114
+ .then(() => {
115
+ api.ui.dialog.clear()
116
+ toast(api, "Goal request sent.", "success")
117
+ })
118
+ .catch((error) => toast(api, error instanceof Error ? error.message : String(error), "error"))
119
+ },
120
+ })),
121
+ onSelect(option) {
122
+ option.onSelect?.()
123
+ },
124
+ }),
125
+ )
126
+ }
127
+
61
128
  function showSetGoal(api: TuiPluginApi, sessionID: string) {
62
129
  const DialogPrompt = api.ui.DialogPrompt
63
130
  api.ui.dialog.setSize("medium")
64
131
  api.ui.dialog.replace(() =>
65
132
  DialogPrompt({
66
133
  title: "Set goal",
67
- placeholder: "Concrete objective",
134
+ placeholder: "Objective, scope, non-goals, verification path",
68
135
  onConfirm(objective) {
69
136
  const trimmed = objective.trim()
70
137
  if (!trimmed) {
71
138
  toast(api, "Goal objective is required.", "warning")
72
139
  return
73
140
  }
74
- api.ui.dialog.replace(() =>
75
- DialogPrompt({
76
- title: "Token budget",
77
- placeholder: "Optional positive integer",
78
- onConfirm(rawBudget) {
79
- const value = rawBudget.trim()
80
- const budget = value ? Number(value) : null
81
- if (budget != null && (!Number.isInteger(budget) || budget <= 0)) {
82
- toast(api, "Token budget must be a positive integer.", "warning")
83
- return
84
- }
85
- void sendGoalPrompt(api, sessionID, createGoalPrompt(trimmed, budget))
86
- .then(() => {
87
- api.ui.dialog.clear()
88
- toast(api, "Goal request sent.", "success")
89
- })
90
- .catch((error) => toast(api, error instanceof Error ? error.message : String(error), "error"))
91
- },
92
- onCancel() {
93
- api.ui.dialog.clear()
94
- },
95
- }),
96
- )
141
+ showBudgetSelect(api, sessionID, trimmed)
97
142
  },
98
143
  onCancel() {
99
144
  api.ui.dialog.clear()
@@ -189,12 +234,15 @@ function isGoalSnapshot(value: unknown): value is GoalSnapshot {
189
234
  if (!isRecord(value)) return false
190
235
  if (typeof value.sessionID !== "string") return false
191
236
  if (typeof value.objective !== "string") return false
192
- if (!["active", "paused", "budgetLimited", "complete"].includes(String(value.status))) return false
237
+ if (!["active", "paused", "budgetLimited", "complete", "unmet"].includes(String(value.status))) return false
193
238
  if (value.tokenBudget !== null && typeof value.tokenBudget !== "number") return false
194
239
  if (typeof value.tokensUsed !== "number") return false
195
240
  if (typeof value.timeUsedSeconds !== "number") return false
196
241
  if (typeof value.createdAt !== "number") return false
197
242
  if (typeof value.updatedAt !== "number") return false
243
+ if (value.completionEvidence != null && typeof value.completionEvidence !== "string") return false
244
+ if (value.blocker != null && typeof value.blocker !== "string") return false
245
+ if (value.closedAt != null && typeof value.closedAt !== "number") return false
198
246
  if (value.remainingTokens !== null && typeof value.remainingTokens !== "number") return false
199
247
  return true
200
248
  }
@@ -231,13 +279,16 @@ function goalFromSession(api: TuiPluginApi, sessionID: string) {
231
279
  function formatGoal(goal: GoalSnapshot | null) {
232
280
  if (!goal) return "No recent goal state found in this session."
233
281
  const budget = goal.tokenBudget == null ? "none" : `${goal.tokensUsed} / ${goal.tokenBudget}`
234
- return [
282
+ const lines = [
235
283
  `Objective: ${goal.objective}`,
236
284
  `Status: ${goal.status}`,
237
285
  `Tokens: ${budget}`,
238
286
  `Remaining tokens: ${goal.remainingTokens ?? "n/a"}`,
239
287
  `Time used: ${goal.timeUsedSeconds}s`,
240
- ].join("\n")
288
+ ]
289
+ if (goal.completionEvidence) lines.push(`Completion evidence: ${goal.completionEvidence}`)
290
+ if (goal.blocker) lines.push(`Blocker: ${goal.blocker}`)
291
+ return lines.join("\n")
241
292
  }
242
293
 
243
294
  function GoalSidebar(props: { api: TuiPluginApi; sessionID: string }) {
@@ -266,7 +317,7 @@ function GoalSidebar(props: { api: TuiPluginApi; sessionID: string }) {
266
317
  <Show when={goal()}>
267
318
  {(value: () => GoalSnapshot) => (
268
319
  <Show
269
- when={value().status === "complete"}
320
+ when={value().status === "complete" || value().status === "unmet"}
270
321
  fallback={
271
322
  <box>
272
323
  <text fg={theme().text}>
@@ -280,8 +331,9 @@ function GoalSidebar(props: { api: TuiPluginApi; sessionID: string }) {
280
331
  </box>
281
332
  }
282
333
  >
283
- <text fg={theme().primary}>
284
- <b>Goal achieved</b> ({formatDurationBadge(value().timeUsedSeconds)})
334
+ <text fg={value().status === "complete" ? theme().primary : theme().textMuted}>
335
+ <b>{value().status === "complete" ? "Goal achieved" : "Goal unmet"}</b> (
336
+ {formatDurationBadge(value().timeUsedSeconds)})
285
337
  </text>
286
338
  </Show>
287
339
  )}