A GOAL band above the prompt that reads Claude's own task list: the goal's name, a 6-cell bar of tasks done, percent, time, ETA and the task running now; a…

A ◆ GOAL row above the prompt that reads Claude's own task list. There is no goal to set by hand: the row appears when the main loop writes a task list, and hides two minutes after the last task is done.
◆ GOAL Build the mods ▰▰▰▰▱▱ 4/6 · 67% · 18m · ETA ~9m │ ▶ Write the deck
◆ GOAL Build the mods ▰▰▰▰▱▱ 4/6 · 67% · 31m · ETA ~9m │ ▶ Write the deck 10m (amber: dragging)
◆ GOAL Build the mods ▰▰▰▰▰▰ 6/6 · done ✓ in 34m (green, two minutes)
TodoWrite (the whole list each call), TaskCreate and TaskUpdate (one task each call; deleted removes it) and TaskList (brings the goal's own tasks up to date). Only the main loop counts: subagents' lists are ignored.done/total · percent · minutes since the list was made, then ETA ~9m once a task has finished: the finished tasks' average time × the tasks left. A task that never showed as running counts from the finish before it.done ✓ in 12m in green for two minutes, then the row hides, and one toast: "Goal done in 12m, 4 tasks". A new list after that starts a new goal./clear before they were done), kept in the store across sessions.zm-60-goal and takes its slot in the zehui-mods stack order (SYS 10, PLAN 20, HANDOFF 30, CACHE 40, AGENTS 50, GOAL 60, HERE 70, NEXT 80); other bands follow.| Command | What it does |
|---|---|
/goals | Opens the pane: every task (▶ running, ✓ done, ○ open) with its minutes, then the last five goals. |
/goals on / off | Shows or hides the row and its done toast (kept across sessions; overrides the band option). |
/goals clear | Forgets the current goal and the history. |
/goals help | Explains the row and the arguments. |
The bare /goal is built into Claude Code, hence /goals. The row has no buttons, so nothing in it needs the band focused.
band: on (default) or off.
claude plugin install goal-meter@zehui-mods --scope user
See PROVENANCE.md for where it came from.
hooks/register.tsx 293 lines1// The goal-meter mod: the ◆ GOAL row (row.tsx) and the /goals pane
2// (pane.tsx), read from Claude's own task list. Each task-list tool call of
3// the main loop (TodoWrite, TaskCreate, TaskUpdate, TaskList) changes the one
4// goal; subagents' lists are ignored. The goal is named after the prompt that
5// started the turn its list was made in. Finished goals and replaced ones go
6// to a five-long history in the store.
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, PluginOptions, Register, Timer } from 'claude-code'
9
10import type { Goal, GoalRecord, GoalTaskStatus } from '../types'
11import {
12 COMMAND,
13 DONE_SHOW_MS,
14 PANE,
15 PANE_TITLE,
16 STORE_BAND,
17 STORE_HISTORY,
18 TICK_MS,
19 applyCreate,
20 applyList,
21 applyTodos,
22 applyUpdate,
23 doneToast,
24 historyOf,
25 isBandOn,
26 isShown,
27 withRecord,
28} from './kit'
29import type { TodoItem } from './kit'
30import { drawPane } from './pane'
31import { drawRow } from './row'
32
33const goal = atom({ plugin: 'goal-meter', key: 'goal' } as const, null)
34const history = atom({ plugin: 'goal-meter', key: 'history' } as const, [])
35const now = atom({ plugin: 'goal-meter', key: 'now' } as const, 0)
36const prompt = atom({ plugin: 'goal-meter', key: 'prompt' } as const, '')
37const band = atom({ plugin: 'goal-meter', key: 'band' } as const, '')
38
39// The row's tick while a goal shows, and the one-shot that hides a finished
40// goal's row on time. A reload drops both with the environment; session.start
41// (raised again for a fresh load) starts the tick again while a goal shows.
42let ticker: Timer | null = null
43let linger: Timer | null = null
44
45const stopTick = () => {
46 ticker?.cancel()
47 ticker = null
48}
49
50function startTick($: EngineInterface): void {
51 if (ticker) return
52 ticker = $.clock.every(TICK_MS, () => {
53 void tick($).catch(() => {})
54 })
55}
56
57/** Moves `now` so the row's minutes redraw; stops once the row has nothing to show. */
58async function tick($: EngineInterface): Promise<void> {
59 const at = await $.clock.now()
60 await update($, now, () => at)
61 if (!isShown(await read($, goal), at)) stopTick()
62}
63
64/** The store's history as it stands now (another session may have written it since this one started); null with no store. */
65async function storedHistory($: EngineInterface): Promise<GoalRecord[] | null> {
66 try {
67 return historyOf(await $.store.get(STORE_HISTORY))
68 } catch {
69 return null
70 }
71}
72
73// Records go in one at a time, so two in this process never write over each other.
74let saving: Promise<void> = Promise.resolve()
75
76function remember($: EngineInterface, g: Goal): Promise<void> {
77 const run = saving.then(() => save($, g))
78 saving = run.catch(() => {})
79 return run
80}
81
82/** The goal into the history: merged with the store's list read fresh, written back, mirrored into state. */
83async function save($: EngineInterface, g: Goal): Promise<void> {
84 const stored = await storedHistory($)
85 const list = await update($, history, h => withRecord(stored ?? h, g))
86 try {
87 await $.store.set(STORE_HISTORY, list)
88 } catch {
89 // No store: the pane still lists them for this process.
90 }
91}
92
93/** Writes a /goals setting to the store; the words to add to the reply when it could not be kept. */
94async function persist($: EngineInterface, key: string, value: unknown): Promise<string> {
95 try {
96 await $.store.set(key, value)
97 return ''
98 } catch {
99 return ' (not saved for the next session)'
100 }
101}
102
103type Change = (prev: Goal | null, at: number, prompt: string) => { goal: Goal | null; replaced?: Goal | null }
104
105/**
106 * One change to the goal: written, the tick started while it shows, a
107 * replaced unfinished goal kept in the history, and on its finish the
108 * history, the one toast and the hide two minutes on.
109 */
110async function change($: EngineInterface, options: PluginOptions, fn: Change): Promise<void> {
111 const at = await $.clock.now()
112 const p = await read($, prompt)
113 // Worked out inside update(): when a parallel task-list call writes first,
114 // update() runs `fn` again on that newer goal instead of overwriting it.
115 const seen: { prev: Goal | null; out: ReturnType<Change> } = { prev: null, out: { goal: null } }
116 const next = await update($, goal, cur => {
117 seen.prev = cur
118 seen.out = fn(cur, at, p)
119 return seen.out.goal
120 })
121 const prev = seen.prev
122 const replaced = seen.out.replaced ?? null
123 await update($, now, () => at)
124 // A finished goal is in the history from its finish; an unfinished one goes in as it is replaced.
125 if (replaced && replaced.tasks.length > 0 && replaced.doneAt === undefined) await remember($, replaced)
126 const wasDone = prev !== null && next !== null && prev.startedAt === next.startedAt && prev.doneAt !== undefined
127 if (next && next.doneAt !== undefined && !wasDone) {
128 await remember($, next)
129 if (isBandOn(await read($, band), options.band)) $.ui.toast(doneToast(next))
130 linger?.cancel()
131 linger = $.clock.after(DONE_SHOW_MS, () => {
132 void tick($).catch(() => {})
133 })
134 }
135 if (isShown(next, at)) startTick($)
136}
137
138const STATUSES = new Set(['pending', 'in_progress', 'completed'])
139const isStatus = (s: unknown): s is GoalTaskStatus => typeof s === 'string' && STATUSES.has(s)
140const str = (v: unknown): string => (typeof v === 'string' ? v : typeof v === 'number' ? String(v) : '')
141const obj = (v: unknown): Record<string, unknown> => (typeof v === 'object' && v !== null ? (v as Record<string, unknown>) : {})
142
143/** A task-list call that went through, read defensively: what it said and what it answered. */
144async function record($: EngineInterface, options: PluginOptions, input: Record<string, unknown>, result: unknown): Promise<void> {
145 const out = obj(result)
146 switch (input.tool) {
147 case 'TodoWrite': {
148 if (!Array.isArray(input.todos)) return
149 const todos: TodoItem[] = input.todos
150 .map(obj)
151 .filter(t => typeof t.content === 'string' && isStatus(t.status))
152 .map(t => ({ content: str(t.content), status: t.status as GoalTaskStatus }))
153 await change($, options, (prev, at, p) => applyTodos(prev, todos, at, p))
154 return
155 }
156 case 'TaskCreate': {
157 const task = obj(out.task)
158 const id = str(task.id)
159 const subject = str(input.subject) || str(task.subject)
160 if (!id || !subject) return
161 await change($, options, (prev, at, p) => applyCreate(prev, id, subject, at, p))
162 return
163 }
164 case 'TaskUpdate': {
165 const taskId = str(input.taskId)
166 // A refusal answers success: false without isError; the task did not change.
167 if (!taskId || out.success === false) return
168 const status = input.status === 'deleted' || isStatus(input.status) ? input.status : undefined
169 const subject = str(input.subject) || undefined
170 await change($, options, (prev, at) => ({
171 goal: applyUpdate(prev, { taskId, ...(status ? { status } : {}), ...(subject ? { subject } : {}) }, at),
172 }))
173 return
174 }
175 case 'TaskList': {
176 if (!Array.isArray(out.tasks)) return
177 const listed = out.tasks
178 .map(obj)
179 .filter(t => str(t.id) && isStatus(t.status))
180 .map(t => ({ id: str(t.id), status: t.status as GoalTaskStatus }))
181 await change($, options, (prev, at) => ({ goal: applyList(prev, listed, at) }))
182 return
183 }
184 }
185}
186
187const USAGE = `Usage: /${COMMAND} [on | off | clear | help]`
188
189const HELP = [
190 `/${COMMAND}: the GOAL row reads Claude's own task list (TodoWrite, or TaskCreate and TaskUpdate) in the main loop; there is nothing to set by hand.`,
191 ` /${COMMAND} open the pane: every task (▶ running, ✓ done, ○ open) with its minutes, then the last five goals`,
192 ` /${COMMAND} on|off show or hide the GOAL row and its done toast`,
193 ` /${COMMAND} clear forget the current goal and the history`,
194 ` /${COMMAND} help this text`,
195 'Row: name ▰▰▰▰▱▱ 4/6 · 67% · 18m · ETA ~9m │ ▶ running task. ETA is the finished tasks\' average times the tasks left.',
196 'The bar turns amber when the running task has taken twice that average. Done: "done ✓ in 12m" for two minutes, then the row hides.',
197 'Subagents\' task lists are ignored.',
198].join('\n')
199
200export const register: Register = (on, options) => {
201 on('session.start', async ($, e, next) => {
202 const started = await next(e)
203 await $.command.register({
204 name: 'goals',
205 description: 'GOAL row: open the goals pane (every task and the last five goals), turn the row on or off, clear, or help',
206 argumentHint: 'on | off | clear | help',
207 })
208 try {
209 const stored = await $.store.get(STORE_BAND)
210 await update($, band, () => (stored === 'on' || stored === 'off' ? stored : ''))
211 const past = historyOf(await $.store.get(STORE_HISTORY))
212 await update($, history, () => past)
213 } catch {
214 // No store: the option applies and the history starts empty.
215 }
216 const at = await $.clock.now()
217 await update($, now, () => at)
218 stopTick()
219 if (isShown(await read($, goal), at)) startTick($)
220 return started
221 })
222
223 // The prompt that starts a main-loop turn names a goal whose list that turn makes.
224 on('turn.start', async ($, e, next) => {
225 const started = await next(e)
226 try {
227 if (e.agentId === undefined && e.text.trim()) {
228 const text = e.text
229 await update($, prompt, () => text.slice(0, 2_000))
230 }
231 } catch {
232 // Best effort: the name falls back to the last prompt.
233 }
234 return started
235 })
236
237 // The task-list tools of the main loop. Read after the tool ran (TaskCreate's
238 // id is in its result); a call that was refused or failed changes nothing.
239 on('tool.call', { tool: ['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskList'] }, async ($, e, next) => {
240 const ran = await next(e)
241 if (e.agentId !== undefined || ran.deny !== undefined || ran.isError) return ran
242 try {
243 await record($, options, e as unknown as Record<string, unknown>, ran.result)
244 } catch {
245 // The goal is best effort: the tool's result goes on whatever happens here.
246 }
247 return ran
248 }).catch(($, e, next) => next(e)) // A failure here never blocks the tool.
249
250 // A /clear or an in-process resume puts another conversation in this
251 // process: its goal is not this one. An unfinished goal goes to the history.
252 on('session.end', async ($, e, next) => {
253 const result = await next(e)
254 if (e.reason === 'clear' || e.reason === 'resume') {
255 await change($, options, prev => ({ goal: null, replaced: prev })).catch(() => {})
256 }
257 return result
258 })
259
260 // The command and its matcher are spelled literally, so the host reads it as answering its own command.
261 on('command.run', { command: 'goals' }, async ($, e) => {
262 const [verb = ''] = e.args.trim().toLowerCase().split(/\s+/).filter(Boolean)
263 switch (verb) {
264 case '': {
265 const at = await $.clock.now()
266 await update($, now, () => at)
267 await $.ui.open({ id: PANE, title: PANE_TITLE })
268 return { text: 'Goals pane opened.' }
269 }
270 case 'on':
271 case 'off': {
272 await update($, band, () => verb)
273 const note = await persist($, STORE_BAND, verb)
274 return { text: `${verb === 'on' ? 'GOAL line on.' : `GOAL line off (no row, no toast; /${COMMAND} still opens the pane).`}${note}` }
275 }
276 case 'clear': {
277 await update($, goal, () => null)
278 await update($, history, () => [])
279 stopTick()
280 const note = await persist($, STORE_HISTORY, [])
281 return { text: `Goal and history cleared.${note}` }
282 }
283 case 'help':
284 return { text: HELP }
285 default:
286 return { text: USAGE }
287 }
288 })
289
290 on('ui.render', { component: 'Pane', requestId: 'goals' }, ($, e) => drawPane($, e))
291 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => drawRow($, e, () => next(e), options))
292}
293hooks/kit.ts 357 lines1// Shared by register.tsx, row.tsx and pane.tsx: the look, how a task-list
2// tool call changes the goal, the figures the row shows and the small
3// formatters. Pure functions only; the state atoms are declared again in each
4// file that reads or writes them (the engine reads a state source only from a
5// const of the calling file).
6import type { Goal, GoalRecord, GoalTask, GoalTaskStatus } from '../types'
7import type { RenderElement, RenderNode } from 'claude-code'
8
9export const CHIP = ' ◆ GOAL ' // padded to the widest zehui-mods chip (HANDOFF) so the colour bars line up
10export const CHIP_FG = '#0b0b0f'
11export const CHIP_BG = '#facc15'
12export const GREEN = '#22c55e'
13export const AMBER = '#f59e0b'
14export const BAR_CELLS = 6
15/** The row's tick while a goal shows. */
16export const TICK_MS = 15_000
17/** A finished goal's `done ✓` stays this long, then the row hides. */
18export const DONE_SHOW_MS = 2 * 60_000
19/** A running task is dragging once it has taken this many times the finished tasks' average. */
20export const SLOW_FACTOR = 2
21export const NAME_MAX = 50
22export const HISTORY_MAX = 5
23export const COMMAND = 'goals'
24export const PANE = 'goals'
25export const PANE_TITLE = 'Goals'
26export const STORE_BAND = 'goal-meter.band'
27export const STORE_HISTORY = 'goal-meter.history'
28
29const MINUTE = 60_000
30
31// --- terminal columns
32
33/** East Asian Wide and Fullwidth ranges (CJK, Hangul, fullwidth forms) and emoji: two columns each. */
34const WIDE: readonly (readonly [number, number])[] = [
35 [0x1100, 0x115f],
36 [0x2e80, 0xa4cf],
37 [0xac00, 0xd7a3],
38 [0xf900, 0xfaff],
39 [0xfe30, 0xfe4f],
40 [0xff00, 0xff60],
41 [0xffe0, 0xffe6],
42 [0x1f300, 0x1faff],
43 [0x20000, 0x3fffd],
44]
45
46/** The columns one character takes in the terminal: 2 for a wide one, else 1. */
47export function charCells(ch: string): number {
48 const cp = ch.codePointAt(0) ?? 0
49 return WIDE.some(([lo, hi]) => cp >= lo && cp <= hi) ? 2 : 1
50}
51
52/** The columns a string takes in the terminal. */
53export const cellsOf = (s: string): number => [...s].reduce((w, ch) => w + charCells(ch), 0)
54
55// --- the goal's name
56
57/** The first non-empty line of a prompt, cut to `max` terminal columns (an ellipsis marks the cut). */
58export function goalName(prompt: string, max = NAME_MAX): string {
59 const line = prompt.split(/\r?\n/).map(l => l.trim()).find(l => l.length > 0) ?? ''
60 const flat = line.replace(/\s+/g, ' ')
61 if (flat.length === 0) return 'Task list'
62 if (cellsOf(flat) <= max) return flat
63 let kept = ''
64 let width = 0
65 for (const ch of flat) {
66 const w = charCells(ch)
67 if (width + w > max - 1) break
68 kept += ch
69 width += w
70 }
71 return `${kept.trimEnd()}…`
72}
73
74// --- how a task-list call changes the goal
75
76/** A task moved to `status` at `at`; its times follow the move. */
77export function withStatus(t: GoalTask, status: GoalTaskStatus, at: number): GoalTask {
78 if (t.status === status) return t
79 if (status === 'in_progress') return { id: t.id, title: t.title, status, startedAt: t.startedAt ?? at }
80 if (status === 'completed') return { ...t, status, doneAt: at }
81 return { id: t.id, title: t.title, status }
82}
83
84export const isAllDone = (g: Goal): boolean => g.tasks.length > 0 && g.tasks.every(t => t.status === 'completed')
85
86/** The goal's `doneAt`: set when its last task is completed, cleared when one opens again. */
87export function settle(g: Goal, at: number): Goal {
88 if (isAllDone(g)) return g.doneAt === undefined ? { ...g, doneAt: at } : g
89 if (g.doneAt === undefined) return g
90 const { doneAt: _, ...open } = g
91 return open
92}
93
94/** Whether a list call starts a new goal: none yet, or the last one is finished. */
95export const startsNew = (g: Goal | null): boolean => g === null || g.doneAt !== undefined
96
97const fresh = (prompt: string, at: number): Goal => ({ name: goalName(prompt), startedAt: at, tasks: [] })
98
99export type TodoItem = { content: string; status: GoalTaskStatus }
100
101const sameTitles = (g: Goal, todos: readonly TodoItem[]): boolean =>
102 g.tasks.length === todos.length && g.tasks.every((t, i) => t.title === todos[i]?.content)
103
104/**
105 * TodoWrite: the list is the whole list. Items keep their times by title; a
106 * new list after a finished goal starts a new goal (`replaced` is the old
107 * one). An empty list ends the goal.
108 */
109export function applyTodos(
110 prev: Goal | null,
111 todos: readonly TodoItem[],
112 at: number,
113 prompt: string,
114): { goal: Goal | null; replaced: Goal | null } {
115 if (todos.length === 0) return { goal: null, replaced: prev }
116 const isNew = startsNew(prev)
117 // The finished list written again as it was: the same goal, not a new one.
118 if (isNew && prev && todos.every(td => td.status === 'completed') && sameTitles(prev, todos)) return { goal: prev, replaced: null }
119 const base = isNew ? fresh(prompt, at) : (prev as Goal)
120 const unused = [...base.tasks]
121 const tasks = todos.map((td, i) => {
122 const k = unused.findIndex(t => t.title === td.content)
123 const old = k >= 0 ? unused.splice(k, 1)[0] : undefined
124 const task: GoalTask = old ?? { id: `todo-${at}-${i}`, title: td.content, status: 'pending' }
125 return withStatus(task, td.status, at)
126 })
127 return { goal: settle({ ...base, tasks }, at), replaced: isNew ? prev : null }
128}
129
130/** TaskCreate: one pending task; after a finished goal it starts a new goal. */
131export function applyCreate(
132 prev: Goal | null,
133 id: string,
134 subject: string,
135 at: number,
136 prompt: string,
137): { goal: Goal; replaced: Goal | null } {
138 const isNew = startsNew(prev)
139 const base = isNew ? fresh(prompt, at) : (prev as Goal)
140 const tasks = [...base.tasks.filter(t => t.id !== id), { id, title: subject, status: 'pending' as const }]
141 return { goal: settle({ ...base, tasks }, at), replaced: isNew ? prev : null }
142}
143
144/** TaskUpdate: a known task's status or subject; `deleted` removes it. Unknown ids change nothing. */
145export function applyUpdate(
146 prev: Goal | null,
147 u: { taskId: string; status?: GoalTaskStatus | 'deleted'; subject?: string },
148 at: number,
149): Goal | null {
150 if (!prev || !prev.tasks.some(t => t.id === u.taskId)) return prev
151 const status = u.status
152 const tasks =
153 status === 'deleted'
154 ? prev.tasks.filter(t => t.id !== u.taskId)
155 : prev.tasks.map(t => {
156 if (t.id !== u.taskId) return t
157 const titled = u.subject ? { ...t, title: u.subject } : t
158 return status ? withStatus(titled, status, at) : titled
159 })
160 return settle({ ...prev, tasks }, at)
161}
162
163/**
164 * TaskList: the statuses of the goal's own tasks, as the list reads them now.
165 * Tasks of older goals in the same list are not pulled in, and a finished
166 * goal is left alone.
167 */
168export function applyList(prev: Goal | null, listed: readonly { id: string; status: GoalTaskStatus }[], at: number): Goal | null {
169 if (!prev || prev.doneAt !== undefined) return prev
170 const byId = new Map(listed.map(t => [t.id, t.status]))
171 const tasks = prev.tasks.map(t => {
172 const s = byId.get(t.id)
173 return s ? withStatus(t, s, at) : t
174 })
175 return settle({ ...prev, tasks }, at)
176}
177
178// --- the figures
179
180/**
181 * How long a finished task took: from its start, or, when it never showed as
182 * running, from the finish before it (or the goal's start).
183 */
184export function spanOf(t: GoalTask, g: Goal): number | null {
185 if (t.doneAt === undefined) return null
186 const doneAt = t.doneAt
187 const before = g.tasks
188 .filter(o => o !== t && o.doneAt !== undefined && o.doneAt <= doneAt)
189 .reduce((m, o) => Math.max(m, o.doneAt ?? 0), 0)
190 const from = t.startedAt ?? (before > 0 ? before : g.startedAt)
191 return Math.max(0, doneAt - from)
192}
193
194/** The finished tasks' average time; null before the first finish. */
195export function averageMs(g: Goal): number | null {
196 const spans = g.tasks.filter(t => t.status === 'completed').map(t => spanOf(t, g) ?? 0)
197 return spans.length ? spans.reduce((a, b) => a + b, 0) / spans.length : null
198}
199
200export type GoalView = {
201 done: number
202 total: number
203 pct: number
204 /** Since the goal started; up to its finish once done. */
205 elapsedMs: number
206 /** Average per finished task × tasks left; null before the first finish or once done. */
207 etaMs: number | null
208 /** The in_progress task, else the first pending one. */
209 current: GoalTask | null
210 /** How long the current task has run; null when it is not running. */
211 currentMs: number | null
212 /** The running task has taken SLOW_FACTOR times the average. */
213 isSlow: boolean
214 isDone: boolean
215}
216
217export function viewOf(g: Goal, at: number): GoalView {
218 const total = g.tasks.length
219 const done = g.tasks.filter(t => t.status === 'completed').length
220 const isDone = g.doneAt !== undefined
221 const avg = averageMs(g)
222 const current = isDone ? null : (g.tasks.find(t => t.status === 'in_progress') ?? g.tasks.find(t => t.status === 'pending') ?? null)
223 const currentMs = current?.status === 'in_progress' && current.startedAt !== undefined ? Math.max(0, at - current.startedAt) : null
224 return {
225 done,
226 total,
227 pct: total > 0 ? Math.round((done / total) * 100) : 0,
228 elapsedMs: Math.max(0, (g.doneAt ?? at) - g.startedAt),
229 etaMs: !isDone && avg !== null && done > 0 ? avg * (total - done) : null,
230 current,
231 currentMs,
232 isSlow: currentMs !== null && avg !== null && avg > 0 && currentMs >= SLOW_FACTOR * avg,
233 isDone,
234 }
235}
236
237/** Whether the row shows the goal at `at`: tasks to show, and not past the done linger. */
238export const isShown = (g: Goal | null, at: number): g is Goal =>
239 g !== null && g.tasks.length > 0 && (g.doneAt === undefined || at - g.doneAt < DONE_SHOW_MS)
240
241/** Filled cells of the bar: done of total, at least one once any is done, all only when all are. */
242export function barCells(done: number, total: number, cells = BAR_CELLS): number {
243 if (total <= 0 || done <= 0) return 0
244 if (done >= total) return cells
245 return Math.min(cells - 1, Math.max(1, Math.round((done / total) * cells)))
246}
247
248// --- the history
249
250export const recordOf = (g: Goal): GoalRecord => ({
251 name: g.name,
252 startedAt: g.startedAt,
253 ...(g.doneAt !== undefined ? { doneAt: g.doneAt } : {}),
254 total: g.tasks.length,
255 done: g.tasks.filter(t => t.status === 'completed').length,
256})
257
258/** The goal into the history, newest first: replacing its own earlier record, the last HISTORY_MAX kept. */
259export const withRecord = (history: readonly GoalRecord[], g: Goal): GoalRecord[] =>
260 [recordOf(g), ...history.filter(r => r.startedAt !== g.startedAt)].slice(0, HISTORY_MAX)
261
262/** The store's history, whatever it held: well-formed records only. */
263export function historyOf(stored: unknown): GoalRecord[] {
264 if (!Array.isArray(stored)) return []
265 return stored
266 .filter(
267 (r): r is GoalRecord =>
268 typeof r === 'object' &&
269 r !== null &&
270 typeof (r as GoalRecord).name === 'string' &&
271 typeof (r as GoalRecord).startedAt === 'number' &&
272 typeof (r as GoalRecord).total === 'number' &&
273 typeof (r as GoalRecord).done === 'number',
274 )
275 .slice(0, HISTORY_MAX)
276}
277
278// --- the switch and the formatters
279
280/** The row's switch: /goals on|off when set, else the `band` option (anything but `off` is on). */
281export const isBandOn = (stored: '' | 'on' | 'off', option: unknown): boolean => (stored ? stored === 'on' : option !== 'off')
282
283/** `18m`, `1h05m`: whole minutes, rounded. */
284export function fmtMin(ms: number): string {
285 const m = Math.max(0, Math.round(ms / MINUTE))
286 return m >= 60 ? `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m` : `${m}m`
287}
288
289/** `~9m`: an estimate, never under a minute. */
290export const fmtEta = (ms: number): string => `~${fmtMin(Math.max(MINUTE, ms))}`
291
292/** `14:32`, local time. */
293export const hhmm = (ms: number): string => {
294 const d = new Date(ms)
295 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
296}
297
298/** The one toast when the last task is completed. */
299export const doneToast = (g: Goal): string =>
300 `Goal done in ${fmtMin((g.doneAt ?? g.startedAt) - g.startedAt)}, ${g.tasks.length} ${g.tasks.length === 1 ? 'task' : 'tasks'}`
301
302// Stack order of the zehui-mods band rows (plan 2026-10-20-boards-codemap-recap,
303// section 0): SYS 10, PLAN 20, HANDOFF 30, CACHE 40, AGENTS 50, GOAL 60, HERE 70,
304// NEXT 80. Copied unchanged from the canonical
305// ~/.claude/skills/building-claude-code-mods/references/stack-row.tsx; change it
306// there first, then in every band mod.
307
308/** The rows each zehui-mods band draws, by stack order. */
309export const STACK = { sys: 10, plan: 20, handoff: 30, cache: 40, agents: 50, goal: 60, here: 70, next: 80 } as const
310
311/** A row's key: `zm-<order>-<name>`. */
312export const stackKey = (name: keyof typeof STACK): string => `zm-${STACK[name]}-${name}`
313
314type Loose = { type?: unknown; props?: Record<string, unknown> | null; part?: unknown; children?: unknown }
315
316/** The order of a zehui-mods row (`zm-<order>-<name>`), or null for any other node. */
317export const stackOrderOf = (node: unknown): number | null => {
318 const key = node && typeof node === 'object' ? (node as Loose).props?.key : undefined
319 const m = typeof key === 'string' ? /^zm-(\d+)-/.exec(key) : null
320 return m ? Number(m[1]) : null
321}
322
323/**
324 * The column a band hook beneath built: a Box whose only prop is
325 * `flexDirection: 'column'` and whose direct children hold a zm row. A Box with
326 * other props (a border, a padding) is another mod's own drawing, kept whole.
327 */
328const isStack = (node: Loose): node is Loose & { children: unknown[] } =>
329 node.type === 'Box' &&
330 node.props?.flexDirection === 'column' &&
331 Object.keys(node.props).every(k => k === 'flexDirection') &&
332 Array.isArray(node.children) &&
333 node.children.some(c => stackOrderOf(c) !== null)
334
335/**
336 * `row` put in its slot among the zm rows of `below` (what `next(e)` drew):
337 * zm rows sorted by their number, then every other child in its original order.
338 * Nothing beneath (null, a failed next, the engine placeholder): the row alone.
339 */
340export function stackRow(row: RenderElement, below: unknown): RenderElement {
341 const tree = below && typeof below === 'object' && (below as Loose).type !== 'engine' ? (below as Loose) : null
342 if (!tree) return row
343 let items: unknown[] = [tree]
344 if (isStack(tree)) {
345 items = [...tree.children]
346 // The column's mark (whose drawing started there) moves to the one row
347 // that carries none, its own; the engine lets a mark move, never multiply.
348 const bare = items.filter(c => c && typeof c === 'object' && !(c as Loose).part)
349 if (tree.part && bare.length === 1) items = items.map(c => (c === bare[0] ? { ...(c as object), part: tree.part } : c))
350 }
351 const rows = items.filter(c => stackOrderOf(c) !== null)
352 const others = items.filter(c => stackOrderOf(c) === null)
353 // A stable sort: two rows of one number keep their order, this row after.
354 const sorted = [...rows, row].sort((a, b) => (stackOrderOf(a) ?? 0) - (stackOrderOf(b) ?? 0))
355 return { type: 'Box', props: { flexDirection: 'column' }, children: [...sorted, ...others] as RenderNode[] }
356}
357hooks/pane.tsx 92 lines1// The /goals pane: the goal's figures, every task (▶ running, ✓ done,
2// ○ open) with its minutes, then the last five goals. Box and Text only, so
3// every surface draws it the same.
4import { atom, read } from 'claude-code'
5import type { EngineInterface, RenderInput } from 'claude-code'
6
7import type { Goal, GoalRecord, GoalTask } from '../types'
8import { AMBER, CHIP_BG, GREEN, fmtEta, fmtMin, hhmm, spanOf, viewOf } from './kit'
9
10const goal = atom({ plugin: 'goal-meter', key: 'goal' } as const, null)
11const history = atom({ plugin: 'goal-meter', key: 'history' } as const, [])
12const now = atom({ plugin: 'goal-meter', key: 'now' } as const, 0)
13
14/** One task's line: its glyph and colour, its title and its minutes. */
15export function taskLine(t: GoalTask, g: Goal, at: number): { glyph: string; color?: string; text: string } {
16 if (t.status === 'completed') {
17 const ms = spanOf(t, g)
18 return { glyph: '✓', color: GREEN, text: `${t.title}${ms !== null ? ` ${fmtMin(ms)}` : ''}` }
19 }
20 if (t.status === 'in_progress') {
21 const ms = t.startedAt !== undefined ? Math.max(0, at - t.startedAt) : null
22 return { glyph: '▶', color: AMBER, text: `${t.title}${ms !== null ? ` ${fmtMin(ms)}` : ''}` }
23 }
24 return { glyph: '○', text: t.title }
25}
26
27/** One earlier goal's line. */
28export const recordLine = (r: GoalRecord): string =>
29 r.doneAt !== undefined
30 ? `✓ ${r.name} · ${r.total} ${r.total === 1 ? 'task' : 'tasks'} · ${fmtMin(r.doneAt - r.startedAt)} · ${hhmm(r.startedAt)}`
31 : `○ ${r.name} · ${r.done}/${r.total} · stopped · ${hhmm(r.startedAt)}`
32
33export async function drawPane($: EngineInterface, e: RenderInput<'Pane'>) {
34 const g = await read($, goal)
35 const past = (await read($, history)).filter(r => !g || r.startedAt !== g.startedAt)
36 const at = Math.max(await read($, now), g?.startedAt ?? 0)
37 const cols = e.props.bodyColumns > 0 ? e.props.bodyColumns : 40
38 const { Box, Text } = $.ui.resolve(e)
39 const v = g && g.tasks.length > 0 ? viewOf(g, at) : null
40 const summary = v
41 ? v.isDone
42 ? `${v.done}/${v.total} · done ✓ in ${fmtMin(v.elapsedMs)}`
43 : `${v.done}/${v.total} · ${v.pct}% · ${fmtMin(v.elapsedMs)}${v.etaMs !== null ? ` · ETA ${fmtEta(v.etaMs)}` : ''}`
44 : ''
45 const rule = (title: string) => `${title} ${'─'.repeat(Math.max(3, Math.min(40, cols - title.length - 1)))}`
46
47 return (
48 <Box flexDirection="column">
49 {g && v ? (
50 <Box key="goal" flexDirection="column">
51 <Text wrap="truncate-end">
52 <Text color={CHIP_BG} bold>
53 {'◆ '}
54 </Text>
55 <Text bold>{g.name}</Text>
56 </Text>
57 <Text key="goal-summary" dimColor>
58 {summary}
59 </Text>
60 {g.tasks.map(t => {
61 const line = taskLine(t, g, at)
62 return (
63 <Text key={`task-${t.id}`} wrap="truncate-end">
64 <Text {...(line.color ? { color: line.color } : { dimColor: true })}>{`${line.glyph} `}</Text>
65 <Text {...(t.status === 'pending' ? { dimColor: true } : {})}>{line.text}</Text>
66 </Text>
67 )
68 })}
69 </Box>
70 ) : (
71 <Text key="goal-none" dimColor>
72 No goal yet. The GOAL row appears when Claude writes a task list.
73 </Text>
74 )}
75 <Text key="history-head" bold>
76 {rule('Last goals')}
77 </Text>
78 {past.length === 0 ? (
79 <Text key="history-none" dimColor>
80 No earlier goals.
81 </Text>
82 ) : (
83 past.map(r => (
84 <Text key={`goal-${r.startedAt}`} wrap="truncate-end" {...(r.doneAt === undefined ? { dimColor: true } : {})}>
85 {recordLine(r)}
86 </Text>
87 ))
88 )}
89 </Box>
90 )
91}
92hooks/row.tsx 113 lines1// The ◆ GOAL row above the prompt: the goal's name, a 6-cell bar of tasks
2// done, `4/6 · 67% · 18m · ETA ~9m` and the task running now. The bar is
3// green, amber while the running task drags (twice the finished tasks'
4// average, its own minutes after it). Finished: `done ✓ in 12m` in green for
5// two minutes, then the row hides. Its row sits in the zehui-mods stack
6// by its order (stackRow); other mods' bands follow.
7import { atom, read } from 'claude-code'
8import type { EngineInterface, PluginOptions, RenderElement, RenderInput } from 'claude-code'
9
10import {
11 AMBER,
12 BAR_CELLS,
13 CHIP,
14 CHIP_BG,
15 CHIP_FG,
16 GREEN,
17 barCells,
18 cellsOf,
19 fmtEta,
20 fmtMin,
21 goalName,
22 isBandOn,
23 isShown,
24 viewOf,
25 stackKey,
26 stackRow,
27} from './kit'
28
29const goal = atom({ plugin: 'goal-meter', key: 'goal' } as const, null)
30const now = atom({ plugin: 'goal-meter', key: 'now' } as const, 0)
31const band = atom({ plugin: 'goal-meter', key: 'band' } as const, '')
32
33/** One run of text in the row: its words and how they are drawn. */
34type Seg = { text: string; color?: string; bg?: string; isBold?: boolean; isDim?: boolean }
35
36/** The row's width in terminal columns (a CJK character takes two). */
37const widthOf = (segs: readonly Seg[]): number => segs.reduce((w, s) => w + cellsOf(s.text), 0)
38
39/** A short name when the full row does not fit the band. */
40const SHORT_NAME = 24
41
42export async function drawRow(
43 $: EngineInterface,
44 e: RenderInput<'AbovePrompt'>,
45 pass: () => Promise<RenderElement>,
46 options: PluginOptions,
47) {
48 if (e.surface !== 'terminal' || e.props.hasSurvey) return pass()
49 if (!isBandOn(await read($, band), options.band)) return pass()
50 const g = await read($, goal)
51 const at = Math.max(await read($, now), g?.startedAt ?? 0)
52 if (!isShown(g, at)) return pass()
53 const v = viewOf(g, at)
54 const color = v.isSlow ? AMBER : GREEN
55
56 // Other mods' bands beneath this row; stackRow puts this row in its slot
57 // among the zehui-mods rows and never nests the engine's own placeholder.
58 const below = await pass().catch(() => null)
59
60 const n = barCells(v.done, v.total)
61 const bar: Seg[] = [
62 ...(n > 0 ? [{ text: '▰'.repeat(n), color }] : []),
63 ...(n < BAR_CELLS ? [{ text: '▱'.repeat(BAR_CELLS - n), isDim: true }] : []),
64 ]
65 const tail: Seg[] = v.isDone
66 ? [{ text: ` ${v.done}/${v.total} · ` }, { text: `done ✓ in ${fmtMin(v.elapsedMs)}`, color: GREEN, isBold: true }]
67 : [
68 {
69 text: ` ${v.done}/${v.total} · ${v.pct}% · ${fmtMin(v.elapsedMs)}${v.etaMs !== null ? ` · ETA ${fmtEta(v.etaMs)}` : ''}`,
70 },
71 ...(v.current
72 ? [
73 { text: ' │ ', isDim: true },
74 { text: `▶ ${v.current.title}`, ...(v.isSlow ? { color: AMBER } : {}) },
75 ...(v.isSlow && v.currentMs !== null ? [{ text: ` ${fmtMin(v.currentMs)}`, color: AMBER, isBold: true }] : []),
76 ]
77 : []),
78 ]
79 const build = (name: string): Seg[] => [
80 { text: CHIP, color: CHIP_FG, bg: CHIP_BG, isBold: true },
81 { text: ' ' },
82 { text: name, isBold: true },
83 { text: ' ' },
84 ...bar,
85 ...tail,
86 ]
87 const cols = e.props.bodyColumns > 0 ? e.props.bodyColumns : 80
88 let segs = build(g.name)
89 if (widthOf(segs) > cols) segs = build(goalName(g.name, SHORT_NAME))
90
91 const { Box, Text } = $.ui.resolve(e)
92 const row = (
93 <Box key={stackKey('goal')} flexDirection="row">
94 <Box key="goal-line" flexDirection="row">
95 <Text wrap="truncate-end">
96 {segs.map((s, i) => (
97 <Text
98 key={`seg-${i}`}
99 {...(s.color ? { color: s.color } : {})}
100 {...(s.bg ? { backgroundColor: s.bg } : {})}
101 {...(s.isBold ? { bold: true } : {})}
102 {...(s.isDim ? { dimColor: true } : {})}
103 >
104 {s.text}
105 </Text>
106 ))}
107 </Text>
108 </Box>
109 </Box>
110 )
111 return stackRow(row, below)
112}
113types/index.d.ts 52 lines1// The goal-meter mod's state contract.
2
3export type GoalTaskStatus = 'pending' | 'in_progress' | 'completed'
4
5/** One task of the main loop's task list, as the goal keeps it. */
6export type GoalTask = {
7 /** TaskCreate's id, or one minted for a TodoWrite item. */
8 id: string
9 title: string
10 status: GoalTaskStatus
11 /** When it went in_progress; absent while pending, or when it went straight to completed. */
12 startedAt?: number
13 /** When it was completed. */
14 doneAt?: number
15}
16
17/** The goal: one task list of the main loop, from its first task to its last finish. */
18export type Goal = {
19 /** The first line of the prompt that started the turn the list was made in, cut to 50 characters. */
20 name: string
21 startedAt: number
22 tasks: GoalTask[]
23 /** When the last task was completed; absent while any is open. */
24 doneAt?: number
25}
26
27/** A goal as the history keeps it ($.store 'goal-meter.history', the last five). */
28export type GoalRecord = {
29 name: string
30 startedAt: number
31 doneAt?: number
32 total: number
33 done: number
34}
35
36declare module 'claude-code' {
37 interface PluginState {
38 'goal-meter': {
39 /** The goal in this conversation; null before the first task list, after /goals clear or a /clear. */
40 goal: Goal | null
41 /** The last five goals, newest first (a mirror of the store, so the pane redraws). */
42 history: GoalRecord[]
43 /** The tick's last time, so the row's minutes move. */
44 now: number
45 /** The first line of the last main-loop prompt that started a turn. */
46 prompt: string
47 /** /goals on|off as kept in the store; '' while the plugin option applies. */
48 band: '' | 'on' | 'off'
49 }
50 }
51}
52