SLOPSHOPPER

goal-meter

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…

newpanebandguardcommandtoast
v0.1.1MITupdated 2026-10-20ZH-L1N/agent-window-mods/goal-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · goal-meter
│ ┃ Goals ✕ › fix the failing auth test and add an audit log call │ ┃ No goal yet. The GOAL row appears when │ ┃ Claude writes a task list. ⏺ Read(src/auth.ts) │ ┃ Last goals ⎿ Read 6 lines │ ┃ ──────────────────────────────────────── ⏺ Update(src/auth.ts) │ ┃ No earlier goals. ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /goals │ ⎿ goal-meter: Goals pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Goals
No goal yet. The GOAL row appears when Claude writes a task list. Last goals ──────────────────────────────────────── No earlier goals.
README

goal-meter

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)
  • Source: the task-list tools of this build: 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.
  • Name: the first line of the prompt that started the turn the list was made in, cut to 50 terminal columns (24 on a narrow band; a CJK character takes two).
  • Bar: 6 cells of tasks done out of all. Green; amber when the running task has taken twice the finished tasks' average, with its own minutes after it.
  • Figures: 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.
  • Task: the running task, or the first open one when none runs.
  • Done: 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.
  • History: the last five goals, finished or stopped (replaced by a new list or a /clear before they were done), kept in the store across sessions.
  • Stack: the row is keyed 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.

/goals

CommandWhat it does
/goalsOpens the pane: every task (▶ running, ✓ done, ○ open) with its minutes, then the last five goals.
/goals on / offShows or hides the row and its done toast (kept across sessions; overrides the band option).
/goals clearForgets the current goal and the history.
/goals helpExplains 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.

Options

band: on (default) or off.

Install (local)

claude plugin install goal-meter@zehui-mods --scope user

See PROVENANCE.md for where it came from.

Source 5 files
hooks/register.tsx 293 lines
1// 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}
293
hooks/kit.ts 357 lines
1// 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}
357
hooks/pane.tsx 92 lines
1// 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}
92
hooks/row.tsx 113 lines
1// 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}
113
types/index.d.ts 52 lines
1// 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