Live plan, % done, time left and a one-line 'now' for long tasks, in a pane and on the working row.

What it does: a live progress view for long tasks: the plan, % done, time elapsed, time left, and one line on what Claude is doing right now. It replaces scrolling through verbose updates.
Where you see it:
◎ 64% ⣿⣿⣿⣿⣷⣀⣀ 3/5 ~8m — <now> while working, then ✓ done 6m53s until your next prompt.hooks/row.tsx on its own frame clock: `` ⠹ 37% ━━━━━━ 2/4 · 4m 33s · ~12m left · plan › ` a spinner that cycles through cyans and teals (the dock's Plan color), the % done, a short bar shaded cyan to teal with a glint sweeping through it (the cell being filled pulses), the step number, time on the task, and time left: green on pace, amber with step running long once the current step has run past its share, grey while it's a rough guess. plan › (or a click anywhere on the row) opens the Mission pane with the whole checklist and the "now" trail; it then reads hide plan. While the row shows, the dock above the prompt stays out of the way; once Claude stops, it shows the same progress there. The app adds its own tokens and status after it. On the terminal the row keeps the engine's own animation, with its text rewritten: 37% · step 2/4 · 4m 33s · ~12m left`./mission, toggles a pane with the checklist (✓ done with time taken, ▶ current, ○ to do) and a Trail: the last "now" lines with timestamps, a condensed history of the work.How it works: the mod gives Claude a tool, mcp__mission-control__plan. For tasks with 3+ steps or over ~2 minutes, Claude posts a plan (3-8 steps, each sized 1-3), then updates it as steps start and finish. If Claude re-sends the whole list instead (the same title, or each step with a status, even written as title or plain strings), the board takes the new list and keeps its clock; a list it can't read changes nothing. The mod does the maths:
How accurate is it? Every time the board shows a time left, the mod notes it. When the task finishes, it compares each guess with the time that was actually left and keeps the median miss (a guess of 4m when 2m were left is 100% off; a 1-minute floor stops the last few seconds from skewing it). The Mission pane then shows Past estimates here: off by ~X% on average (N tasks), averaged over the project's last 20 tasks. Expect early guesses to be off by 2-3×; they tighten once a step or two finishes, and as the project builds up history.
Cost: a short hidden reminder (~60 tokens) is attached to each prompt you send, plus a few small tool calls per task. No extra model calls.
Data saved: ~/.claude/mods-data/mission-control/<session>.json: finished tasks' size, duration and estimate miss, used to calibrate future estimates and to score them.
Limits: the estimate is only as good as the plan. If scope grows, Claude adds steps and the % drops. It's an estimate, not a promise.
hooks/register.tsx 587 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Mission, MissionStep, MissionSummary } from '../types'
5
6// Claude reports its plan through a tool this mod registers; the mod turns it
7// into % done and time left, shown on the working row, in a band above the
8// prompt, and in a pane (/mission). Nothing here guesses at the model's
9// thinking: the "Now" line is what Claude itself says it is doing.
10
11const TOOL = 'mcp__mission-control__plan'
12const PANE = 'mission'
13// An unfinished board Claude stops updating goes after this many turns.
14const STALE_TURNS = 2
15const DEFAULT_MS_PER_UNIT = 120_000
16const mission = atom({ plugin: 'mission-control', key: 'mission' } as const, null)
17const summaryRef = atom({ plugin: 'mission-control', key: 'summary' } as const, null)
18const isOpen = atom({ plugin: 'mission-control', key: 'isOpen' } as const, false)
19
20type PlanInput = {
21 title?: string
22 steps?: unknown[]
23 add?: unknown[]
24 start?: number
25 done?: number[]
26 now?: string
27 finished?: boolean
28}
29
30type MissionRecord = { project: string; title: string; units: number; activeMs: number; at: number; estimateError?: number }
31// Past tasks a project's accuracy averages over.
32const ACCURACY_TASKS = 20
33// Under a minute left, a few seconds off is a large share: the floor keeps the score fair.
34const ERROR_FLOOR_MS = 60_000
35
36type Progress = {
37 pct: number
38 elapsed: number
39 leftMs: number | null
40 isRough: boolean
41 /** The current step has run past the time its size gives it at this pace. */
42 isOver: boolean
43 stepNo: number
44 total: number
45}
46
47const DESCRIPTION = [
48 'Mission board: shows the person a live plan, % done and time left while you work.',
49 'Use it for any task with 3+ steps or likely to take more than ~2 minutes; skip it for quick answers.',
50 'Start: call once with `title` (<= 60 chars) and `steps` (3-8, plain words, each with `size` 1 small / 2 medium / 3 large).',
51 'Then call it as you go: `start` with the step number you begin, `done` with step numbers you finished,',
52 'and `now`: one plain line (<= 90 chars) on what you are doing or figuring out right now.',
53 'Use `add` if the scope grows. Call with `finished: true` when the whole task is complete.',
54 'To update, prefer `start`/`done`; sending `steps` again under the same title restates the plan (each step its `text` and `status`) and keeps its clock.',
55 'Keep calls brief; they cost the person nothing and replace a long scroll of updates.',
56].join(' ')
57
58const SCHEMA = {
59 type: 'object',
60 properties: {
61 title: { type: 'string', description: 'Short task title. Give it with steps to start a new plan.' },
62 steps: {
63 type: 'array',
64 description: '3-8 steps. Giving steps starts a new plan, or restates the running one under its title.',
65 items: {
66 type: 'object',
67 properties: {
68 text: { type: 'string' },
69 size: { type: 'integer', minimum: 1, maximum: 3 },
70 status: { type: 'string', enum: ['todo', 'doing', 'done'] },
71 },
72 required: ['text'],
73 },
74 },
75 add: {
76 type: 'array',
77 description: 'Steps to append when the scope grows.',
78 items: {
79 type: 'object',
80 properties: { text: { type: 'string' }, size: { type: 'integer', minimum: 1, maximum: 3 } },
81 required: ['text'],
82 },
83 },
84 start: { type: 'integer', description: 'Step number (1-based) you are starting now.' },
85 done: { type: 'array', items: { type: 'integer' }, description: 'Step numbers (1-based) now finished.' },
86 now: { type: 'string', description: 'One plain line: what you are doing or working out right now.' },
87 finished: { type: 'boolean', description: 'True when the whole task is complete.' },
88 },
89}
90
91const oneLine = (text: string, max: number) => {
92 const flat = text.replace(/\s+/g, ' ').trim()
93 return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
94}
95
96const fmt = (ms: number) => {
97 const s = Math.max(0, Math.round(ms / 1000))
98 if (s < 60) return `${s}s`
99 const m = Math.floor(s / 60)
100 if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`
101 return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
102}
103
104const fmtLeft = (ms: number) => {
105 const m = Math.round(ms / 60000)
106 if (m < 1) return '<1m'
107 if (m < 60) return `${m}m`
108 return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
109}
110
111const bar = (pct: number, width: number) => {
112 const full = Math.round(pct * width)
113 return '█'.repeat(full) + '░'.repeat(Math.max(0, width - full))
114}
115
116const projectOf = (dir: string) => {
117 const main = dir.replace(/[\\/]\.claude[\\/]worktrees[\\/].*$/i, '')
118 return main.split(/[\\/]/).filter(Boolean).pop() ?? main
119}
120
121// Claude sometimes sends steps in another shape: a `title` instead of `text`,
122// plain strings, a `status` on each. Every step it names is read, so a plan is
123// never emptied by its own wording.
124const STEP_TEXT = ['text', 'title', 'content', 'name', 'step'] as const
125
126const parseStep = (raw: unknown) => {
127 const s = (raw !== null && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
128 const key = STEP_TEXT.find(k => typeof s[k] === 'string' && (s[k] as string).trim() !== '')
129 const text = typeof raw === 'string' ? raw : key ? (s[key] as string) : ''
130 const size = Number(s.size)
131 const status = String(s.status ?? '').toLowerCase().replace(/[\s_-]/g, '')
132 return {
133 text: oneLine(text, 80),
134 size: Number.isFinite(size) && size > 0 ? Math.min(3, Math.max(1, Math.round(size))) : null,
135 status: /^(done|complete|completed|finished)$/.test(status)
136 ? ('done' as const)
137 : /^(doing|inprogress|active|running|started|current)$/.test(status)
138 ? ('doing' as const)
139 : /^(todo|pending|notstarted|open)$/.test(status)
140 ? ('todo' as const)
141 : null,
142 }
143}
144
145// `old`: the plan being restated, whose steps keep their size and times when the text matches.
146const toSteps = (list: unknown[], old: MissionStep[] = []): MissionStep[] =>
147 list
148 .map(parseStep)
149 .filter(p => p.text !== '')
150 .slice(0, 12)
151 .map(p => {
152 const prev = old.find(o => o.text === p.text)
153 const status = p.status ?? prev?.status ?? 'todo'
154 return {
155 text: p.text,
156 size: p.size ?? prev?.size ?? 1,
157 status,
158 ...(status !== 'todo' && prev?.startActive !== undefined ? { startActive: prev.startActive } : {}),
159 ...(status === 'done' && prev?.status === 'done' && prev.doneActive !== undefined ? { doneActive: prev.doneActive } : {}),
160 }
161 })
162
163const activeAt = (m: Mission, now: number) => m.activeMs + (m.runningSince === null ? 0 : now - m.runningSince)
164
165const progress = (m: Mission, now: number): Progress => {
166 const elapsed = activeAt(m, now)
167 const units = m.steps.reduce((a, s) => a + s.size, 0) || 1
168 const doneSteps = m.steps.filter(s => s.status === 'done')
169 const doneUnits = doneSteps.reduce((a, s) => a + s.size, 0)
170 const lastDone = Math.max(0, ...doneSteps.map(s => s.doneActive ?? 0))
171
172 // Pace (ms per unit of size): what this mission has shown so far, blended
173 // with earlier missions while little is done; a flat default before either.
174 const observed = doneUnits > 0 && lastDone > 0 ? lastDone / doneUnits : null
175 const trust = Math.min(1, (doneUnits / units) * 2)
176 const pace =
177 observed !== null && m.calibration !== null
178 ? observed * trust + m.calibration * (1 - trust)
179 : observed ?? m.calibration ?? DEFAULT_MS_PER_UNIT
180 const isRough = observed === null && m.calibration === null
181
182 const cur = m.steps.find(s => s.status === 'doing')
183 const spent = cur ? elapsed - (cur.startActive ?? elapsed) : 0
184 const partial = cur ? Math.min(0.9, spent / (pace * cur.size)) * cur.size : 0
185
186 const pct = m.isFinished ? 1 : Math.min(0.99, (doneUnits + partial) / units)
187 const leftMs = m.isFinished ? 0 : Math.max(0, (units - doneUnits - partial) * pace)
188 const stepNo = cur ? m.steps.indexOf(cur) + 1 : Math.min(m.steps.length, doneSteps.length + 1)
189 const isOver = !m.isFinished && cur !== undefined && spent > pace * cur.size
190 return { pct, elapsed, leftMs, isRough, isOver, stepNo, total: m.steps.length }
191}
192
193const summary = (m: Mission, p: Progress) => {
194 if (m.isFinished) return `done in ${fmt(p.elapsed)}`
195 const left = p.leftMs === null ? 'estimating' : `${p.isRough ? '≈' : '~'}${fmtLeft(p.leftMs)} left`
196 return `step ${p.stepNo}/${p.total} · ${fmt(p.elapsed)} · ${left}`
197}
198
199const dataDir = async ($: EngineInterface) => {
200 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.'
201 return `${home.replace(/\\/g, '/')}/.claude/mods-data/mission-control`
202}
203
204const loadRecords = async ($: EngineInterface): Promise<MissionRecord[]> => {
205 const dir = await dataDir($)
206 if (!(await $.fs.exists(dir))) return []
207 const out: MissionRecord[] = []
208 for (const f of await $.fs.list(dir)) {
209 if (!f.name.endsWith('.json')) continue
210 try {
211 out.push(...(JSON.parse(await $.fs.read(`${dir}/${f.name}`)) as MissionRecord[]))
212 } catch {
213 // skip a file that does not parse
214 }
215 }
216 return out.sort((a, b) => a.at - b.at)
217}
218
219const calibrationFor = (records: MissionRecord[], project: string) => {
220 const all = records.filter(r => r.units > 0 && r.activeMs > 0)
221 const mine = all.filter(r => r.project === project)
222 const pool = (mine.length >= 3 ? mine : all).slice(-20).map(r => r.activeMs / r.units)
223 if (pool.length === 0) return null
224 return [...pool].sort((a, b) => a - b)[Math.floor(pool.length / 2)]!
225}
226
227// How far off the board's time-left estimates were, scored at the finish: the
228// median, over every estimate given, of |estimated - actual time left| / actual.
229const scoreEstimates = (m: Mission, totalMs: number) => {
230 const errors = (m.estimates ?? [])
231 .filter(e => e.atActive < totalMs)
232 .map(e => Math.abs(e.leftMs - (totalMs - e.atActive)) / Math.max(totalMs - e.atActive, ERROR_FLOOR_MS))
233 if (errors.length === 0) return null
234 return [...errors].sort((a, b) => a - b)[Math.floor(errors.length / 2)]!
235}
236
237// The project's average miss over its last scored tasks, for "Past estimates: off by ~X%".
238const accuracyOf = (records: MissionRecord[], project: string) => {
239 const scored = records.filter(r => r.project === project && typeof r.estimateError === 'number').slice(-ACCURACY_TASKS)
240 if (scored.length === 0) return null
241 const avg = scored.reduce((a, r) => a + (r.estimateError ?? 0), 0) / scored.length
242 return { avgErrorPct: Math.round(avg * 100), tasks: scored.length }
243}
244
245// Saves the finished task, then shows the project's accuracy with it counted.
246const finishRecord = async ($: EngineInterface, m: Mission) => {
247 await saveRecord($, m)
248 const accuracy = accuracyOf(await loadRecords($), m.project)
249 await update($, mission, cur => (cur && cur.id === m.id ? { ...cur, accuracy } : cur))
250 await publish($)
251}
252
253const saveRecord = async ($: EngineInterface, m: Mission) => {
254 const file = `${await dataDir($)}/${await $.session.id()}.json`
255 const list = (await $.fs.exists(file)) ? (JSON.parse(await $.fs.read(file)) as MissionRecord[]) : []
256 const err = scoreEstimates(m, m.activeMs)
257 list.push({
258 project: m.project,
259 title: m.title,
260 units: m.steps.reduce((a, s) => a + s.size, 0),
261 activeMs: m.activeMs,
262 at: await $.clock.now(),
263 ...(err === null ? {} : { estimateError: Math.round(err * 1000) / 1000 }),
264 })
265 await $.fs.write(file, JSON.stringify(list.slice(-500)))
266}
267
268// What the dock shows; written on every change and once a second while running.
269const publish = async ($: EngineInterface) => {
270 const m = await read($, mission)
271 // A board with no steps (left by an older version) shows nothing until Claude posts its plan.
272 if (!m || m.steps.length === 0) {
273 if ((await read($, summaryRef)) !== null) await update($, summaryRef, () => null)
274 return
275 }
276 const p = progress(m, await $.clock.now())
277 const fresh: MissionSummary = {
278 title: m.title,
279 pct: p.pct,
280 stepNo: p.stepNo,
281 total: p.total,
282 left: m.isFinished || p.leftMs === null ? null : `${p.isRough ? '≈' : '~'}${fmtLeft(p.leftMs)}`,
283 elapsedMs: p.elapsed,
284 isFinished: m.isFinished,
285 now: m.now,
286 accuracy: m.accuracy ?? null,
287 }
288 await update($, summaryRef, () => fresh)
289}
290
291const togglePane = async ($: EngineInterface) => {
292 if ((await $.ui.panes()).some(p => p.id === PANE)) {
293 await $.ui.close({ id: PANE })
294 // A plugin's own close does not reach its own ui.close hook: reset the flag here.
295 await update($, isOpen, () => false)
296 return 'Mission pane closed.'
297 }
298 const opened = await $.ui.open({ id: PANE, title: 'Mission' })
299 if (opened.isPlaced) await update($, isOpen, () => true)
300 else $.ui.toast(`Mission pane could not open (${opened.reason}).`)
301 return opened.isPlaced ? 'Mission pane opened.' : `Could not open the pane (${opened.reason}).`
302}
303
304let touched = false
305let idleTurns = 0
306
307export const register: Register = on => {
308 on('session.start', async ($, e, next) => {
309 await $.tool.register({ name: 'plan', description: DESCRIPTION, inputSchema: SCHEMA })
310 await $.command.register({ name: 'mission', description: 'Open the Mission Control pane (plan, % done, time left)' })
311 // Once a second while a mission runs, so elapsed and time left tick in the dock and pane.
312 $.clock.every(1000, async () => {
313 const m = await read($, mission)
314 if (m && m.runningSince !== null && !m.isFinished) await publish($)
315 })
316 await publish($)
317 return next(e)
318 })
319
320 // A short reminder beside each prompt the person sends, so long tasks get a board.
321 on('prompt.submit', async ($, e, next) => {
322 if (e.origin.kind === 'plugin') return next(e)
323 const m = await read($, mission)
324 if (m?.isFinished) {
325 await update($, mission, () => null)
326 await publish($)
327 }
328 const open = m && !m.isFinished
329 ? ` The board currently shows "${m.title}" (${m.steps.filter(s => s.status === 'done').length}/${m.steps.length} steps done): update it if you continue that work, or post new steps for a new task.`
330 : ''
331 const note =
332 `[mission-control] For multi-step work (3+ steps or over ~2 minutes), first post a short plan with ${TOOL}, ` +
333 `then update it as steps start and finish, with a one-line "now". Skip it for quick answers.${open}`
334 return next({ ...e, context: [...(e.context ?? []), note] })
335 })
336
337 on('turn.start', async ($, e, next) => {
338 touched = false
339 const now = await $.clock.now()
340 await update($, mission, m => (m && !m.isFinished && m.runningSince === null ? { ...m, runningSince: now } : m))
341 await publish($)
342 return next(e)
343 })
344
345 on('turn.complete', async ($, e, next) => {
346 if (e.agentId !== undefined) return next(e)
347 const now = await $.clock.now()
348 await update($, mission, m =>
349 m && m.runningSince !== null ? { ...m, activeMs: activeAt(m, now), runningSince: null } : m,
350 )
351 const m = await read($, mission)
352 idleTurns = touched ? 0 : idleTurns + 1
353 if (m && !m.isFinished && idleTurns >= STALE_TURNS) await update($, mission, () => null)
354 await publish($)
355 return next(e)
356 })
357
358 on('tool.call', { tool: TOOL }, async ($, e) => {
359 if (e.agentId !== undefined) return { result: 'The mission board follows the main session only; nothing changed.' }
360 const input = e as unknown as PlanInput
361 touched = true
362 idleTurns = 0
363 const now = await $.clock.now()
364 let m = await read($, mission)
365
366 if (Array.isArray(input.steps) && input.steps.length > 0) {
367 const title = oneLine(input.title ?? 'Current task', 60)
368 const parsed = input.steps.map(parseStep).filter(p => p.text !== '')
369 // Nothing readable in the list: the board stays as it was.
370 if (parsed.length === 0) return { result: 'Nothing changed: give each step its words in `text`, e.g. { "text": "Fix the parser", "size": 2 }.' }
371 // The running plan sent again (same title, or steps carrying a status):
372 // the new list replaces its steps, and it keeps its clock and trail.
373 if (m && !m.isFinished && (title === m.title || parsed.some(p => p.status !== null))) {
374 m = { ...m, title, steps: toSteps(input.steps, m.steps) }
375 } else {
376 const project = projectOf(await $.session.cwd())
377 const records = await loadRecords($).catch(() => [])
378 m = {
379 id: String(now),
380 title,
381 project,
382 steps: toSteps(input.steps),
383 now: '',
384 notes: [],
385 activeMs: 0,
386 runningSince: now,
387 isFinished: false,
388 calibration: calibrationFor(records, project),
389 estimates: [],
390 accuracy: accuracyOf(records, project),
391 }
392 }
393 }
394 if (!m) return { result: 'No plan yet: call again with a title and steps.' }
395
396 const t = activeAt(m, now)
397 const steps = m.steps.map(s => ({ ...s }))
398 if (Array.isArray(input.add)) steps.push(...toSteps(input.add))
399 for (const n of input.done ?? []) {
400 const s = steps[n - 1]
401 if (s && s.status !== 'done') Object.assign(s, { status: 'done', doneActive: t, startActive: s.startActive ?? t })
402 }
403 if (typeof input.start === 'number') {
404 const k = input.start - 1
405 // Plans run in order: starting step k closes the ones before it.
406 steps.forEach((s, i) => {
407 if (i < k && s.status !== 'done') Object.assign(s, { status: 'done', doneActive: t, startActive: s.startActive ?? t })
408 if (i > k && s.status === 'doing') s.status = 'todo'
409 })
410 const s = steps[k]
411 if (s && s.status !== 'done') Object.assign(s, { status: 'doing', startActive: t })
412 }
413 // Steps that arrived already done or under way (a restated list) count from now.
414 for (const s of steps) {
415 if (s.status !== 'todo') s.startActive ??= t
416 if (s.status === 'done') s.doneActive ??= t
417 }
418
419 let upd: Mission = {
420 ...m,
421 steps,
422 title: input.title && !input.steps ? oneLine(input.title, 60) : m.title,
423 runningSince: m.runningSince ?? now,
424 }
425 if (typeof input.now === 'string' && input.now.trim() !== '') {
426 const text = oneLine(input.now, 90)
427 const last = upd.notes.at(-1)
428 upd = { ...upd, now: text, notes: last?.text === text ? upd.notes : [...upd.notes, { atActive: t, text }].slice(-12) }
429 }
430 // The time left the board shows now: kept, to score against the real finish.
431 if (input.finished !== true) {
432 const est = progress(upd, now).leftMs
433 if (est !== null) upd = { ...upd, estimates: [...(upd.estimates ?? []), { atActive: t, leftMs: Math.round(est) }].slice(-40) }
434 }
435 if (input.finished === true) {
436 upd = {
437 ...upd,
438 steps: upd.steps.map(s => (s.status === 'done' ? s : { ...s, status: 'done', doneActive: t, startActive: s.startActive ?? t })),
439 isFinished: true,
440 activeMs: t,
441 runningSince: null,
442 now: '',
443 }
444 const done = upd
445 void finishRecord($, done).catch(() => undefined)
446 }
447
448 const final = upd
449 await update($, mission, () => final)
450 await publish($)
451 const p = progress(final, now)
452 return { result: `Board updated: ${Math.round(p.pct * 100)}%, ${summary(final, p)}.` }
453 })
454
455 // The working row ("Working…" on the desktop) carries the progress while Claude
456 // works (the dock leaves its own progress out meanwhile). The desktop draws an
457 // animated, colored row with a "plan ›" link (hooks/row.tsx, on its own frame
458 // clock); elsewhere the row's text is rewritten. Named `Client` with a
459 // literal module path: the engine finds the row's module by reading this source.
460 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
461 await read($, summaryRef)
462 const m = await read($, mission)
463 if (!m || m.isFinished || m.runningSince === null || m.steps.length === 0 || e.props.message !== null) return next(e)
464 const p = progress(m, await $.clock.now())
465 const elements = $.ui.resolve(e)
466 const Client = 'Client' in elements && e.surface === 'desktop' ? elements.Client : undefined
467 if (Client) {
468 const { Box } = elements
469 const row = {
470 pct: p.pct,
471 stepNo: p.stepNo,
472 total: p.total,
473 elapsed: fmt(p.elapsed),
474 left: p.leftMs === null ? null : `${p.isRough ? '≈' : '~'}${fmtLeft(p.leftMs)} left`,
475 pace: p.isRough ? 'rough' : p.isOver ? 'slow' : 'ok',
476 isPlanOpen: await read($, isOpen),
477 }
478 return (
479 <Box flexDirection="row">
480 <Client key="mission:row" module="./row.tsx" width="100%" props={row} />
481 </Box>
482 )
483 }
484 const message = `${Math.round(p.pct * 100)}% · ${summary(m, p)}`
485 return next({ ...e, props: { ...e.props, message } })
486 })
487
488 // A click on the working row's "plan ›" toggles the Mission pane.
489 on('ui.message', { component: 'Spinner' }, async ($, e, next) => {
490 if (e.element !== 'mission:row') return next(e)
491 if ((e.data as { open?: unknown } | null)?.open === 'plan') await togglePane($)
492 return {}
493 })
494
495 on('command.run', { command: 'mission' }, async $ => ({ text: await togglePane($) }))
496
497 // A press on the dock's Plan button.
498 on('state.set', { plugin: 'mod-hub', key: 'signal' }, async ($, e, next) => {
499 const done = await next(e)
500 // Only a write that landed: update() retries a missed one, which would toggle twice.
501 const sig = e.value as { target?: string; action?: string } | null
502 if (done.value?.isSet !== true || !sig) return done
503 if (sig.target === 'mission-control') await togglePane($)
504 // Review: show the panel, never close it.
505 else if (sig.action === 'review' && !(await $.ui.panes()).some(p => p.id === PANE)) await togglePane($)
506 return done
507 })
508
509 on('ui.close', { id: PANE }, async ($, e, next) => {
510 const done = await next(e)
511 await update($, isOpen, () => false)
512 return done
513 })
514
515 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
516 const { Box, Text, Button } = $.ui.resolve(e)
517 await read($, summaryRef)
518 const m = await read($, mission)
519 if (!m) {
520 return (
521 <Box flexDirection="column">
522 <Text dimColor>No mission yet. Claude posts a plan here when it starts a multi-step task.</Text>
523 <Button key="close" label="Close" role="dismiss" onPress={() => togglePane($)} />
524 </Box>
525 )
526 }
527 const p = progress(m, await $.clock.now())
528 const width = Math.max(10, Math.min(30, (e.props.bodyColumns ?? 40) - 8))
529 return (
530 <Box flexDirection="column" gap={1}>
531 <Box flexDirection="column">
532 <Text bold>{m.title}</Text>
533 <Box flexDirection="row" gap={1}>
534 <Text color={m.isFinished ? 'success' : 'claude'}>{bar(p.pct, width)}</Text>
535 <Text bold>{Math.round(p.pct * 100)}%</Text>
536 </Box>
537 <Text dimColor>
538 elapsed {fmt(p.elapsed)}
539 {m.isFinished ? ' · finished' : p.leftMs === null ? '' : ` · ${p.isRough ? 'about' : '~'} ${fmtLeft(p.leftMs)} left`}
540 {p.isRough && !m.isFinished ? ' (rough until a step finishes)' : ''}
541 </Text>
542 {m.accuracy && (
543 <Text dimColor>
544 Past estimates here: off by ~{m.accuracy.avgErrorPct}% on average ({m.accuracy.tasks} {m.accuracy.tasks === 1 ? 'task' : 'tasks'})
545 </Text>
546 )}
547 </Box>
548 <Box flexDirection="column">
549 {m.steps.map((s, i) => {
550 const took =
551 s.status === 'done' && s.doneActive !== undefined && s.startActive !== undefined && s.doneActive > s.startActive
552 ? ` ${fmt(s.doneActive - s.startActive)}`
553 : s.status === 'doing' && s.startActive !== undefined
554 ? ` ${fmt(p.elapsed - s.startActive)}…`
555 : ''
556 return (
557 <Text
558 dimColor={s.status === 'done'}
559 bold={s.status === 'doing'}
560 color={s.status === 'doing' ? 'claude' : undefined}
561 wrap="truncate-end"
562 >
563 {s.status === 'done' ? '✓' : s.status === 'doing' ? '▶' : '○'} {i + 1}. {s.text}
564 {took}
565 </Text>
566 )
567 })}
568 </Box>
569 {m.notes.length > 0 && (
570 <Box flexDirection="column">
571 <Text bold>Trail</Text>
572 {m.notes.slice(-8).map(n => (
573 <Text dimColor={n.text !== m.now} wrap="truncate-end">
574 {fmt(n.atActive).padStart(7)} {n.text}
575 </Text>
576 ))}
577 </Box>
578 )}
579 <Box flexDirection="row" gap={1}>
580 <Button key="close" label="Close" role="dismiss" onPress={() => togglePane($)} />
581 {m.isFinished && <Button key="clear" label="Clear" onPress={async () => { await update($, mission, () => null); await publish($) }} />}
582 </Box>
583 </Box>
584 )
585 })
586}
587hooks/row.tsx 109 lines1import type { ClientModule } from 'claude-code'
2
3// The working row on the desktop, drawn by the surface so it animates on its
4// own frame clock (nothing goes back to the hooks module per frame): a spinner
5// that cycles color, the % done, a short gradient bar with a glint running
6// through it and a pulsing leading edge, the step number, time on task, time
7// left (amber when the step runs long), and a "plan ›" link. A click on the
8// row tells the hooks module ({ open: 'plan' }), which toggles the Mission
9// pane with the whole checklist.
10
11type Props = {
12 pct: number
13 stepNo: number
14 total: number
15 elapsed: string
16 left: string | null
17 pace: 'ok' | 'slow' | 'rough'
18 isPlanOpen: boolean
19}
20type State = { frame: number; isHover: boolean }
21
22const FRAME_MS = 80
23const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
24// The spinner's colors (cyan to teal, the dock's Plan color), cycled smoothly;
25// the bar runs from the first to the fourth.
26const HUES = ['#22d3ee', '#38bdf8', '#67e8f9', '#2dd4bf', '#5eead4', '#2dd4bf', '#67e8f9', '#38bdf8']
27const BAR_CELLS = 6
28const TRACK = '#3f3f46'
29const GLINT = '#f0f9ff'
30const ACCENT = '#22d3ee'
31const ACCENT_HOVER = '#a5f3fc'
32const PACE = { ok: '#4ade80', slow: '#facc15', rough: '#9ca3af' } as const
33
34const rgb = (hex: string) => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16))
35const mix = (from: string, to: string, t: number) => {
36 const a = rgb(from)
37 const b = rgb(to)
38 return `#${a.map((v, i) => Math.round(v + (b[i]! - v) * t).toString(16).padStart(2, '0')).join('')}`
39}
40const hueAt = (frame: number) => {
41 const pos = (frame / 3) % HUES.length
42 const k = Math.floor(pos)
43 return mix(HUES[k]!, HUES[(k + 1) % HUES.length]!, pos - k)
44}
45// 0..1, once round every ~1.5 s.
46const pulse = (frame: number) => (Math.sin(frame / 3) + 1) / 2
47
48const Row: ClientModule<Props, State> = (props, surface) => {
49 const { Box, Text } = surface.elements
50 if (surface.state === undefined) {
51 surface.setState({ frame: 0, isHover: false })
52 surface.every(FRAME_MS, () => {
53 const cur = surface.state
54 if (cur) surface.setState({ ...cur, frame: cur.frame + 1 })
55 })
56 }
57 const st = surface.state ?? { frame: 0, isHover: false }
58 const frame = st.frame
59
60 // Set on every draw so the listener always reads the current state.
61 surface.onPointer(e => {
62 const now = surface.state ?? { frame: 0, isHover: false }
63 if (e.type === 'up') surface.post({ open: 'plan' })
64 const isHover = e.type !== 'leave'
65 if (isHover !== now.isHover) surface.setState({ ...now, isHover })
66 })
67
68 // Full cells shade cyan to teal with a glint sweeping across them; the
69 // cell being filled pulses; the rest is track.
70 const pct = Math.max(0, Math.min(1, props.pct))
71 const full = Math.floor(pct * BAR_CELLS)
72 const sweep = (frame % (full + 6)) - 3
73 const cells = Array.from({ length: BAR_CELLS }, (_, i) => {
74 if (i < full) {
75 const base = mix(HUES[0]!, HUES[3]!, i / (BAR_CELLS - 1))
76 const d = Math.abs(i - sweep)
77 return <Text color={d === 0 ? mix(base, GLINT, 0.7) : d === 1 ? mix(base, GLINT, 0.3) : base}>━</Text>
78 }
79 if (i === full && pct < 1) return <Text color={mix(TRACK, HUES[3]!, 0.3 + 0.7 * pulse(frame))}>━</Text>
80 return <Text color={TRACK}>━</Text>
81 })
82 const left = props.left === null ? 'estimating…' : props.pace === 'slow' ? `${props.left} · step running long` : props.left
83
84 return (
85 <Box flexDirection="row" gap={1} flexWrap="nowrap" overflow="hidden" alignItems="center" height={1}>
86 <Text bold color={hueAt(frame)}>
87 {SPIN[frame % SPIN.length]}
88 </Text>
89 <Text bold color="#f4f4f5">
90 {`${Math.round(pct * 100)}%`}
91 </Text>
92 <Box flexDirection="row" flexShrink={0}>
93 {cells}
94 </Box>
95 <Text bold color={ACCENT}>{`${props.stepNo}/${props.total}`}</Text>
96 <Text color="#71717a">·</Text>
97 <Text color="#9ca3af">{props.elapsed}</Text>
98 <Text color="#71717a">·</Text>
99 <Text color={props.left === null ? PACE.rough : PACE[props.pace]}>{left}</Text>
100 <Text color="#71717a">·</Text>
101 <Text color={st.isHover ? ACCENT_HOVER : ACCENT} underline={st.isHover}>
102 {props.isPlanOpen ? 'hide plan' : 'plan ›'}
103 </Text>
104 </Box>
105 )
106}
107
108export default Row
109types/index.d.ts 51 lines1export type MissionStep = {
2 text: string
3 /** Relative effort, 1 (small) to 3 (large). */
4 size: number
5 status: 'todo' | 'doing' | 'done'
6 /** Active time (ms into the mission) when the step started / finished. */
7 startActive?: number
8 doneActive?: number
9}
10
11export type MissionNote = { atActive: number; text: string }
12
13export type Mission = {
14 id: string
15 title: string
16 project: string
17 steps: MissionStep[]
18 now: string
19 notes: MissionNote[]
20 /** Active time banked before `runningSince`; turns waiting on the person do not count. */
21 activeMs: number
22 runningSince: number | null
23 isFinished: boolean
24 /** ms per unit of step size from earlier missions, when there are any. */
25 calibration: number | null
26 /** Each time-left estimate the board gave (active ms in, ms left), scored when the task finishes. */
27 estimates?: { atActive: number; leftMs: number }[]
28 /** How far off this project's past estimates were; null until a task with estimates finishes. */
29 accuracy?: { avgErrorPct: number; tasks: number } | null
30}
31
32/** What the dock shows, refreshed every second while a mission runs. */
33export type MissionSummary = {
34 title: string
35 pct: number
36 stepNo: number
37 total: number
38 left: string | null
39 elapsedMs: number
40 isFinished: boolean
41 now: string
42 /** How far off past time-left estimates were in this project; null until a task with estimates finishes. */
43 accuracy?: { avgErrorPct: number; tasks: number } | null
44}
45
46declare module 'claude-code' {
47 interface PluginState {
48 'mission-control': { mission: Mission | null; summary: MissionSummary | null; isOpen: boolean }
49 }
50}
51